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:
+594
@@ -0,0 +1,594 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
InvalidPluginPackageAutomationPublicationError,
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES,
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
PluginPackageAutomationPublicationUnavailableError,
|
||||
assertPluginPackageAutomationPublicationSuccessor,
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize,
|
||||
normalizePluginPackageAutomationPublication,
|
||||
normalizePluginPackageAutomationPublicationRecoveryCursor,
|
||||
type PluginPackageAutomationPublication,
|
||||
type PluginPackageAutomationPublicationRecoveryPage,
|
||||
type PluginPackageAutomationPublicationRepository,
|
||||
type PluginPackageAutomationPublicationRecoverySource,
|
||||
type PluginPackageAutomationPublicationStartGuard,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageAutomationPublicationError(message);
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function targetIdentity(projectId: unknown, packageName: unknown): {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
} {
|
||||
if (typeof projectId !== 'string' || !IDENTIFIER.test(projectId)) {
|
||||
return invalid('projectId is invalid');
|
||||
}
|
||||
if (typeof packageName !== 'string' || !PACKAGE_NAME.test(packageName)) {
|
||||
return invalid('packageName is invalid');
|
||||
}
|
||||
return { projectId, packageName };
|
||||
}
|
||||
|
||||
function publicationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('publicationDigest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(
|
||||
publication: Readonly<PluginPackageAutomationPublication>,
|
||||
): string {
|
||||
const value = JSON.stringify(publication);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES
|
||||
) {
|
||||
return invalid('publication exceeds the durable JSON budget');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageAutomationPublicationError ||
|
||||
error instanceof PluginPackageAutomationPublicationConflictError ||
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageAutomationPublicationConflictError(
|
||||
'durable publication chain changed',
|
||||
);
|
||||
}
|
||||
return new PluginPackageAutomationPublicationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageAutomationPublicationRepository
|
||||
implements
|
||||
PluginPackageAutomationPublicationRepository,
|
||||
PluginPackageAutomationPublicationRecoverySource,
|
||||
PluginPackageAutomationPublicationStartGuard
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
#parse(row: Row): Readonly<PluginPackageAutomationPublication> {
|
||||
try {
|
||||
const publication = normalizePluginPackageAutomationPublication(
|
||||
JSON.parse(text(row, 'publicationJson')) as
|
||||
PluginPackageAutomationPublication,
|
||||
);
|
||||
if (
|
||||
publication.publicationDigest !==
|
||||
text(row, 'publicationDigest') ||
|
||||
publication.target.projectId !== text(row, 'projectId') ||
|
||||
publication.target.packageName !== text(row, 'packageName') ||
|
||||
publication.target.installationId !== text(row, 'installationId') ||
|
||||
publication.target.lockDigest !== text(row, 'lockDigest') ||
|
||||
publication.target.generation !== integer(row, 'generation') ||
|
||||
publication.target.generationDigest !==
|
||||
text(row, 'generationDigest') ||
|
||||
publication.target.materializedRevisionDigest !==
|
||||
text(row, 'materializedRevisionDigest') ||
|
||||
publication.state !== text(row, 'state') ||
|
||||
publication.version !== integer(row, 'version') ||
|
||||
publication.publishedAtMs !== integer(row, 'publishedAtMs')
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return publication;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#select(
|
||||
where: string,
|
||||
values: readonly (string | number | null)[],
|
||||
): Row | undefined {
|
||||
return this.#authority.client
|
||||
.prepare(
|
||||
`SELECT publication_digest AS "publicationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest",
|
||||
generation,
|
||||
generation_digest AS "generationDigest",
|
||||
materialized_revision_digest AS "materializedRevisionDigest",
|
||||
state,
|
||||
version,
|
||||
published_at_ms AS "publishedAtMs",
|
||||
publication_json AS "publicationJson"
|
||||
FROM "QingLong3PluginPackageAutomationPublications"
|
||||
WHERE ${where}`,
|
||||
)
|
||||
.get(...values) as Row | undefined;
|
||||
}
|
||||
|
||||
#findByDigest(
|
||||
digest: string,
|
||||
): Readonly<PluginPackageAutomationPublication> | null {
|
||||
const row = this.#select('publication_digest = ?', [digest]);
|
||||
return row ? this.#parse(row) : null;
|
||||
}
|
||||
|
||||
#findCurrent(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Readonly<PluginPackageAutomationPublication> | null {
|
||||
const row = this.#select(
|
||||
`publication_digest = (
|
||||
SELECT publication_digest
|
||||
FROM "QingLong3PluginPackageAutomationPublicationHeads"
|
||||
WHERE project_id = ? AND package_name = ?
|
||||
)`,
|
||||
[projectId, packageName],
|
||||
);
|
||||
return row ? this.#parse(row) : null;
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageAutomationPublicationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
findCurrent(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
return this.#enqueue(() => this.#findCurrent(projectId, packageName));
|
||||
}
|
||||
|
||||
findByDigest(
|
||||
publicationDigestValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
return this.#enqueue(() => this.#findByDigest(digest));
|
||||
}
|
||||
|
||||
isStartAllowed(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
publicationDigestValue: string,
|
||||
): Promise<boolean> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
return this.#enqueue(() => {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageAutomationPublicationHeads" AS head
|
||||
JOIN "QingLong3PluginPackageAutomationPublications" AS publication
|
||||
ON publication.publication_digest = head.publication_digest
|
||||
JOIN "QingLong3PluginPackageInstallHeads" AS install_head
|
||||
ON install_head.project_id = publication.project_id
|
||||
AND install_head.package_name = publication.package_name
|
||||
AND install_head.installation_id =
|
||||
publication.installation_id
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = install_head.installation_id
|
||||
AND install.lock_digest = publication.lock_digest
|
||||
LEFT JOIN "QingLong3PluginPackageLifecycleHeads" AS lifecycle
|
||||
ON lifecycle.project_id = publication.project_id
|
||||
AND lifecycle.package_name = publication.package_name
|
||||
WHERE head.project_id = ?
|
||||
AND head.package_name = ?
|
||||
AND head.publication_digest = ?
|
||||
AND publication.state = 'active'
|
||||
AND install.state = 'active'
|
||||
AND install.active_lock_digest = publication.lock_digest
|
||||
AND (
|
||||
lifecycle.event_digest IS NULL OR
|
||||
lifecycle.disposition = 'active'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
WHERE quarantine.project_id = publication.project_id
|
||||
AND quarantine.package_name = publication.package_name
|
||||
AND quarantine.installation_id =
|
||||
publication.installation_id
|
||||
AND quarantine.lock_digest = publication.lock_digest
|
||||
)
|
||||
) AS "allowed"`,
|
||||
)
|
||||
.get(projectId, packageName, digest) as Row | undefined;
|
||||
if (!row || (row.allowed !== 0 && row.allowed !== 1)) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return row.allowed === 1;
|
||||
});
|
||||
}
|
||||
|
||||
listPendingPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
}>;
|
||||
}): Promise<Readonly<PluginPackageAutomationPublicationRecoveryPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
return Promise.reject(
|
||||
new InvalidPluginPackageAutomationPublicationError(
|
||||
'pending page options are invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize(options.limit);
|
||||
const after =
|
||||
options.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageAutomationPublicationRecoveryCursor(
|
||||
options.after,
|
||||
);
|
||||
return this.#enqueue(() => {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT install.project_id AS "projectId",
|
||||
install.package_name AS "packageName"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS install_head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = install_head.installation_id
|
||||
JOIN "QingLong3PluginPackageMaterializedRevisions" AS revision
|
||||
ON revision.project_id = install.project_id
|
||||
AND revision.package_name = install.package_name
|
||||
AND revision.generation = install.target_generation
|
||||
AND revision.lock_digest = install.lock_digest
|
||||
LEFT JOIN
|
||||
"QingLong3PluginPackageAutomationPublicationHeads" AS publication
|
||||
ON publication.project_id = install.project_id
|
||||
AND publication.package_name = install.package_name
|
||||
WHERE install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
WHERE quarantine.project_id = install.project_id
|
||||
AND quarantine.package_name = install.package_name
|
||||
AND quarantine.installation_id = install.installation_id
|
||||
AND quarantine.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
publication.generation_digest IS NULL OR
|
||||
publication.generation_digest <> revision.generation_digest
|
||||
)
|
||||
AND (
|
||||
? IS NULL OR install.project_id > ? OR
|
||||
(install.project_id = ? AND install.package_name > ?)
|
||||
)
|
||||
ORDER BY install.project_id, install.package_name
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
after?.projectId ?? null,
|
||||
after?.projectId ?? null,
|
||||
after?.projectId ?? null,
|
||||
after?.packageName ?? null,
|
||||
options.limit + 1,
|
||||
) as Row[];
|
||||
const truncated = rows.length > options.limit;
|
||||
const candidates = rows.slice(0, options.limit).map((row) =>
|
||||
Object.freeze({
|
||||
projectId: text(row, 'projectId'),
|
||||
packageName: text(row, 'packageName'),
|
||||
}),
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
findCurrentInTransaction(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Readonly<PluginPackageAutomationPublication> | null {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
if (!this.#authority.client.isTransaction) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return this.#findCurrent(projectId, packageName);
|
||||
}
|
||||
|
||||
publishInTransaction(
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}> {
|
||||
const client = this.#authority.client;
|
||||
if (!client.isTransaction) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
const publication = normalizePluginPackageAutomationPublication(value);
|
||||
const publicationJson = serialize(publication);
|
||||
const existing = this.#findByDigest(publication.publicationDigest);
|
||||
if (existing) {
|
||||
if (serialize(existing) !== publicationJson) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'publication digest is bound to another semantic publication',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication: existing,
|
||||
});
|
||||
}
|
||||
const current = this.#findCurrent(
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
);
|
||||
if (publication.version === 1) {
|
||||
if (current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'Package already has an automation publication head',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'previous automation publication head is absent',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertPluginPackageAutomationPublicationSuccessor(
|
||||
current,
|
||||
publication,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPluginPackageAutomationPublicationError) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication does not succeed the current head',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const revision = client
|
||||
.prepare(
|
||||
`SELECT revision_digest AS "revisionDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
generation,
|
||||
lock_digest AS "lockDigest"
|
||||
FROM "QingLong3PluginPackageMaterializedRevisions"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(publication.target.generationDigest) as Row | undefined;
|
||||
if (
|
||||
!revision ||
|
||||
text(revision, 'revisionDigest') !==
|
||||
publication.target.materializedRevisionDigest ||
|
||||
text(revision, 'projectId') !== publication.target.projectId ||
|
||||
text(revision, 'packageName') !== publication.target.packageName ||
|
||||
integer(revision, 'generation') !== publication.target.generation ||
|
||||
text(revision, 'lockDigest') !== publication.target.lockDigest
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'materialized revision fence does not match publication target',
|
||||
);
|
||||
}
|
||||
const securityFence = client
|
||||
.prepare(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
WHERE quarantine.project_id = ?
|
||||
AND quarantine.package_name = ?
|
||||
AND quarantine.installation_id = ?
|
||||
AND quarantine.lock_digest = ?
|
||||
) AS "blocked"`,
|
||||
)
|
||||
.get(
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.target.installationId,
|
||||
publication.target.lockDigest,
|
||||
) as Row | undefined;
|
||||
if (
|
||||
!securityFence ||
|
||||
(securityFence.blocked !== 0 && securityFence.blocked !== 1)
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
if (securityFence.blocked === 1) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'quarantined Package generation cannot publish automation',
|
||||
);
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageAutomationPublications" (
|
||||
publication_digest, project_id, package_name, installation_id,
|
||||
lock_digest, generation, generation_digest,
|
||||
materialized_revision_digest, state, version,
|
||||
previous_publication_digest, lifecycle_event_digest,
|
||||
published_at_ms, publication_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
publication.publicationDigest,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.target.installationId,
|
||||
publication.target.lockDigest,
|
||||
publication.target.generation,
|
||||
publication.target.generationDigest,
|
||||
publication.target.materializedRevisionDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.previousPublicationDigest,
|
||||
publication.lifecycleEventDigest,
|
||||
publication.publishedAtMs,
|
||||
publicationJson,
|
||||
);
|
||||
if (!current) {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageAutomationPublicationHeads" (
|
||||
project_id, package_name, publication_digest,
|
||||
generation_digest, state, version, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
);
|
||||
} else {
|
||||
const updated = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageAutomationPublicationHeads"
|
||||
SET publication_digest = ?, generation_digest = ?, state = ?,
|
||||
version = ?, updated_at_ms = ?
|
||||
WHERE project_id = ? AND package_name = ?
|
||||
AND publication_digest = ? AND version = ?`,
|
||||
)
|
||||
.run(
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
current.publicationDigest,
|
||||
current.version,
|
||||
);
|
||||
if (updated.changes !== 1) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication head changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
publication,
|
||||
});
|
||||
}
|
||||
|
||||
publish(
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}>
|
||||
> {
|
||||
return this.#enqueue(() => {
|
||||
const client = this.#authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = this.publishInTransaction(value);
|
||||
client.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+230
@@ -0,0 +1,230 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_REVISION_JSON_BYTES,
|
||||
PluginPackageResourceMaterializationConflictError,
|
||||
PluginPackageResourceMaterializationUnavailableError,
|
||||
normalizePluginPackageMaterializedRevision,
|
||||
type PluginPackageMaterializedRevision,
|
||||
type PluginPackageMaterializedRevisionRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageResourceMaterializationError(message);
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageResourceMaterializationUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageResourceMaterializationUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function generationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('generation digest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(
|
||||
revision: Readonly<PluginPackageMaterializedRevision>,
|
||||
): string {
|
||||
const value = JSON.stringify(revision);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_REVISION_JSON_BYTES
|
||||
) {
|
||||
return invalid('materialized revision exceeds the durable JSON budget');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageResourceMaterializationError ||
|
||||
error instanceof PluginPackageResourceMaterializationConflictError ||
|
||||
error instanceof PluginPackageResourceMaterializationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageResourceMaterializationConflictError(
|
||||
'durable revision identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageResourceMaterializationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageMaterializedRevisionRepository
|
||||
implements PluginPackageMaterializedRevisionRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
private readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
|
||||
constructor(
|
||||
authority: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(),
|
||||
) {
|
||||
if (!(taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)) {
|
||||
throw new TypeError('TaskSpec semantic registry is invalid');
|
||||
}
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.taskSpecSemanticRegistry = taskSpecSemanticRegistry;
|
||||
}
|
||||
|
||||
private parse(row: Row): Readonly<PluginPackageMaterializedRevision> {
|
||||
try {
|
||||
const revision = normalizePluginPackageMaterializedRevision(
|
||||
JSON.parse(text(row, 'revisionJson')) as PluginPackageMaterializedRevision,
|
||||
this.taskSpecSemanticRegistry,
|
||||
);
|
||||
if (
|
||||
revision.generation.generationDigest !==
|
||||
text(row, 'generationDigest') ||
|
||||
revision.generation.projectId !== text(row, 'projectId') ||
|
||||
revision.generation.packageName !== text(row, 'packageName') ||
|
||||
revision.generation.generation !== integer(row, 'generation') ||
|
||||
revision.generation.lockDigest !== text(row, 'lockDigest') ||
|
||||
revision.manifestDigest !== text(row, 'manifestDigest') ||
|
||||
revision.revisionDigest !== text(row, 'revisionDigest')
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationUnavailableError();
|
||||
}
|
||||
integer(row, 'createdAtMs');
|
||||
return revision;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageResourceMaterializationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageResourceMaterializationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
private findStored(
|
||||
digest: string,
|
||||
): Readonly<PluginPackageMaterializedRevision> | null {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`SELECT
|
||||
generation_digest AS "generationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
generation,
|
||||
lock_digest AS "lockDigest",
|
||||
manifest_digest AS "manifestDigest",
|
||||
revision_digest AS "revisionDigest",
|
||||
revision_json AS "revisionJson",
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "QingLong3PluginPackageMaterializedRevisions"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(digest) as Row | undefined;
|
||||
return row ? this.parse(row) : null;
|
||||
}
|
||||
|
||||
private enqueue<T>(work: () => T): Promise<T> {
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageResourceMaterializationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
async find(
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
|
||||
const normalizedDigest = generationDigest(digest);
|
||||
return await this.enqueue(() => this.findStored(normalizedDigest));
|
||||
}
|
||||
|
||||
publish(
|
||||
value: Readonly<PluginPackageMaterializedRevision>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
revision: Readonly<PluginPackageMaterializedRevision>;
|
||||
}>
|
||||
> {
|
||||
const revision = normalizePluginPackageMaterializedRevision(
|
||||
value,
|
||||
this.taskSpecSemanticRegistry,
|
||||
);
|
||||
const revisionJson = serialize(revision);
|
||||
return this.enqueue(() => {
|
||||
const result = this.authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageMaterializedRevisions" (
|
||||
generation_digest, project_id, package_name, generation,
|
||||
lock_digest, manifest_digest, revision_digest, revision_json,
|
||||
created_at_ms
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
||||
CAST(unixepoch('subsec') * 1000 AS INTEGER))
|
||||
ON CONFLICT (generation_digest) DO NOTHING`,
|
||||
)
|
||||
.run(
|
||||
revision.generation.generationDigest,
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
revision.generation.generation,
|
||||
revision.generation.lockDigest,
|
||||
revision.manifestDigest,
|
||||
revision.revisionDigest,
|
||||
revisionJson,
|
||||
);
|
||||
const stored = this.findStored(revision.generation.generationDigest);
|
||||
if (!stored) {
|
||||
throw new PluginPackageResourceMaterializationUnavailableError();
|
||||
}
|
||||
if (
|
||||
stored.revisionDigest !== revision.revisionDigest ||
|
||||
JSON.stringify(stored) !== revisionJson
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'generation digest is bound to another semantic revision',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: result.changes === 1 ? 'created' : 'existing',
|
||||
revision: stored,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
PluginPackageInstallProposalConflictError,
|
||||
PluginPackageInstallProposalUnavailableError,
|
||||
normalizePluginPackageInstallProposal,
|
||||
type CreatePluginPackageInstallProposalCommand,
|
||||
type CreatePluginPackageInstallProposalResult,
|
||||
type PluginPackageInstallProposal,
|
||||
type PluginPackageInstallProposalRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-proposal';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value !== null && typeof value !== 'string') {
|
||||
throw new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nullableInteger(row: Row, key: string): number | null {
|
||||
const value = row[key];
|
||||
if (value !== null && !Number.isSafeInteger(value)) {
|
||||
throw new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
return value as number | null;
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function parseProposal(row: Row): Readonly<PluginPackageInstallProposal> {
|
||||
try {
|
||||
const proposal = normalizePluginPackageInstallProposal(
|
||||
JSON.parse(text(row, 'proposalJson')) as PluginPackageInstallProposal,
|
||||
);
|
||||
if (proposal.proposalDigest !== text(row, 'proposalDigest')) {
|
||||
throw new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
return proposal;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageInstallProposalUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
export function findLocalPluginPackageInstallProposal(
|
||||
client: DatabaseSync,
|
||||
actionRef: string,
|
||||
): Readonly<PluginPackageInstallProposal> | null {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT "proposal_json" AS "proposalJson",
|
||||
"proposal_digest" AS "proposalDigest"
|
||||
FROM "QingLong3PluginPackageInstallProposals"
|
||||
WHERE "action_ref" = ?`,
|
||||
)
|
||||
.get(actionRef) as Row | undefined;
|
||||
return row ? parseProposal(row) : null;
|
||||
}
|
||||
|
||||
function parseAudit(row: Row): Readonly<SecurityAuditRecord> {
|
||||
try {
|
||||
const subjectType = nullableText(row, 'subjectType');
|
||||
const subjectId = nullableText(row, 'subjectId');
|
||||
const projectVersion = nullableInteger(row, 'projectVersion');
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: text(row, 'eventId'),
|
||||
requestId: text(row, 'requestId'),
|
||||
operationId: text(row, 'operationId'),
|
||||
projectId: nullableText(row, 'projectId'),
|
||||
subject:
|
||||
subjectType === null || subjectId === null
|
||||
? null
|
||||
: { type: subjectType, id: subjectId },
|
||||
authenticationId: nullableText(row, 'authenticationId'),
|
||||
outcome: text(row, 'outcome'),
|
||||
reasons: JSON.parse(text(row, 'reasonsJson')),
|
||||
fence:
|
||||
projectVersion === null
|
||||
? null
|
||||
: {
|
||||
projectVersion,
|
||||
bindingVersion: nullableInteger(row, 'bindingVersion'),
|
||||
},
|
||||
occurredAtMs: integer(row, 'occurredAtMs'),
|
||||
} as SecurityAuditRecord);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageInstallProposalUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function storageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackageInstallProposalConflictError ||
|
||||
error instanceof PluginPackageInstallProposalUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
return new PluginPackageInstallProposalUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageInstallProposalRepository
|
||||
implements PluginPackageInstallProposalRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
readonly #client: DatabaseSync;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.#client = this.#authority.client;
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw storageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageInstallProposalUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
#proposal(actionRef: string): Readonly<PluginPackageInstallProposal> | null {
|
||||
return findLocalPluginPackageInstallProposal(this.#client, actionRef);
|
||||
}
|
||||
|
||||
#audit(eventId: string): Readonly<SecurityAuditRecord> | null {
|
||||
const row = this.#client
|
||||
.prepare(
|
||||
`SELECT "event_id" AS "eventId", "request_id" AS "requestId",
|
||||
"operation_id" AS "operationId", "project_id" AS "projectId",
|
||||
"subject_type" AS "subjectType", "subject_id" AS "subjectId",
|
||||
"authentication_id" AS "authenticationId",
|
||||
"outcome" AS "outcome", "reasons_json" AS "reasonsJson",
|
||||
"fence_project_version" AS "projectVersion",
|
||||
"fence_binding_version" AS "bindingVersion",
|
||||
"occurred_at_ms" AS "occurredAtMs"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ?`,
|
||||
)
|
||||
.get(eventId) as Row | undefined;
|
||||
return row ? parseAudit(row) : null;
|
||||
}
|
||||
|
||||
findProposalByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackageInstallProposal> | null> {
|
||||
return this.#enqueue(() => this.#proposal(actionRef));
|
||||
}
|
||||
|
||||
createProposal(
|
||||
command: CreatePluginPackageInstallProposalCommand,
|
||||
): Promise<Readonly<CreatePluginPackageInstallProposalResult>> {
|
||||
const proposal = normalizePluginPackageInstallProposal(command.proposal);
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
return this.#enqueue(() => {
|
||||
this.#client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (
|
||||
audit.requestId !== proposal.actionRef ||
|
||||
audit.operationId !== 'plugin_package.propose' ||
|
||||
audit.projectId !== proposal.projectId ||
|
||||
audit.subject?.type !== proposal.proposedBy.type ||
|
||||
audit.subject.id !== proposal.proposedBy.id ||
|
||||
audit.authenticationId === null ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!same(audit.reasons, ['package_proposal']) ||
|
||||
audit.fence?.projectVersion !==
|
||||
proposal.proposalFence.projectVersion ||
|
||||
audit.fence.bindingVersion !==
|
||||
proposal.proposalFence.bindingVersion ||
|
||||
audit.occurredAtMs !== proposal.createdAtMs
|
||||
) {
|
||||
throw new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
const existing = this.#proposal(proposal.actionRef);
|
||||
if (existing) {
|
||||
const existingAudit = this.#audit(audit.eventId);
|
||||
if (
|
||||
!same(existing, proposal) ||
|
||||
!existingAudit ||
|
||||
!same(existingAudit, audit)
|
||||
) {
|
||||
throw new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
const fence = this.#client
|
||||
.prepare(
|
||||
`SELECT project."status" AS "status",
|
||||
project."version" AS "projectVersion",
|
||||
(
|
||||
SELECT max(binding."version")
|
||||
FROM "QingLong3ProjectRoleBindings" AS binding
|
||||
WHERE binding."project_id" = project."id"
|
||||
AND binding."subject_type" = ?
|
||||
AND binding."subject_id" = ?
|
||||
) AS "bindingVersion"
|
||||
FROM "QingLong3Projects" AS project
|
||||
WHERE project."id" = ?`,
|
||||
)
|
||||
.get(
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.projectId,
|
||||
) as Row | undefined;
|
||||
if (
|
||||
!fence ||
|
||||
fence.status !== 'active' ||
|
||||
integer(fence, 'projectVersion') !==
|
||||
proposal.proposalFence.projectVersion ||
|
||||
nullableInteger(fence, 'bindingVersion') !==
|
||||
proposal.proposalFence.bindingVersion
|
||||
) {
|
||||
throw new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
this.#client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageInstallProposals" (
|
||||
"action_ref", "project_id", "action_type", "permission",
|
||||
"action_digest", "preview_digest", "proposed_by_type",
|
||||
"proposed_by_id", "fence_project_version",
|
||||
"fence_binding_version", "created_at_ms", "proposal_json",
|
||||
"proposal_digest"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
proposal.actionRef,
|
||||
proposal.projectId,
|
||||
proposal.actionType,
|
||||
proposal.permission,
|
||||
proposal.actionDigest,
|
||||
proposal.previewDigest,
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.proposalFence.projectVersion,
|
||||
proposal.proposalFence.bindingVersion,
|
||||
proposal.createdAtMs,
|
||||
JSON.stringify(proposal),
|
||||
proposal.proposalDigest,
|
||||
);
|
||||
this.#client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
"event_id", "request_id", "operation_id", "project_id",
|
||||
"subject_type", "subject_id", "authentication_id", "outcome",
|
||||
"reasons_json", "fence_project_version",
|
||||
"fence_binding_version", "occurred_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
);
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, proposal });
|
||||
} catch (error) {
|
||||
if (this.#client.isTransaction) this.#client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
InvalidPluginPackageQuarantineError,
|
||||
MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS,
|
||||
PluginPackageQuarantineConflictError,
|
||||
PluginPackageQuarantineUnavailableError,
|
||||
assertPluginPackageWithdrawalMatchesEvent,
|
||||
createPluginPackageWithdrawalReceipt,
|
||||
normalizePluginPackageQuarantineEvent,
|
||||
normalizePluginPackageWithdrawalReceipt,
|
||||
pluginPackageQuarantineTaskMutationId,
|
||||
type PluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineRepository,
|
||||
type PluginPackageQuarantineTarget,
|
||||
type PluginPackageQuarantineTaskWithdrawal,
|
||||
type PluginPackageWithdrawalReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-quarantine';
|
||||
import {
|
||||
normalizePluginPackageInstallRecord,
|
||||
type PluginPackageInstallRecord,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
normalizeProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionActiveVectorDigest,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
type ProjectToolDefinitionSnapshot,
|
||||
type ProjectToolDefinitionSnapshotContribution,
|
||||
type ProjectToolDefinitionSnapshotSource,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import {
|
||||
createTaskDefinitionRecord,
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export const EDGE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT = 4;
|
||||
export const STANDALONE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT = 16;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function json(row: Row, key: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text(row, key));
|
||||
} catch {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageQuarantineError ||
|
||||
error instanceof PluginPackageQuarantineConflictError ||
|
||||
error instanceof PluginPackageQuarantineUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageQuarantineConflictError(
|
||||
'durable quarantine identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageQuarantineUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function taskRecord(row: Row): Readonly<TaskDefinitionRecord> {
|
||||
try {
|
||||
return normalizeTaskDefinitionRecord({
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
revision: integer(row, 'revision'),
|
||||
mutationId: text(row, 'mutationId'),
|
||||
name: text(row, 'name'),
|
||||
...(row.description === null
|
||||
? {}
|
||||
: { description: text(row, 'description') }),
|
||||
kind: text(row, 'kind') as TaskDefinitionRecord['kind'],
|
||||
spec: json(row, 'specJson') as TaskDefinitionRecord['spec'],
|
||||
labels: json(row, 'labelsJson') as TaskDefinitionRecord['labels'],
|
||||
enabled: integer(row, 'enabled') === 1,
|
||||
contentDigest: text(row, 'contentDigest'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
updatedAtMs: integer(row, 'updatedAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function activeSource(
|
||||
contribution: Readonly<ProjectToolDefinitionSnapshotContribution>,
|
||||
): Readonly<ProjectToolDefinitionSnapshotSource> {
|
||||
return Object.freeze({
|
||||
installationId: contribution.generation.installationId,
|
||||
packageName: contribution.generation.packageName,
|
||||
generation: contribution.generation.generation,
|
||||
generationDigest: contribution.generation.generationDigest,
|
||||
lockDigest: contribution.generation.lockDigest,
|
||||
revisionDigest: contribution.revisionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageQuarantineRepository
|
||||
implements PluginPackageQuarantineRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
readonly #registry: TaskSpecSemanticRegistry;
|
||||
readonly #activeSourceLimit: number;
|
||||
|
||||
constructor(
|
||||
authority: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
options: Readonly<{
|
||||
registry?: TaskSpecSemanticRegistry;
|
||||
activeSourceLimit?: number;
|
||||
}> = {},
|
||||
) {
|
||||
const registry =
|
||||
options.registry ?? createBuiltInTaskSpecSemanticRegistry();
|
||||
const activeSourceLimit =
|
||||
options.activeSourceLimit ??
|
||||
STANDALONE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT;
|
||||
if (!(registry instanceof TaskSpecSemanticRegistry)) {
|
||||
throw new TypeError('TaskSpec semantic registry is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(activeSourceLimit) ||
|
||||
activeSourceLimit < 1 ||
|
||||
activeSourceLimit >
|
||||
STANDALONE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT
|
||||
) {
|
||||
throw new RangeError('active source limit is invalid');
|
||||
}
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.#registry = registry;
|
||||
this.#activeSourceLimit = activeSourceLimit;
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageQuarantineUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
#eventByDigest(
|
||||
eventDigest: string,
|
||||
): Readonly<PluginPackageQuarantineEvent> | null {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT event_json AS "eventJson"
|
||||
FROM "QingLong3PluginPackageQuarantineEvents"
|
||||
WHERE event_digest = ?`,
|
||||
)
|
||||
.get(eventDigest) as Row | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
return normalizePluginPackageQuarantineEvent(
|
||||
json(row, 'eventJson') as PluginPackageQuarantineEvent,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#receiptByEvent(
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
): Readonly<PluginPackageWithdrawalReceipt> | null {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageWithdrawalReceipts"
|
||||
WHERE event_digest = ?`,
|
||||
)
|
||||
.get(event.eventDigest) as Row | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
const receipt = normalizePluginPackageWithdrawalReceipt(
|
||||
json(row, 'receiptJson') as PluginPackageWithdrawalReceipt,
|
||||
);
|
||||
assertPluginPackageWithdrawalMatchesEvent(event, receipt);
|
||||
this.#assertReceiptRelations(receipt);
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#assertReceiptRelations(
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>,
|
||||
): void {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT item.project_id AS "projectId",
|
||||
item.task_id AS "taskId",
|
||||
item.previous_revision AS "previousRevision",
|
||||
item.disabled_revision AS "disabledRevision",
|
||||
item.previous_content_digest AS "previousContentDigest",
|
||||
item.disabled_content_digest AS "disabledContentDigest",
|
||||
previous.content_digest AS "storedPreviousContentDigest",
|
||||
disabled.content_digest AS "storedDisabledContentDigest"
|
||||
FROM "QingLong3PluginPackageWithdrawalTasks" AS item
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS previous
|
||||
ON previous.project_id = item.project_id
|
||||
AND previous.task_id = item.task_id
|
||||
AND previous.revision = item.previous_revision
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS disabled
|
||||
ON disabled.project_id = item.project_id
|
||||
AND disabled.task_id = item.task_id
|
||||
AND disabled.revision = item.disabled_revision
|
||||
WHERE item.event_digest = ?
|
||||
ORDER BY item.task_id COLLATE BINARY`,
|
||||
)
|
||||
.all(receipt.eventDigest) as Row[];
|
||||
const withdrawals = rows.map((row) =>
|
||||
Object.freeze({
|
||||
taskId: text(row, 'taskId'),
|
||||
previousRevision: integer(row, 'previousRevision'),
|
||||
disabledRevision: integer(row, 'disabledRevision'),
|
||||
previousContentDigest: text(row, 'previousContentDigest'),
|
||||
disabledContentDigest: text(row, 'disabledContentDigest'),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
rows.some(
|
||||
(row) =>
|
||||
text(row, 'projectId') !== receipt.target.projectId ||
|
||||
text(row, 'previousContentDigest') !==
|
||||
text(row, 'storedPreviousContentDigest') ||
|
||||
text(row, 'disabledContentDigest') !==
|
||||
text(row, 'storedDisabledContentDigest'),
|
||||
) ||
|
||||
!same(withdrawals, receipt.capability.taskWithdrawals)
|
||||
) {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
if (receipt.capability.status === 'not_active') return;
|
||||
const snapshotRow = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT snapshot_json AS "snapshotJson"
|
||||
FROM "QingLong3ProjectToolDefinitionSnapshots"
|
||||
WHERE project_id = ? AND active_vector_digest = ?
|
||||
AND snapshot_digest = ?`,
|
||||
)
|
||||
.get(
|
||||
receipt.target.projectId,
|
||||
receipt.capability.currentActiveVectorDigest,
|
||||
receipt.capability.currentToolSnapshotDigest,
|
||||
) as Row | undefined;
|
||||
if (!snapshotRow) {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
try {
|
||||
const snapshot = normalizeProjectToolDefinitionSnapshot(
|
||||
json(snapshotRow, 'snapshotJson') as ProjectToolDefinitionSnapshot,
|
||||
);
|
||||
if (
|
||||
snapshot.sources.length !== receipt.capability.retainedSourceCount ||
|
||||
snapshot.sources.some(
|
||||
(source) =>
|
||||
source.packageName === receipt.target.packageName &&
|
||||
source.lockDigest === receipt.target.lockDigest,
|
||||
)
|
||||
) {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#findStored(
|
||||
eventDigest: string,
|
||||
): Readonly<PluginPackageWithdrawalReceipt> | null {
|
||||
const event = this.#eventByDigest(eventDigest);
|
||||
if (!event) return null;
|
||||
const receipt = this.#receiptByEvent(event);
|
||||
if (!receipt) {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
findTargetsByLockDigest(
|
||||
lockDigest: string,
|
||||
): Promise<readonly Readonly<PluginPackageQuarantineTarget>[]> {
|
||||
if (typeof lockDigest !== 'string' || !/^[0-9a-f]{64}$/.test(lockDigest)) {
|
||||
throw new InvalidPluginPackageQuarantineError('lockDigest is invalid');
|
||||
}
|
||||
return this.#enqueue(() => {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT record_json AS "recordJson"
|
||||
FROM "QingLong3PluginPackageInstalls"
|
||||
WHERE lock_digest = ?
|
||||
AND state IN ('queued','staged','activating','active')
|
||||
ORDER BY project_id, package_name, installation_id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(lockDigest, this.#activeSourceLimit + 1) as Row[];
|
||||
if (rows.length > this.#activeSourceLimit) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'matching install targets exceed the local Profile limit',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return Object.freeze(
|
||||
rows.map((row) => {
|
||||
const record = normalizePluginPackageInstallRecord(
|
||||
json(row, 'recordJson') as PluginPackageInstallRecord,
|
||||
);
|
||||
return Object.freeze({
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
installationId: record.installationId,
|
||||
lockDigest: record.lockDigest,
|
||||
installState:
|
||||
record.state as PluginPackageQuarantineTarget['installState'],
|
||||
installVersion: record.version,
|
||||
installRecordDigest: record.recordDigest,
|
||||
activeLockDigest: record.activeLockDigest,
|
||||
});
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PluginPackageQuarantineConflictError ||
|
||||
error instanceof PluginPackageQuarantineUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findByEventDigest(
|
||||
eventDigest: string,
|
||||
): Promise<Readonly<PluginPackageWithdrawalReceipt> | null> {
|
||||
if (
|
||||
typeof eventDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(eventDigest)
|
||||
) {
|
||||
throw new InvalidPluginPackageQuarantineError('eventDigest is invalid');
|
||||
}
|
||||
return this.#enqueue(() => this.#findStored(eventDigest));
|
||||
}
|
||||
|
||||
#install(
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
): Readonly<PluginPackageInstallRecord> {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT record_json AS "recordJson"
|
||||
FROM "QingLong3PluginPackageInstalls"
|
||||
WHERE installation_id = ?`,
|
||||
)
|
||||
.get(event.target.installationId) as Row | undefined;
|
||||
if (!row) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'target install is absent',
|
||||
);
|
||||
}
|
||||
let record: Readonly<PluginPackageInstallRecord>;
|
||||
try {
|
||||
record = normalizePluginPackageInstallRecord(
|
||||
json(row, 'recordJson') as PluginPackageInstallRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
if (
|
||||
record.projectId !== event.target.projectId ||
|
||||
record.packageName !== event.target.packageName ||
|
||||
record.lockDigest !== event.target.lockDigest ||
|
||||
record.state !== event.target.installState ||
|
||||
record.version !== event.target.installVersion ||
|
||||
record.recordDigest !== event.target.installRecordDigest ||
|
||||
record.activeLockDigest !== event.target.activeLockDigest
|
||||
) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'target install advanced or drifted',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
#activeContributions(
|
||||
projectId: string,
|
||||
): readonly Readonly<ProjectToolDefinitionSnapshotContribution>[] {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT revision.revision_json AS "revisionJson"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS head_install
|
||||
ON head_install.installation_id = head.installation_id
|
||||
JOIN "QingLong3PluginPackageInstalls" AS active_install
|
||||
ON active_install.project_id = head.project_id
|
||||
AND active_install.package_name = head.package_name
|
||||
AND active_install.lock_digest = head_install.active_lock_digest
|
||||
JOIN "QingLong3PluginPackageMaterializedRevisions" AS revision
|
||||
ON revision.project_id = active_install.project_id
|
||||
AND revision.package_name = active_install.package_name
|
||||
AND revision.generation = active_install.target_generation
|
||||
AND revision.lock_digest = active_install.lock_digest
|
||||
LEFT JOIN "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
ON quarantine.project_id = active_install.project_id
|
||||
AND quarantine.package_name = active_install.package_name
|
||||
AND quarantine.installation_id = active_install.installation_id
|
||||
AND quarantine.lock_digest = active_install.lock_digest
|
||||
WHERE head.project_id = ?
|
||||
AND head_install.active_lock_digest IS NOT NULL
|
||||
AND active_install.state = 'active'
|
||||
AND quarantine.event_digest IS NULL
|
||||
ORDER BY head.package_name COLLATE BINARY
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(projectId, this.#activeSourceLimit + 1) as Row[];
|
||||
if (rows.length > this.#activeSourceLimit) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'active Package sources exceed the local Profile limit',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return Object.freeze(
|
||||
rows.map((row) =>
|
||||
projectToolDefinitionSnapshotContribution(
|
||||
json(row, 'revisionJson') as never,
|
||||
this.#registry,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#enabledOwnedTasks(
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
): readonly Readonly<TaskDefinitionRecord>[] {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT head.project_id AS "projectId",
|
||||
head.task_id AS "taskId",
|
||||
revision.revision AS "revision",
|
||||
revision.mutation_id AS "mutationId",
|
||||
revision.name AS "name",
|
||||
revision.description AS "description",
|
||||
revision.kind AS "kind",
|
||||
revision.spec_json AS "specJson",
|
||||
revision.labels_json AS "labelsJson",
|
||||
revision.enabled AS "enabled",
|
||||
revision.content_digest AS "contentDigest",
|
||||
head.created_at_ms AS "createdAtMs",
|
||||
revision.created_at_ms AS "updatedAtMs"
|
||||
FROM "QingLong3PluginPackageTaskOwnerships" AS ownership
|
||||
JOIN "QingLong3TaskDefinitions" AS head
|
||||
ON head.project_id = ownership.project_id
|
||||
AND head.task_id = ownership.task_id
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE ownership.project_id = ?
|
||||
AND ownership.package_name = ?
|
||||
AND revision.enabled = 1
|
||||
ORDER BY ownership.task_id COLLATE BINARY
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS + 1,
|
||||
) as Row[];
|
||||
if (rows.length > MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'owned Tasks exceed the quarantine withdrawal limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze(rows.map(taskRecord));
|
||||
}
|
||||
|
||||
#appendDisabledTask(
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
current: Readonly<TaskDefinitionRecord>,
|
||||
committedAtMs: number,
|
||||
): Readonly<PluginPackageQuarantineTaskWithdrawal> {
|
||||
const disabled = createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: current.projectId,
|
||||
taskId: current.taskId,
|
||||
expectedRevision: current.revision,
|
||||
mutationId: pluginPackageQuarantineTaskMutationId(
|
||||
event.eventDigest,
|
||||
current.taskId,
|
||||
),
|
||||
name: current.name,
|
||||
...(current.description === undefined
|
||||
? {}
|
||||
: { description: current.description }),
|
||||
kind: current.kind,
|
||||
spec: current.spec,
|
||||
labels: current.labels,
|
||||
enabled: false,
|
||||
occurredAtMs: committedAtMs,
|
||||
},
|
||||
current.createdAtMs,
|
||||
);
|
||||
this.#authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||
project_id, task_id, revision, mutation_id, name, description,
|
||||
kind, spec_json, labels_json, enabled, content_digest, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
disabled.projectId,
|
||||
disabled.taskId,
|
||||
disabled.revision,
|
||||
disabled.mutationId,
|
||||
disabled.name,
|
||||
disabled.description ?? null,
|
||||
disabled.kind,
|
||||
JSON.stringify(disabled.spec),
|
||||
JSON.stringify(disabled.labels),
|
||||
disabled.contentDigest,
|
||||
disabled.updatedAtMs,
|
||||
);
|
||||
const update = this.#authority.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3TaskDefinitions"
|
||||
SET current_revision = ?, updated_at_ms = ?
|
||||
WHERE project_id = ? AND task_id = ? AND current_revision = ?`,
|
||||
)
|
||||
.run(
|
||||
disabled.revision,
|
||||
disabled.updatedAtMs,
|
||||
disabled.projectId,
|
||||
disabled.taskId,
|
||||
current.revision,
|
||||
);
|
||||
if (update.changes !== 1) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'TaskDefinition head changed during quarantine',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
taskId: current.taskId,
|
||||
previousRevision: current.revision,
|
||||
disabledRevision: disabled.revision,
|
||||
previousContentDigest: current.contentDigest,
|
||||
disabledContentDigest: disabled.contentDigest,
|
||||
});
|
||||
}
|
||||
|
||||
#publishSnapshot(
|
||||
snapshot: Readonly<ProjectToolDefinitionSnapshot>,
|
||||
committedAtMs: number,
|
||||
): void {
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
const insert = this.#authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectToolDefinitionSnapshots" (
|
||||
project_id, active_vector_digest, definitions_digest,
|
||||
snapshot_digest, snapshot_json, committed_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (project_id, active_vector_digest) DO NOTHING`,
|
||||
)
|
||||
.run(
|
||||
snapshot.projectId,
|
||||
snapshot.activeVectorDigest,
|
||||
snapshot.definitionsDigest,
|
||||
snapshot.snapshotDigest,
|
||||
snapshotJson,
|
||||
committedAtMs,
|
||||
);
|
||||
if (insert.changes === 1) {
|
||||
const insertSource = this.#authority.client.prepare(
|
||||
`INSERT INTO "QingLong3ProjectToolDefinitionSnapshotSources" (
|
||||
project_id, active_vector_digest, package_name, installation_id,
|
||||
generation, generation_digest, lock_digest, revision_digest
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const source of snapshot.sources) {
|
||||
insertSource.run(
|
||||
snapshot.projectId,
|
||||
snapshot.activeVectorDigest,
|
||||
source.packageName,
|
||||
source.installationId,
|
||||
source.generation,
|
||||
source.generationDigest,
|
||||
source.lockDigest,
|
||||
source.revisionDigest,
|
||||
);
|
||||
}
|
||||
}
|
||||
const stored = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT snapshot_json AS "snapshotJson"
|
||||
FROM "QingLong3ProjectToolDefinitionSnapshots"
|
||||
WHERE project_id = ? AND active_vector_digest = ?`,
|
||||
)
|
||||
.get(snapshot.projectId, snapshot.activeVectorDigest) as Row | undefined;
|
||||
if (!stored || text(stored, 'snapshotJson') !== snapshotJson) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'active vector is bound to another Tool snapshot',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#insertEvent(event: Readonly<PluginPackageQuarantineEvent>): void {
|
||||
this.#authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageQuarantineEvents" (
|
||||
event_digest, mutation_id, revocation_receipt_digest, impact_digest,
|
||||
project_id, package_name, installation_id, lock_digest,
|
||||
install_state, install_version, install_record_digest,
|
||||
active_lock_digest, proposer_type, proposer_id, confirmer_type,
|
||||
confirmer_id, authorization_mode, reason_code, occurred_at_ms,
|
||||
event_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.eventDigest,
|
||||
event.mutationId,
|
||||
event.revocationReceiptDigest,
|
||||
event.impactDigest,
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
event.target.installationId,
|
||||
event.target.lockDigest,
|
||||
event.target.installState,
|
||||
event.target.installVersion,
|
||||
event.target.installRecordDigest,
|
||||
event.target.activeLockDigest,
|
||||
event.proposer.type,
|
||||
event.proposer.id,
|
||||
event.confirmer.type,
|
||||
event.confirmer.id,
|
||||
event.authorizationMode,
|
||||
event.reasonCode,
|
||||
event.occurredAtMs,
|
||||
JSON.stringify(event),
|
||||
);
|
||||
}
|
||||
|
||||
#insertReceipt(receipt: Readonly<PluginPackageWithdrawalReceipt>): void {
|
||||
const capability = receipt.capability;
|
||||
this.#authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageWithdrawalReceipts" (
|
||||
event_digest, receipt_digest, project_id, capability_status,
|
||||
task_count, previous_active_vector_digest,
|
||||
current_active_vector_digest, current_tool_snapshot_digest,
|
||||
retained_source_count, committed_at_ms, receipt_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
receipt.eventDigest,
|
||||
receipt.receiptDigest,
|
||||
receipt.target.projectId,
|
||||
capability.status,
|
||||
capability.taskWithdrawals.length,
|
||||
capability.previousActiveVectorDigest,
|
||||
capability.currentActiveVectorDigest,
|
||||
capability.currentToolSnapshotDigest,
|
||||
capability.retainedSourceCount,
|
||||
receipt.committedAtMs,
|
||||
JSON.stringify(receipt),
|
||||
);
|
||||
const insertTask = this.#authority.client.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageWithdrawalTasks" (
|
||||
event_digest, project_id, task_id, previous_revision,
|
||||
disabled_revision, previous_content_digest, disabled_content_digest
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const task of capability.taskWithdrawals) {
|
||||
insertTask.run(
|
||||
receipt.eventDigest,
|
||||
receipt.target.projectId,
|
||||
task.taskId,
|
||||
task.previousRevision,
|
||||
task.disabledRevision,
|
||||
task.previousContentDigest,
|
||||
task.disabledContentDigest,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
quarantine(
|
||||
eventValue: Readonly<PluginPackageQuarantineEvent>,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>
|
||||
> {
|
||||
const event = normalizePluginPackageQuarantineEvent(eventValue);
|
||||
if (typeof confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
return this.#enqueue(async () => {
|
||||
const client = this.#authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
await confirmAuthorization();
|
||||
const existingEvent = this.#eventByDigest(event.eventDigest);
|
||||
if (existingEvent) {
|
||||
if (!same(existingEvent, event)) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'event digest is bound to another quarantine',
|
||||
);
|
||||
}
|
||||
const receipt = this.#receiptByEvent(existingEvent);
|
||||
if (!receipt) {
|
||||
throw new PluginPackageQuarantineUnavailableError();
|
||||
}
|
||||
await confirmAuthorization();
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt,
|
||||
});
|
||||
}
|
||||
const targetEvent = client
|
||||
.prepare(
|
||||
`SELECT event_digest AS "eventDigest"
|
||||
FROM "QingLong3PluginPackageQuarantineEvents"
|
||||
WHERE project_id = ? AND package_name = ?
|
||||
AND installation_id = ? AND lock_digest = ?`,
|
||||
)
|
||||
.get(
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
event.target.installationId,
|
||||
event.target.lockDigest,
|
||||
) as Row | undefined;
|
||||
if (targetEvent) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'target lock is already quarantined by another event',
|
||||
);
|
||||
}
|
||||
this.#install(event);
|
||||
const clock = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS "nowMs"`,
|
||||
)
|
||||
.get() as Row;
|
||||
const committedAtMs = Math.max(
|
||||
integer(clock, 'nowMs'),
|
||||
event.occurredAtMs,
|
||||
);
|
||||
if (event.target.installState !== 'active') {
|
||||
this.#insertEvent(event);
|
||||
const receipt = createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: {
|
||||
status: 'not_active',
|
||||
taskWithdrawals: [],
|
||||
previousActiveVectorDigest: null,
|
||||
currentActiveVectorDigest: null,
|
||||
currentToolSnapshotDigest: null,
|
||||
retainedSourceCount: 0,
|
||||
},
|
||||
committedAtMs,
|
||||
});
|
||||
this.#insertReceipt(receipt);
|
||||
await confirmAuthorization();
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt,
|
||||
});
|
||||
}
|
||||
|
||||
const previousContributions = this.#activeContributions(
|
||||
event.target.projectId,
|
||||
);
|
||||
const targetIndex = previousContributions.findIndex(
|
||||
({ generation }) =>
|
||||
generation.packageName === event.target.packageName &&
|
||||
generation.installationId === event.target.installationId &&
|
||||
generation.lockDigest === event.target.lockDigest,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'active target is not a complete Tool source',
|
||||
);
|
||||
}
|
||||
const previousSources = Object.freeze(
|
||||
previousContributions.map(activeSource),
|
||||
);
|
||||
const retainedContributions = Object.freeze(
|
||||
previousContributions.filter((_, index) => index !== targetIndex),
|
||||
);
|
||||
const snapshot = createProjectToolDefinitionSnapshot({
|
||||
projectId: event.target.projectId,
|
||||
contributions: retainedContributions,
|
||||
});
|
||||
const tasks = this.#enabledOwnedTasks(event);
|
||||
const taskWithdrawals = Object.freeze(
|
||||
tasks.map((task) =>
|
||||
this.#appendDisabledTask(event, task, committedAtMs),
|
||||
),
|
||||
);
|
||||
this.#insertEvent(event);
|
||||
this.#publishSnapshot(snapshot, committedAtMs);
|
||||
const receipt = createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: {
|
||||
status: 'withdrawn',
|
||||
taskWithdrawals,
|
||||
previousActiveVectorDigest: projectToolDefinitionActiveVectorDigest(
|
||||
event.target.projectId,
|
||||
previousSources,
|
||||
),
|
||||
currentActiveVectorDigest: snapshot.activeVectorDigest,
|
||||
currentToolSnapshotDigest: snapshot.snapshotDigest,
|
||||
retainedSourceCount: snapshot.sources.length,
|
||||
},
|
||||
committedAtMs,
|
||||
});
|
||||
this.#insertReceipt(receipt);
|
||||
await confirmAuthorization();
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
InvalidPluginPackageTaskReconciliationError,
|
||||
PluginPackageTaskReconciliationConflictError,
|
||||
PluginPackageTaskReconciliationUnavailableError,
|
||||
normalizePluginPackageTaskReconciliationReceipt,
|
||||
planPluginPackageTaskReconciliation,
|
||||
pluginPackageTaskReconciliationTaskIds,
|
||||
type PluginPackageTaskOwnershipFact,
|
||||
type PluginPackageTaskReconciliationReceipt,
|
||||
type PluginPackageTaskReconciliationRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-reconciliation';
|
||||
import {
|
||||
assertPluginPackageTaskPublicationRecoveryPageSize,
|
||||
normalizePluginPackageTaskPublicationRecoveryCursor,
|
||||
type PluginPackageTaskPublicationRecoveryPage,
|
||||
type PluginPackageTaskPublicationRecoverySource,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import {
|
||||
normalizePluginPackageMaterializedRevision,
|
||||
type PluginPackageMaterializedRevision,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGenerationSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import {
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
compileLocalCommandTaskDefinition,
|
||||
} from '@qinglong/runtime-core/task-definition-execution-compiler';
|
||||
import {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
import { LocalSqliteDispatchDefinitionStore } from '../task-definition/dispatchDefinitionStore';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const SELECT_TASK_FIELDS = `
|
||||
head."project_id" AS "projectId",
|
||||
head."task_id" AS "taskId",
|
||||
revision."revision" AS "revision",
|
||||
revision."mutation_id" AS "mutationId",
|
||||
revision."name" AS "name",
|
||||
revision."description" AS "description",
|
||||
revision."kind" AS "kind",
|
||||
revision."spec_json" AS "specJson",
|
||||
revision."labels_json" AS "labelsJson",
|
||||
revision."enabled" AS "enabled",
|
||||
revision."content_digest" AS "contentDigest",
|
||||
head."created_at_ms" AS "createdAtMs",
|
||||
revision."created_at_ms" AS "updatedAtMs"`;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageTaskReconciliationUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value === null) return null;
|
||||
return text(row, key);
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageTaskReconciliationUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function json(row: Row, key: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text(row, key));
|
||||
} catch {
|
||||
throw new PluginPackageTaskReconciliationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function taskRecord(row: Row): Readonly<TaskDefinitionRecord> {
|
||||
try {
|
||||
return normalizeTaskDefinitionRecord({
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
revision: integer(row, 'revision'),
|
||||
mutationId: text(row, 'mutationId'),
|
||||
name: text(row, 'name'),
|
||||
...(row.description === null
|
||||
? {}
|
||||
: { description: text(row, 'description') }),
|
||||
kind: text(row, 'kind') as TaskDefinitionRecord['kind'],
|
||||
spec: json(row, 'specJson') as TaskDefinitionRecord['spec'],
|
||||
labels: json(row, 'labelsJson') as TaskDefinitionRecord['labels'],
|
||||
enabled: integer(row, 'enabled') === 1,
|
||||
contentDigest: text(row, 'contentDigest'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
updatedAtMs: integer(row, 'updatedAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageTaskReconciliationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageTaskReconciliationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageTaskReconciliationError ||
|
||||
error instanceof PluginPackageTaskReconciliationConflictError ||
|
||||
error instanceof PluginPackageTaskReconciliationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageTaskReconciliationConflictError(
|
||||
'durable reconciliation identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageTaskReconciliationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageTaskReconciliationRepository
|
||||
implements
|
||||
PluginPackageTaskReconciliationRepository,
|
||||
PluginPackageTaskPublicationRecoverySource
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
readonly #dispatchDefinitions: LocalSqliteDispatchDefinitionStore;
|
||||
readonly #registry: TaskSpecSemanticRegistry;
|
||||
|
||||
constructor(
|
||||
authority: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
registry = createBuiltInTaskSpecSemanticRegistry(),
|
||||
) {
|
||||
if (!(registry instanceof TaskSpecSemanticRegistry)) {
|
||||
throw new TypeError('TaskSpec semantic registry is invalid');
|
||||
}
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.#dispatchDefinitions = new LocalSqliteDispatchDefinitionStore(
|
||||
this.#authority.client,
|
||||
);
|
||||
this.#registry = registry;
|
||||
}
|
||||
|
||||
#findStored(
|
||||
generationDigest: string,
|
||||
): Readonly<PluginPackageTaskReconciliationReceipt> | null {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageTaskReconciliations"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(generationDigest) as Row | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
return normalizePluginPackageTaskReconciliationReceipt(
|
||||
json(row, 'receiptJson') as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageTaskReconciliationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageTaskReconciliationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageTaskReconciliationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
find(
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageTaskReconciliationReceipt> | null> {
|
||||
if (typeof generationDigest !== 'string' || !/^[0-9a-f]{64}$/.test(generationDigest)) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'generationDigest is invalid',
|
||||
);
|
||||
}
|
||||
return this.#enqueue(() => this.#findStored(generationDigest));
|
||||
}
|
||||
|
||||
listPendingPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
}>;
|
||||
}): Promise<Readonly<PluginPackageTaskPublicationRecoveryPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'pending page options are invalid',
|
||||
);
|
||||
}
|
||||
assertPluginPackageTaskPublicationRecoveryPageSize(options.limit);
|
||||
const after =
|
||||
options.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageTaskPublicationRecoveryCursor(options.after);
|
||||
return this.#enqueue(() => {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT head.project_id AS "projectId",
|
||||
head.package_name AS "packageName"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
LEFT JOIN "QingLong3PluginPackageTaskReconciliations" AS receipt
|
||||
ON receipt.project_id = install.project_id
|
||||
AND receipt.package_name = install.package_name
|
||||
AND receipt.generation = install.target_generation
|
||||
AND receipt.lock_digest = install.lock_digest
|
||||
WHERE install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND receipt.generation_digest IS NULL
|
||||
AND (
|
||||
? IS NULL OR head.project_id > ? OR
|
||||
(head.project_id = ? AND head.package_name > ?)
|
||||
)
|
||||
ORDER BY head.project_id, head.package_name
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
after?.projectId ?? null,
|
||||
after?.projectId ?? null,
|
||||
after?.projectId ?? null,
|
||||
after?.packageName ?? null,
|
||||
options.limit + 1,
|
||||
) as Row[];
|
||||
const truncated = rows.length > options.limit;
|
||||
const candidates = rows.slice(0, options.limit).map((row) =>
|
||||
Object.freeze({
|
||||
projectId: text(row, 'projectId'),
|
||||
packageName: text(row, 'packageName'),
|
||||
}),
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reconcile(
|
||||
revisionValue: Readonly<PluginPackageMaterializedRevision>,
|
||||
activeGenerationSource: PluginPackageResourceGenerationSource,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageTaskReconciliationReceipt>;
|
||||
}>
|
||||
> {
|
||||
const revision = normalizePluginPackageMaterializedRevision(
|
||||
revisionValue,
|
||||
this.#registry,
|
||||
);
|
||||
if (
|
||||
!activeGenerationSource ||
|
||||
typeof activeGenerationSource.findActiveResourceGeneration !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'active generation source is invalid',
|
||||
);
|
||||
}
|
||||
return this.#enqueue(async () => {
|
||||
const client = this.#authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const existing = this.#findStored(
|
||||
revision.generation.generationDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.materializedRevisionDigest !== revision.revisionDigest ||
|
||||
existing.projectId !== revision.generation.projectId ||
|
||||
existing.packageName !== revision.generation.packageName
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'generation is bound to another materialized revision',
|
||||
);
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt: existing,
|
||||
});
|
||||
}
|
||||
|
||||
const materialized = client
|
||||
.prepare(
|
||||
`SELECT revision_digest AS "revisionDigest"
|
||||
FROM "QingLong3PluginPackageMaterializedRevisions"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(revision.generation.generationDigest) as Row | undefined;
|
||||
if (
|
||||
!materialized ||
|
||||
text(materialized, 'revisionDigest') !== revision.revisionDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'materialized revision is not durably published',
|
||||
);
|
||||
}
|
||||
const install = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
install.installation_id AS "installationId",
|
||||
install.state AS "state",
|
||||
install.target_generation AS "targetGeneration",
|
||||
install.lock_digest AS "lockDigest",
|
||||
install.previous_active_lock_digest AS "previousLockDigest"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
WHERE head.project_id = ? AND head.package_name = ?`,
|
||||
)
|
||||
.get(
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
) as Row | undefined;
|
||||
if (
|
||||
!install ||
|
||||
text(install, 'installationId') !==
|
||||
revision.generation.installationId ||
|
||||
text(install, 'state') !== 'active' ||
|
||||
integer(install, 'targetGeneration') !==
|
||||
revision.generation.generation ||
|
||||
text(install, 'lockDigest') !== revision.generation.lockDigest ||
|
||||
nullableText(install, 'previousLockDigest') !==
|
||||
revision.generation.previousActiveLockDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'Package install head is not the materialized generation',
|
||||
);
|
||||
}
|
||||
const previous =
|
||||
revision.generation.generation === 1
|
||||
? null
|
||||
: (() => {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageTaskReconciliations"
|
||||
WHERE project_id = ? AND package_name = ?
|
||||
AND generation = ?`,
|
||||
)
|
||||
.get(
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
revision.generation.generation - 1,
|
||||
) as Row | undefined;
|
||||
return row
|
||||
? normalizePluginPackageTaskReconciliationReceipt(
|
||||
json(row, 'receiptJson') as PluginPackageTaskReconciliationReceipt,
|
||||
)
|
||||
: null;
|
||||
})();
|
||||
const taskIds = pluginPackageTaskReconciliationTaskIds(
|
||||
revision,
|
||||
previous,
|
||||
this.#registry,
|
||||
);
|
||||
const facts: PluginPackageTaskOwnershipFact[] = taskIds.map((taskId) => {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
${SELECT_TASK_FIELDS},
|
||||
ownership.package_name AS "ownerPackageName"
|
||||
FROM (SELECT 1) AS seed
|
||||
LEFT JOIN "QingLong3TaskDefinitions" AS head
|
||||
ON head.project_id = ? AND head.task_id = ?
|
||||
LEFT JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
LEFT JOIN "QingLong3PluginPackageTaskOwnerships" AS ownership
|
||||
ON ownership.project_id = ? AND ownership.task_id = ?`,
|
||||
)
|
||||
.get(
|
||||
revision.generation.projectId,
|
||||
taskId,
|
||||
revision.generation.projectId,
|
||||
taskId,
|
||||
) as Row;
|
||||
return Object.freeze({
|
||||
taskId,
|
||||
packageName:
|
||||
row.ownerPackageName === null
|
||||
? null
|
||||
: text(row, 'ownerPackageName'),
|
||||
current: row.revision === null ? null : taskRecord(row),
|
||||
});
|
||||
});
|
||||
const clock = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS "nowMs"`,
|
||||
)
|
||||
.get() as Row;
|
||||
const plan = planPluginPackageTaskReconciliation({
|
||||
revision,
|
||||
previousReceipt: previous,
|
||||
facts: Object.freeze(facts),
|
||||
committedAtMs: integer(clock, 'nowMs'),
|
||||
taskSpecSemanticRegistry: this.#registry,
|
||||
});
|
||||
|
||||
const activeValue =
|
||||
await activeGenerationSource.findActiveResourceGeneration(
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
);
|
||||
if (
|
||||
activeValue === null ||
|
||||
normalizePluginPackageResourceGeneration(activeValue)
|
||||
.generationDigest !== revision.generation.generationDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'active generation changed during reconciliation',
|
||||
);
|
||||
}
|
||||
|
||||
for (const write of plan.writes) {
|
||||
const definition = write.definition;
|
||||
if (write.command.expectedRevision === null) {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitions" (
|
||||
project_id, task_id, current_revision,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
definition.projectId,
|
||||
definition.taskId,
|
||||
definition.createdAtMs,
|
||||
definition.updatedAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageTaskOwnerships" (
|
||||
project_id, task_id, package_name,
|
||||
claimed_generation_digest, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
definition.projectId,
|
||||
definition.taskId,
|
||||
revision.generation.packageName,
|
||||
revision.generation.generationDigest,
|
||||
plan.receipt.committedAtMs,
|
||||
);
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||
project_id, task_id, revision, mutation_id, name,
|
||||
description, kind, spec_json, labels_json, enabled,
|
||||
content_digest, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
definition.projectId,
|
||||
definition.taskId,
|
||||
definition.revision,
|
||||
definition.mutationId,
|
||||
definition.name,
|
||||
definition.description ?? null,
|
||||
definition.kind,
|
||||
JSON.stringify(definition.spec),
|
||||
JSON.stringify(definition.labels),
|
||||
definition.enabled ? 1 : 0,
|
||||
definition.contentDigest,
|
||||
definition.updatedAtMs,
|
||||
);
|
||||
if (
|
||||
definition.enabled &&
|
||||
definition.kind === 'command' &&
|
||||
definition.spec.schema === BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
|
||||
) {
|
||||
this.#dispatchDefinitions.appendPlan(
|
||||
compileLocalCommandTaskDefinition(definition, this.#registry),
|
||||
);
|
||||
}
|
||||
if (write.command.expectedRevision !== null) {
|
||||
const update = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3TaskDefinitions"
|
||||
SET current_revision = ?, updated_at_ms = ?
|
||||
WHERE project_id = ? AND task_id = ?
|
||||
AND current_revision = ?`,
|
||||
)
|
||||
.run(
|
||||
definition.revision,
|
||||
definition.updatedAtMs,
|
||||
definition.projectId,
|
||||
definition.taskId,
|
||||
write.command.expectedRevision,
|
||||
);
|
||||
if (update.changes !== 1) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'TaskDefinition head changed during reconciliation',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageTaskReconciliations" (
|
||||
generation_digest, project_id, package_name, generation,
|
||||
materialized_revision_digest, lock_digest,
|
||||
previous_lock_digest, receipt_digest, receipt_json,
|
||||
committed_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
plan.receipt.generationDigest,
|
||||
plan.receipt.projectId,
|
||||
plan.receipt.packageName,
|
||||
plan.receipt.generation,
|
||||
plan.receipt.materializedRevisionDigest,
|
||||
plan.receipt.lockDigest,
|
||||
plan.receipt.previousLockDigest,
|
||||
plan.receipt.receiptDigest,
|
||||
JSON.stringify(plan.receipt),
|
||||
plan.receipt.committedAtMs,
|
||||
);
|
||||
const insertItem = client.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageTaskReconciliationItems" (
|
||||
generation_digest, task_id, revision, disposition, content_digest
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const item of plan.receipt.items) {
|
||||
insertItem.run(
|
||||
plan.receipt.generationDigest,
|
||||
item.taskId,
|
||||
item.revision,
|
||||
item.disposition,
|
||||
item.contentDigest,
|
||||
);
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt: plan.receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+890
@@ -0,0 +1,890 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
normalizePluginPackageAutomationPublication,
|
||||
type PluginPackageAutomationPublication,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import {
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
PluginPackageWorkflowAdministrationMutationConflictError,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
import {
|
||||
createPluginPackageWorkflowAdmissionBundle,
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
normalizePluginPackageWorkflowAdmissionReceipt,
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
pluginPackageWorkflowDefinitionDigest,
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
PluginPackageWorkflowAdmissionNotAllowedError,
|
||||
PluginPackageWorkflowAdmissionUnavailableError,
|
||||
type PluginPackageWorkflowAdmissionBundle,
|
||||
type PluginPackageWorkflowAdmissionReceipt,
|
||||
type PluginPackageWorkflowAdmissionRepository,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const WORKFLOW_RUN_STATUSES = new Set([
|
||||
'running',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowExecutionPlanError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function sqliteConstraint(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
const errcode = (error as { errcode?: unknown }).errcode;
|
||||
return (
|
||||
(typeof code === 'string' &&
|
||||
(code === 'ERR_SQLITE_CONSTRAINT' ||
|
||||
code.startsWith('SQLITE_CONSTRAINT') ||
|
||||
code.startsWith('ERR_SQLITE_CONSTRAINT'))) ||
|
||||
(typeof errcode === 'number' && (errcode & 0xff) === 19)
|
||||
);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowExecutionPlanError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionConflictError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionNotAllowedError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (sqliteConstraint(error)) {
|
||||
return new PluginPackageWorkflowAdmissionConflictError(
|
||||
'durable Run, plan, StepRun, event, or receipt identity changed',
|
||||
);
|
||||
}
|
||||
return new PluginPackageWorkflowAdmissionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export interface LocalSqlitePluginPackageWorkflowAdmissionTransactionContext {
|
||||
readonly replay: boolean;
|
||||
readonly plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
readonly receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
}
|
||||
|
||||
export type LocalSqlitePluginPackageWorkflowAdmissionTransactionGuard = (
|
||||
context: Readonly<LocalSqlitePluginPackageWorkflowAdmissionTransactionContext>,
|
||||
) => void;
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function exactArray(
|
||||
left: readonly string[],
|
||||
right: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function insertRun(
|
||||
client: DatabaseSync,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): void {
|
||||
const run = bundle.run;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "Runs" (
|
||||
id, project_id, task_id, task_revision, task_snapshot_ref,
|
||||
trigger_type, execution_origin, execution_owner, request_id,
|
||||
status, version, event_sequence, priority, idempotency_key,
|
||||
created_at_ms, started_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
run.id,
|
||||
run.projectId,
|
||||
run.taskId,
|
||||
run.taskRevision,
|
||||
run.taskSnapshotRef ?? null,
|
||||
run.triggerType,
|
||||
run.executionOrigin,
|
||||
run.executionOwner,
|
||||
run.requestId ?? null,
|
||||
run.status,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
run.priority,
|
||||
run.idempotencyKey ?? null,
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function insertAdmissionEvent(
|
||||
client: DatabaseSync,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): void {
|
||||
const event = bundle.admissionEvent;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
canonicalJson(event.payload),
|
||||
event.createdAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function insertStepEvidence(
|
||||
client: DatabaseSync,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): void {
|
||||
for (const mutation of bundle.stepMutations) {
|
||||
const stepRun = mutation.stepRun;
|
||||
const event = mutation.event;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "StepRuns" (
|
||||
id, run_id, parent_step_run_id, step_key, kind, definition_ref,
|
||||
definition_digest, required, status, version, attempt_count,
|
||||
input_ref, output_ref, approval_request_id, ready_at_ms,
|
||||
started_at_ms, finished_at_ms, result_code, error_summary,
|
||||
created_at_ms, updated_at_ms, last_mutation_id, step_run_digest,
|
||||
step_run_json
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)`,
|
||||
)
|
||||
.run(
|
||||
stepRun.id,
|
||||
stepRun.runId,
|
||||
stepRun.parentStepRunId,
|
||||
stepRun.stepKey,
|
||||
stepRun.kind,
|
||||
stepRun.definitionRef,
|
||||
stepRun.definitionDigest,
|
||||
stepRun.required ? 1 : 0,
|
||||
stepRun.status,
|
||||
stepRun.version,
|
||||
stepRun.attemptCount,
|
||||
stepRun.inputRef,
|
||||
stepRun.outputRef,
|
||||
stepRun.approvalRequestId,
|
||||
stepRun.readyAtMs,
|
||||
stepRun.startedAtMs,
|
||||
stepRun.finishedAtMs,
|
||||
stepRun.resultCode,
|
||||
stepRun.errorSummary,
|
||||
stepRun.createdAtMs,
|
||||
stepRun.updatedAtMs,
|
||||
stepRun.lastMutationId,
|
||||
stepRun.stepRunDigest,
|
||||
canonicalJson(stepRun),
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
stepRun.id,
|
||||
canonicalJson(event.payload),
|
||||
event.createdAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "StepRunMutations" (
|
||||
mutation_id, mutation_digest, run_id, step_run_id,
|
||||
step_run_digest, event_id, event_sequence, run_version,
|
||||
step_run_json, committed_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
stepRun.id,
|
||||
stepRun.stepRunDigest,
|
||||
event.id,
|
||||
event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
canonicalJson(stepRun),
|
||||
bundle.receipt.admittedAtMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function insertAdmission(
|
||||
client: DatabaseSync,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): void {
|
||||
const { plan, receipt } = bundle;
|
||||
const target = plan.target;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageWorkflowAdmissions" (
|
||||
plan_digest, plan_id, run_id, project_id, package_name,
|
||||
installation_id, lock_digest, generation, generation_digest,
|
||||
materialized_revision_digest, publication_digest, workflow_id,
|
||||
workflow_definition_digest, step_count, admitted_at_ms,
|
||||
final_run_version, final_run_event_sequence, receipt_digest,
|
||||
plan_json, receipt_json
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)`,
|
||||
)
|
||||
.run(
|
||||
plan.planDigest,
|
||||
plan.planId,
|
||||
plan.runId,
|
||||
target.projectId,
|
||||
target.packageName,
|
||||
target.installationId,
|
||||
target.lockDigest,
|
||||
target.generation,
|
||||
target.generationDigest,
|
||||
target.materializedRevisionDigest,
|
||||
target.publicationDigest,
|
||||
target.workflowId,
|
||||
target.workflowDefinitionDigest,
|
||||
plan.steps.length,
|
||||
receipt.admittedAtMs,
|
||||
receipt.finalRunVersion,
|
||||
receipt.finalRunEventSequence,
|
||||
receipt.receiptDigest,
|
||||
canonicalJson(plan),
|
||||
canonicalJson(receipt),
|
||||
);
|
||||
for (const step of plan.steps) {
|
||||
const mutation = bundle.stepMutations.find(
|
||||
(candidate) => candidate.stepRun.stepKey === step.stepKey,
|
||||
);
|
||||
if (!mutation) {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageWorkflowAdmissionSteps" (
|
||||
plan_digest, run_id, step_key, step_run_id, task_id,
|
||||
task_definition_ref, task_definition_digest, needs_json,
|
||||
initial_status, mutation_id, event_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
plan.planDigest,
|
||||
plan.runId,
|
||||
step.stepKey,
|
||||
step.stepRunId,
|
||||
step.taskId,
|
||||
step.taskDefinitionRef,
|
||||
step.taskDefinitionDigest,
|
||||
canonicalJson(step.needs),
|
||||
step.initialStatus,
|
||||
mutation.mutationId,
|
||||
mutation.event.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageWorkflowAdmissionRepository
|
||||
implements PluginPackageWorkflowAdmissionRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageWorkflowAdmissionUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
#select(where: string, value: string): Row | undefined {
|
||||
return this.#authority.client
|
||||
.prepare(
|
||||
`SELECT
|
||||
plan_digest AS "planDigest", plan_id AS "planId",
|
||||
run_id AS "runId", project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest", generation,
|
||||
generation_digest AS "generationDigest",
|
||||
materialized_revision_digest AS "materializedRevisionDigest",
|
||||
publication_digest AS "publicationDigest",
|
||||
workflow_id AS "workflowId",
|
||||
workflow_definition_digest AS "workflowDefinitionDigest",
|
||||
step_count AS "stepCount", admitted_at_ms AS "admittedAtMs",
|
||||
final_run_version AS "finalRunVersion",
|
||||
final_run_event_sequence AS "finalRunEventSequence",
|
||||
receipt_digest AS "receiptDigest", plan_json AS "planJson",
|
||||
receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions"
|
||||
WHERE ${where} LIMIT 2`,
|
||||
)
|
||||
.get(value) as Row | undefined;
|
||||
}
|
||||
|
||||
#parse(row: Row): Readonly<{
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>;
|
||||
}> {
|
||||
try {
|
||||
const plan = normalizePluginPackageWorkflowExecutionPlan(
|
||||
JSON.parse(text(row, 'planJson')) as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
const receipt = normalizePluginPackageWorkflowAdmissionReceipt(
|
||||
JSON.parse(
|
||||
text(row, 'receiptJson'),
|
||||
) as PluginPackageWorkflowAdmissionReceipt,
|
||||
);
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
if (
|
||||
plan.planDigest !== text(row, 'planDigest') ||
|
||||
plan.planId !== text(row, 'planId') ||
|
||||
plan.runId !== text(row, 'runId') ||
|
||||
plan.target.projectId !== text(row, 'projectId') ||
|
||||
plan.target.packageName !== text(row, 'packageName') ||
|
||||
plan.target.installationId !== text(row, 'installationId') ||
|
||||
plan.target.lockDigest !== text(row, 'lockDigest') ||
|
||||
plan.target.generation !== integer(row, 'generation') ||
|
||||
plan.target.generationDigest !== text(row, 'generationDigest') ||
|
||||
plan.target.materializedRevisionDigest !==
|
||||
text(row, 'materializedRevisionDigest') ||
|
||||
plan.target.publicationDigest !== text(row, 'publicationDigest') ||
|
||||
plan.target.workflowId !== text(row, 'workflowId') ||
|
||||
plan.target.workflowDefinitionDigest !==
|
||||
text(row, 'workflowDefinitionDigest') ||
|
||||
plan.steps.length !== integer(row, 'stepCount') ||
|
||||
receipt.admittedAtMs !== integer(row, 'admittedAtMs') ||
|
||||
receipt.finalRunVersion !== integer(row, 'finalRunVersion') ||
|
||||
receipt.finalRunEventSequence !==
|
||||
integer(row, 'finalRunEventSequence') ||
|
||||
receipt.receiptDigest !== text(row, 'receiptDigest') ||
|
||||
canonicalJson(bundle.receipt) !== canonicalJson(receipt)
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
this.#assertStoredEvidence(bundle);
|
||||
return Object.freeze({ plan, receipt, bundle });
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageWorkflowAdmissionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#assertStoredEvidence(
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): void {
|
||||
const run = bundle.run;
|
||||
const storedRun = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision",
|
||||
task_snapshot_ref AS "taskSnapshotRef",
|
||||
trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner",
|
||||
request_id AS "requestId", status, version,
|
||||
event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey",
|
||||
created_at_ms AS "createdAtMs",
|
||||
started_at_ms AS "startedAtMs"
|
||||
FROM "Runs" WHERE id = ?`,
|
||||
)
|
||||
.get(run.id) as Row | undefined;
|
||||
const storedRunVersion = storedRun ? integer(storedRun, 'version') : -1;
|
||||
const storedEventSequence = storedRun
|
||||
? integer(storedRun, 'eventSequence')
|
||||
: -1;
|
||||
if (
|
||||
!storedRun ||
|
||||
text(storedRun, 'projectId') !== run.projectId ||
|
||||
text(storedRun, 'taskId') !== run.taskId ||
|
||||
text(storedRun, 'taskRevision') !== run.taskRevision ||
|
||||
text(storedRun, 'taskSnapshotRef') !== run.taskSnapshotRef ||
|
||||
text(storedRun, 'triggerType') !== run.triggerType ||
|
||||
text(storedRun, 'executionOrigin') !== run.executionOrigin ||
|
||||
text(storedRun, 'executionOwner') !== run.executionOwner ||
|
||||
text(storedRun, 'requestId') !== run.requestId ||
|
||||
!WORKFLOW_RUN_STATUSES.has(text(storedRun, 'status')) ||
|
||||
storedRunVersion < run.version ||
|
||||
storedEventSequence < run.eventSequence ||
|
||||
storedRunVersion !== storedEventSequence ||
|
||||
integer(storedRun, 'priority') !== run.priority ||
|
||||
text(storedRun, 'idempotencyKey') !== run.idempotencyKey ||
|
||||
integer(storedRun, 'createdAtMs') !== run.createdAtMs ||
|
||||
integer(storedRun, 'startedAtMs') !== run.startedAtMs
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
const events = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT id, sequence, type, dedupe_key AS "dedupeKey",
|
||||
actor_type AS "actorType", actor_id AS "actorId",
|
||||
step_run_id AS "stepRunId", payload,
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "RunEvents"
|
||||
WHERE run_id = ? AND sequence <= ?
|
||||
ORDER BY sequence`,
|
||||
)
|
||||
.all(run.id, bundle.receipt.finalRunEventSequence) as Row[];
|
||||
const expectedEvents = [
|
||||
bundle.admissionEvent,
|
||||
...bundle.stepMutations.map(({ event }) => event),
|
||||
];
|
||||
if (
|
||||
events.length !== expectedEvents.length ||
|
||||
events.some((row, index) => {
|
||||
const event = expectedEvents[index]!;
|
||||
return (
|
||||
text(row, 'id') !== event.id ||
|
||||
integer(row, 'sequence') !== event.sequence ||
|
||||
text(row, 'type') !== event.type ||
|
||||
text(row, 'dedupeKey') !== event.dedupeKey ||
|
||||
text(row, 'actorType') !== event.actorType ||
|
||||
(row.actorId === null ? undefined : row.actorId) !== event.actorId ||
|
||||
(row.stepRunId === null ? undefined : row.stepRunId) !==
|
||||
event.stepRunId ||
|
||||
text(row, 'payload') !== canonicalJson(event.payload) ||
|
||||
integer(row, 'createdAtMs') !== event.createdAtMs
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
for (const mutation of bundle.stepMutations) {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT
|
||||
step.step_key AS "stepKey", step.step_run_id AS "stepRunId",
|
||||
step.task_id AS "taskId",
|
||||
step.task_definition_ref AS "taskDefinitionRef",
|
||||
step.task_definition_digest AS "taskDefinitionDigest",
|
||||
step.needs_json AS "needsJson",
|
||||
step.initial_status AS "initialStatus",
|
||||
step.mutation_id AS "mutationId", step.event_id AS "eventId",
|
||||
runtime.step_key AS "currentStepKey",
|
||||
runtime.kind AS "currentKind",
|
||||
runtime.definition_ref AS "currentDefinitionRef",
|
||||
runtime.definition_digest AS "currentDefinitionDigest",
|
||||
runtime.required AS "currentRequired",
|
||||
runtime.status AS "currentStatus",
|
||||
runtime.version AS "currentVersion",
|
||||
runtime.last_mutation_id AS "currentLastMutationId",
|
||||
runtime.step_run_digest AS "currentStepRunDigest",
|
||||
runtime.step_run_json AS "currentStepRunJson",
|
||||
mutation.mutation_digest AS "mutationDigest",
|
||||
mutation.event_sequence AS "eventSequence",
|
||||
mutation.run_version AS "runVersion",
|
||||
mutation.step_run_digest AS "initialStepRunDigest",
|
||||
mutation.step_run_json AS "initialStepRunJson"
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissionSteps" AS step
|
||||
JOIN "StepRuns" AS runtime
|
||||
ON runtime.run_id = step.run_id
|
||||
AND runtime.id = step.step_run_id
|
||||
JOIN "StepRunMutations" AS mutation
|
||||
ON mutation.mutation_id = step.mutation_id
|
||||
WHERE step.plan_digest = ? AND step.step_key = ?`,
|
||||
)
|
||||
.get(bundle.plan.planDigest, mutation.stepRun.stepKey) as
|
||||
| Row
|
||||
| undefined;
|
||||
const planStep = bundle.plan.steps.find(
|
||||
({ stepKey }) => stepKey === mutation.stepRun.stepKey,
|
||||
);
|
||||
let currentStepRun: Readonly<StepRunRecord> | null = null;
|
||||
if (row) {
|
||||
try {
|
||||
currentStepRun = normalizeStepRunRecord(
|
||||
JSON.parse(text(row, 'currentStepRunJson')) as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
}
|
||||
if (
|
||||
!row ||
|
||||
!planStep ||
|
||||
!currentStepRun ||
|
||||
text(row, 'stepRunId') !== mutation.stepRun.id ||
|
||||
text(row, 'taskId') !== planStep.taskId ||
|
||||
text(row, 'taskDefinitionRef') !== planStep.taskDefinitionRef ||
|
||||
text(row, 'taskDefinitionDigest') !== planStep.taskDefinitionDigest ||
|
||||
text(row, 'needsJson') !== canonicalJson(planStep.needs) ||
|
||||
text(row, 'initialStatus') !== planStep.initialStatus ||
|
||||
text(row, 'mutationId') !== mutation.mutationId ||
|
||||
text(row, 'eventId') !== mutation.event.id ||
|
||||
text(row, 'mutationDigest') !== mutation.mutationDigest ||
|
||||
integer(row, 'eventSequence') !== mutation.event.sequence ||
|
||||
integer(row, 'runVersion') !== mutation.expectedRunVersion + 1 ||
|
||||
text(row, 'initialStepRunDigest') !== mutation.stepRun.stepRunDigest ||
|
||||
text(row, 'initialStepRunJson') !== canonicalJson(mutation.stepRun) ||
|
||||
currentStepRun.id !== mutation.stepRun.id ||
|
||||
currentStepRun.runId !== mutation.runId ||
|
||||
currentStepRun.stepKey !== planStep.stepKey ||
|
||||
currentStepRun.kind !== 'task' ||
|
||||
currentStepRun.definitionRef !== planStep.taskDefinitionRef ||
|
||||
currentStepRun.definitionDigest !== planStep.taskDefinitionDigest ||
|
||||
currentStepRun.required !== planStep.required ||
|
||||
currentStepRun.version < mutation.stepRun.version ||
|
||||
text(row, 'currentStepKey') !== currentStepRun.stepKey ||
|
||||
text(row, 'currentKind') !== currentStepRun.kind ||
|
||||
text(row, 'currentDefinitionRef') !== currentStepRun.definitionRef ||
|
||||
text(row, 'currentDefinitionDigest') !==
|
||||
currentStepRun.definitionDigest ||
|
||||
integer(row, 'currentRequired') !== (currentStepRun.required ? 1 : 0) ||
|
||||
text(row, 'currentStatus') !== currentStepRun.status ||
|
||||
integer(row, 'currentVersion') !== currentStepRun.version ||
|
||||
text(row, 'currentLastMutationId') !== currentStepRun.lastMutationId ||
|
||||
text(row, 'currentStepRunDigest') !== currentStepRun.stepRunDigest
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#find(
|
||||
where: string,
|
||||
value: string,
|
||||
): Readonly<{
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>;
|
||||
}> | null {
|
||||
const row = this.#select(where, value);
|
||||
return row ? this.#parse(row) : null;
|
||||
}
|
||||
|
||||
#assertCurrentTarget(
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
): void {
|
||||
const guard = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT publication.publication_json AS "publicationJson"
|
||||
FROM "QingLong3PluginPackageAutomationPublicationHeads" AS head
|
||||
JOIN "QingLong3PluginPackageAutomationPublications" AS publication
|
||||
ON publication.publication_digest = head.publication_digest
|
||||
JOIN "QingLong3PluginPackageInstallHeads" AS install_head
|
||||
ON install_head.project_id = publication.project_id
|
||||
AND install_head.package_name = publication.package_name
|
||||
AND install_head.installation_id = publication.installation_id
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = install_head.installation_id
|
||||
AND install.lock_digest = publication.lock_digest
|
||||
LEFT JOIN "QingLong3PluginPackageLifecycleHeads" AS lifecycle
|
||||
ON lifecycle.project_id = publication.project_id
|
||||
AND lifecycle.package_name = publication.package_name
|
||||
WHERE head.project_id = ?
|
||||
AND head.package_name = ?
|
||||
AND head.publication_digest = ?
|
||||
AND publication.state = 'active'
|
||||
AND install.state = 'active'
|
||||
AND install.active_lock_digest = publication.lock_digest
|
||||
AND (
|
||||
lifecycle.event_digest IS NULL OR
|
||||
lifecycle.disposition = 'active'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
WHERE quarantine.project_id = publication.project_id
|
||||
AND quarantine.package_name = publication.package_name
|
||||
AND quarantine.installation_id = publication.installation_id
|
||||
AND quarantine.lock_digest = publication.lock_digest
|
||||
)`,
|
||||
)
|
||||
.get(
|
||||
plan.target.projectId,
|
||||
plan.target.packageName,
|
||||
plan.target.publicationDigest,
|
||||
) as Row | undefined;
|
||||
if (!guard) throw new PluginPackageWorkflowAdmissionNotAllowedError();
|
||||
|
||||
let publication: Readonly<PluginPackageAutomationPublication>;
|
||||
try {
|
||||
publication = normalizePluginPackageAutomationPublication(
|
||||
JSON.parse(
|
||||
text(guard, 'publicationJson'),
|
||||
) as PluginPackageAutomationPublication,
|
||||
);
|
||||
} catch {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
const target = plan.target;
|
||||
const workflow = publication.definitions.workflows.find(
|
||||
({ id }) => id === target.workflowId,
|
||||
);
|
||||
if (
|
||||
publication.publicationDigest !== target.publicationDigest ||
|
||||
publication.target.projectId !== target.projectId ||
|
||||
publication.target.packageName !== target.packageName ||
|
||||
publication.target.installationId !== target.installationId ||
|
||||
publication.target.lockDigest !== target.lockDigest ||
|
||||
publication.target.generation !== target.generation ||
|
||||
publication.target.generationDigest !== target.generationDigest ||
|
||||
publication.target.materializedRevisionDigest !==
|
||||
target.materializedRevisionDigest ||
|
||||
publication.state !== 'active' ||
|
||||
!workflow ||
|
||||
!workflow.enabled ||
|
||||
pluginPackageWorkflowDefinitionDigest(workflow) !==
|
||||
target.workflowDefinitionDigest ||
|
||||
workflow.steps.length !== plan.steps.length
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'the exact Workflow publication drifted',
|
||||
);
|
||||
}
|
||||
|
||||
const revision = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT revision_json AS "revisionJson"
|
||||
FROM "QingLong3PluginPackageMaterializedRevisions"
|
||||
WHERE generation_digest = ?
|
||||
AND project_id = ?
|
||||
AND package_name = ?
|
||||
AND generation = ?
|
||||
AND lock_digest = ?
|
||||
AND revision_digest = ?`,
|
||||
)
|
||||
.get(
|
||||
target.generationDigest,
|
||||
target.projectId,
|
||||
target.packageName,
|
||||
target.generation,
|
||||
target.lockDigest,
|
||||
target.materializedRevisionDigest,
|
||||
) as Row | undefined;
|
||||
if (!revision) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'the exact materialized revision is absent',
|
||||
);
|
||||
}
|
||||
let resources: unknown;
|
||||
try {
|
||||
const parsed = JSON.parse(text(revision, 'revisionJson')) as {
|
||||
resources?: unknown;
|
||||
};
|
||||
resources = parsed.resources;
|
||||
} catch {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
if (!Array.isArray(resources)) {
|
||||
throw new PluginPackageWorkflowAdmissionUnavailableError();
|
||||
}
|
||||
for (const step of plan.steps) {
|
||||
const workflowStep = workflow.steps.find(({ id }) => id === step.stepKey);
|
||||
const matches = resources.filter((resource) => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
const candidate = resource as {
|
||||
kind?: unknown;
|
||||
sourceDigest?: unknown;
|
||||
value?: {
|
||||
id?: unknown;
|
||||
enabled?: unknown;
|
||||
};
|
||||
};
|
||||
return (
|
||||
candidate.kind === 'task' &&
|
||||
candidate.sourceDigest === step.taskDefinitionDigest &&
|
||||
candidate.value?.id === step.taskId &&
|
||||
candidate.value.enabled === true
|
||||
);
|
||||
});
|
||||
if (
|
||||
!workflowStep ||
|
||||
workflowStep.task !== step.taskId ||
|
||||
!exactArray(workflowStep.needs, step.needs) ||
|
||||
step.initialStatus !==
|
||||
(workflowStep.needs.length === 0 ? 'ready' : 'pending') ||
|
||||
step.taskDefinitionRef !==
|
||||
`plugin-package:${target.materializedRevisionDigest}:task:${step.taskId}` ||
|
||||
matches.length !== 1
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'the exact Workflow step or Task evidence drifted',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findByPlanId(
|
||||
planIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null> {
|
||||
const planId = identity(planIdValue, 'planId');
|
||||
return this.#enqueue(
|
||||
() => this.#find('plan_id = ?', planId)?.receipt ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
findByRunId(
|
||||
runIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
return this.#enqueue(
|
||||
() => this.#find('run_id = ?', runId)?.receipt ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
findPlanByPlanId(
|
||||
planIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowExecutionPlan> | null> {
|
||||
const planId = identity(planIdValue, 'planId');
|
||||
return this.#enqueue(() => this.#find('plan_id = ?', planId)?.plan ?? null);
|
||||
}
|
||||
|
||||
admit(
|
||||
planValue: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
transactionGuard?: LocalSqlitePluginPackageWorkflowAdmissionTransactionGuard,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
}>
|
||||
> {
|
||||
if (
|
||||
transactionGuard !== undefined &&
|
||||
typeof transactionGuard !== 'function'
|
||||
) {
|
||||
return Promise.reject(
|
||||
new InvalidPluginPackageWorkflowExecutionPlanError(
|
||||
'transaction guard is invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
const plan = normalizePluginPackageWorkflowExecutionPlan(planValue);
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
return this.#enqueue(() => {
|
||||
let began = false;
|
||||
try {
|
||||
this.#authority.client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const existing = this.#find('plan_id = ?', plan.planId);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.plan.planDigest !== plan.planDigest ||
|
||||
canonicalJson(existing.plan) !== canonicalJson(plan)
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'planId is already bound to another plan',
|
||||
);
|
||||
}
|
||||
transactionGuard?.(
|
||||
Object.freeze({
|
||||
replay: true,
|
||||
plan: existing.plan,
|
||||
receipt: existing.receipt,
|
||||
}),
|
||||
);
|
||||
this.#authority.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt: existing.receipt,
|
||||
});
|
||||
}
|
||||
transactionGuard?.(
|
||||
Object.freeze({
|
||||
replay: false,
|
||||
plan,
|
||||
receipt: bundle.receipt,
|
||||
}),
|
||||
);
|
||||
this.#assertCurrentTarget(plan);
|
||||
insertRun(this.#authority.client, bundle);
|
||||
insertAdmissionEvent(this.#authority.client, bundle);
|
||||
insertStepEvidence(this.#authority.client, bundle);
|
||||
insertAdmission(this.#authority.client, bundle);
|
||||
this.#authority.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt: bundle.receipt,
|
||||
});
|
||||
} finally {
|
||||
if (began) {
|
||||
try {
|
||||
this.#authority.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original fail-closed error.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
ClusterRunCancellationConvergenceUnavailableError,
|
||||
normalizeClusterRunCancellationConvergencePageCommand,
|
||||
normalizeClusterRunCancellationConvergencePageResult,
|
||||
type ClusterRunCancellationConvergencePageCommand,
|
||||
type ClusterRunCancellationConvergencePageResult,
|
||||
type ClusterRunCancellationConvergenceRepository,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation-convergence';
|
||||
import {
|
||||
resolvePluginPackageWorkflowCancellation,
|
||||
type PluginPackageWorkflowCancellationActiveAttempt,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-cancellation-convergence';
|
||||
import type {
|
||||
PluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunMutation,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): ClusterRunCancellationConvergenceUnavailableError {
|
||||
return new ClusterRunCancellationConvergenceUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string' || value.length < 1) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
return row[key] === null || row[key] === undefined
|
||||
? undefined
|
||||
: integer(row, key);
|
||||
}
|
||||
|
||||
function json(row: Row, key: string): Readonly<Record<string, unknown>> {
|
||||
try {
|
||||
const value = JSON.parse(text(row, key)) as unknown;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({ ...(value as Record<string, unknown>) });
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterRunCancellationConvergenceUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function runFromRow(row: Row): Readonly<RunRecord> {
|
||||
return Object.freeze({
|
||||
id: text(row, 'id'),
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
triggerType: text(row, 'triggerType'),
|
||||
executionOrigin: text(
|
||||
row,
|
||||
'executionOrigin',
|
||||
) as RunRecord['executionOrigin'],
|
||||
executionOwner: text(
|
||||
row,
|
||||
'executionOwner',
|
||||
) as RunRecord['executionOwner'],
|
||||
requestId: text(row, 'requestId'),
|
||||
status: text(row, 'status') as RunRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
eventSequence: integer(row, 'eventSequence'),
|
||||
priority: integer(row, 'priority'),
|
||||
idempotencyKey: text(row, 'idempotencyKey'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
...(optionalInteger(row, 'startedAtMs') === undefined
|
||||
? {}
|
||||
: { startedAtMs: optionalInteger(row, 'startedAtMs')! }),
|
||||
cancelRequestedAtMs: integer(row, 'cancelRequestedAtMs'),
|
||||
cancelReason: text(
|
||||
row,
|
||||
'cancelReason',
|
||||
) as NonNullable<RunRecord['cancelReason']>,
|
||||
});
|
||||
}
|
||||
|
||||
function attemptFromRow(row: Row): Readonly<RunAttemptRecord> {
|
||||
return Object.freeze({
|
||||
id: text(row, 'attemptId'),
|
||||
runId: text(row, 'attemptRunId'),
|
||||
stepRunId: text(row, 'attemptStepRunId'),
|
||||
attempt: integer(row, 'attemptNumber'),
|
||||
status: text(row, 'attemptStatus') as RunAttemptRecord['status'],
|
||||
executorType: text(row, 'executorType'),
|
||||
callbackSequence: integer(row, 'callbackSequence'),
|
||||
createdAtMs: integer(row, 'attemptCreatedAtMs'),
|
||||
...(optionalInteger(row, 'attemptStartedAtMs') === undefined
|
||||
? {}
|
||||
: {
|
||||
startedAtMs: optionalInteger(row, 'attemptStartedAtMs')!,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function insertEvent(client: DatabaseSync, event: Readonly<RunEventRecord>): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
event.attemptId ?? null,
|
||||
event.stepRunId ?? null,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function updateStepRun(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
const stepRun = mutation.stepRun;
|
||||
const updated = client
|
||||
.prepare(
|
||||
`UPDATE "StepRuns"
|
||||
SET status = ?, version = ?, attempt_count = ?, output_ref = ?,
|
||||
approval_request_id = ?, ready_at_ms = ?, started_at_ms = ?,
|
||||
finished_at_ms = ?, result_code = ?, error_summary = ?,
|
||||
updated_at_ms = ?, last_mutation_id = ?, step_run_digest = ?,
|
||||
step_run_json = ?
|
||||
WHERE id = ? AND run_id = ? AND version = ?
|
||||
AND step_run_digest = ? AND status = ?`,
|
||||
)
|
||||
.run(
|
||||
stepRun.status,
|
||||
stepRun.version,
|
||||
stepRun.attemptCount,
|
||||
stepRun.outputRef,
|
||||
stepRun.approvalRequestId,
|
||||
stepRun.readyAtMs,
|
||||
stepRun.startedAtMs,
|
||||
stepRun.finishedAtMs,
|
||||
stepRun.resultCode,
|
||||
stepRun.errorSummary,
|
||||
stepRun.updatedAtMs,
|
||||
stepRun.lastMutationId,
|
||||
stepRun.stepRunDigest,
|
||||
JSON.stringify(stepRun),
|
||||
stepRun.id,
|
||||
stepRun.runId,
|
||||
mutation.expectedStepRunVersion,
|
||||
mutation.expectedStepRunDigest,
|
||||
mutation.previousStatus,
|
||||
);
|
||||
if (updated.changes !== 1) throw unavailable();
|
||||
}
|
||||
|
||||
function insertStepMutation(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
committedAtMs: number,
|
||||
): void {
|
||||
insertEvent(client, mutation.event);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "StepRunMutations" (
|
||||
mutation_id, mutation_digest, run_id, step_run_id,
|
||||
step_run_digest, event_id, event_sequence, run_version,
|
||||
step_run_json, committed_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
mutation.stepRun.id,
|
||||
mutation.stepRun.stepRunDigest,
|
||||
mutation.event.id,
|
||||
mutation.event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
JSON.stringify(mutation.stepRun),
|
||||
committedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge/standalone adapter for the shared Workflow cancellation state machine.
|
||||
* It uses the existing single SQLite operation authority and one
|
||||
* `BEGIN IMMEDIATE` transaction per Workflow, so page size does not lengthen a
|
||||
* single write lock on low-memory router-class devices.
|
||||
*/
|
||||
export class LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository
|
||||
implements ClusterRunCancellationConvergenceRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
convergePage(
|
||||
value: Readonly<ClusterRunCancellationConvergencePageCommand>,
|
||||
): Promise<Readonly<ClusterRunCancellationConvergencePageResult>> {
|
||||
const command =
|
||||
normalizeClusterRunCancellationConvergencePageCommand(value);
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const client = this.#authority.client;
|
||||
const candidates = client
|
||||
.prepare(
|
||||
`SELECT id AS "runId"
|
||||
FROM "Runs"
|
||||
WHERE execution_owner = 'runtime'
|
||||
AND trigger_type = 'plugin_package_workflow'
|
||||
AND status = 'running'
|
||||
AND cancel_requested_at_ms IS NOT NULL
|
||||
ORDER BY cancel_requested_at_ms, id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(command.limit) as Row[];
|
||||
let settledRuns = 0;
|
||||
let settledAttempts = 0;
|
||||
let blocked = 0;
|
||||
for (const candidate of candidates) {
|
||||
const result = this.#convergeOne(text(candidate, 'runId'));
|
||||
settledRuns += result.settledRuns;
|
||||
settledAttempts += result.settledAttempts;
|
||||
blocked += result.blocked;
|
||||
}
|
||||
const continuation = client
|
||||
.prepare(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM "Runs"
|
||||
WHERE execution_owner = 'runtime'
|
||||
AND trigger_type = 'plugin_package_workflow'
|
||||
AND status = 'running'
|
||||
AND cancel_requested_at_ms IS NOT NULL
|
||||
LIMIT 1
|
||||
) AS "hasMore"`,
|
||||
)
|
||||
.get() as Row | undefined;
|
||||
if (!continuation) throw unavailable();
|
||||
return normalizeClusterRunCancellationConvergencePageResult({
|
||||
scanned: candidates.length,
|
||||
settledRuns,
|
||||
settledAttempts,
|
||||
blocked,
|
||||
hasMore: integer(continuation, 'hasMore') === 1,
|
||||
}, command.limit);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterRunCancellationConvergenceUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
},
|
||||
() => unavailable(),
|
||||
);
|
||||
}
|
||||
|
||||
#convergeOne(runId: string): Readonly<{
|
||||
settledRuns: number;
|
||||
settledAttempts: number;
|
||||
blocked: number;
|
||||
}> {
|
||||
const client = this.#authority.client;
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const runRows = client
|
||||
.prepare(
|
||||
`SELECT id, project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision",
|
||||
trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner",
|
||||
request_id AS "requestId", status, version,
|
||||
event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey",
|
||||
created_at_ms AS "createdAtMs",
|
||||
started_at_ms AS "startedAtMs",
|
||||
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
cancel_reason AS "cancelReason"
|
||||
FROM "Runs"
|
||||
WHERE id = ? AND execution_owner = 'runtime'
|
||||
AND trigger_type = 'plugin_package_workflow'
|
||||
AND status = 'running'
|
||||
AND cancel_requested_at_ms IS NOT NULL
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(runId) as Row[];
|
||||
if (runRows.length === 0) {
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
settledRuns: 0,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
});
|
||||
}
|
||||
if (runRows.length !== 1) throw unavailable();
|
||||
const run = runFromRow(runRows[0]!);
|
||||
const stepRuns = (
|
||||
client
|
||||
.prepare(
|
||||
`SELECT step_run_json AS "stepRunJson"
|
||||
FROM "StepRuns"
|
||||
WHERE run_id = ?
|
||||
ORDER BY step_key, id`,
|
||||
)
|
||||
.all(runId) as Row[]
|
||||
).map((row) => {
|
||||
try {
|
||||
return normalizeStepRunRecord(
|
||||
json(row, 'stepRunJson') as unknown as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
});
|
||||
const activeRows = client
|
||||
.prepare(
|
||||
`SELECT admission.receipt_json AS "admissionJson",
|
||||
attempt.id AS "attemptId",
|
||||
attempt.run_id AS "attemptRunId",
|
||||
attempt.step_run_id AS "attemptStepRunId",
|
||||
attempt.attempt AS "attemptNumber",
|
||||
attempt.status AS "attemptStatus",
|
||||
attempt.executor_type AS "executorType",
|
||||
attempt.callback_sequence AS "callbackSequence",
|
||||
attempt.created_at_ms AS "attemptCreatedAtMs",
|
||||
attempt.started_at_ms AS "attemptStartedAtMs"
|
||||
FROM
|
||||
"QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
|
||||
AS admission
|
||||
JOIN "RunAttempts" AS attempt
|
||||
ON attempt.id = admission.attempt_id
|
||||
AND attempt.run_id = admission.run_id
|
||||
WHERE admission.run_id = ?
|
||||
AND attempt.status IN ('claimed', 'starting', 'running')
|
||||
ORDER BY attempt.id`,
|
||||
)
|
||||
.all(runId) as Row[];
|
||||
const activeTaskAttempts =
|
||||
activeRows.map(
|
||||
(row): Readonly<
|
||||
PluginPackageWorkflowCancellationActiveAttempt
|
||||
> =>
|
||||
Object.freeze({
|
||||
admission:
|
||||
json(row, 'admissionJson') as unknown as
|
||||
Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>,
|
||||
attempt: attemptFromRow(row),
|
||||
leaseStatus: null,
|
||||
}),
|
||||
);
|
||||
const clock = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER)
|
||||
AS "observedAtMs"`,
|
||||
)
|
||||
.get() as Row | undefined;
|
||||
if (!clock) throw unavailable();
|
||||
const resolution = resolvePluginPackageWorkflowCancellation({
|
||||
run,
|
||||
stepRuns,
|
||||
activeTaskAttempts,
|
||||
observedAtMs: integer(clock, 'observedAtMs'),
|
||||
});
|
||||
for (const transition of resolution.attemptTransitions) {
|
||||
const updated = client
|
||||
.prepare(
|
||||
`UPDATE "RunAttempts"
|
||||
SET status = ?, finished_at_ms = ?,
|
||||
error_code = ?, error_summary = ?
|
||||
WHERE id = ? AND run_id = ?
|
||||
AND status = ? AND callback_sequence = ?`,
|
||||
)
|
||||
.run(
|
||||
transition.attempt.status,
|
||||
transition.attempt.finishedAtMs ?? null,
|
||||
transition.attempt.errorCode ?? null,
|
||||
transition.attempt.errorSummary ?? null,
|
||||
transition.attempt.id,
|
||||
transition.attempt.runId,
|
||||
transition.previousStatus,
|
||||
transition.attempt.callbackSequence,
|
||||
);
|
||||
if (updated.changes !== 1) throw unavailable();
|
||||
insertEvent(client, transition.event);
|
||||
}
|
||||
for (const mutation of resolution.stepMutations) {
|
||||
updateStepRun(client, mutation);
|
||||
insertStepMutation(client, mutation, resolution.observedAtMs);
|
||||
}
|
||||
if (resolution.terminalTransition) {
|
||||
insertEvent(client, resolution.terminalTransition.event);
|
||||
}
|
||||
if (
|
||||
resolution.run.version !== run.version ||
|
||||
resolution.run.eventSequence !== run.eventSequence ||
|
||||
resolution.run.status !== run.status
|
||||
) {
|
||||
const updated = client
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET status = ?, finished_at_ms = ?,
|
||||
error_code = ?, error_summary = ?,
|
||||
version = ?, event_sequence = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
AND execution_owner = 'runtime'
|
||||
AND trigger_type = 'plugin_package_workflow'
|
||||
AND cancel_requested_at_ms = ? AND cancel_reason = ?
|
||||
AND version = ? AND event_sequence = ?`,
|
||||
)
|
||||
.run(
|
||||
resolution.run.status,
|
||||
resolution.run.finishedAtMs ?? null,
|
||||
resolution.run.errorCode ?? null,
|
||||
resolution.run.errorSummary ?? null,
|
||||
resolution.run.version,
|
||||
resolution.run.eventSequence,
|
||||
run.id,
|
||||
run.cancelRequestedAtMs!,
|
||||
run.cancelReason!,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
);
|
||||
if (updated.changes !== 1) throw unavailable();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
settledRuns: resolution.terminalTransition === null ? 0 : 1,
|
||||
settledAttempts: resolution.attemptTransitions.length,
|
||||
blocked: resolution.blockedStepRunIds.length === 0 ? 0 : 1,
|
||||
});
|
||||
} finally {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the fail-closed convergence error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+759
@@ -0,0 +1,759 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE,
|
||||
PluginPackageWorkflowFrontierConflictError,
|
||||
PluginPackageWorkflowFrontierUnavailableError,
|
||||
resolvePluginPackageWorkflowFrontier,
|
||||
type PluginPackageWorkflowFrontierAdvanceResult,
|
||||
type PluginPackageWorkflowFrontierCandidate,
|
||||
type PluginPackageWorkflowFrontierCursor,
|
||||
type PluginPackageWorkflowFrontierPage,
|
||||
type PluginPackageWorkflowFrontierRepository,
|
||||
type PluginPackageWorkflowTerminalStatus,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-frontier';
|
||||
import {
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import type { RunRecord } from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunMutation,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const TERMINAL_RUN_STATUSES = new Set<PluginPackageWorkflowTerminalStatus>([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
const RUN_SELECT = `
|
||||
id, project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision", task_name AS "taskName",
|
||||
task_snapshot_ref AS "taskSnapshotRef", legacy_cron_id AS "legacyCronId",
|
||||
parent_run_id AS "parentRunId", retry_of_run_id AS "retryOfRunId",
|
||||
trigger_id AS "triggerId", trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner", triggered_by AS "triggeredBy",
|
||||
request_id AS "requestId", scheduled_for_ms AS "scheduledForMs",
|
||||
status, version, event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", created_at_ms AS "createdAtMs",
|
||||
queued_at_ms AS "queuedAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
cancel_reason AS "cancelReason", error_code AS "errorCode",
|
||||
error_summary AS "errorSummary"
|
||||
`.trim();
|
||||
|
||||
const STEP_RUN_SELECT = `
|
||||
id, run_id AS "runId", parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey", kind, definition_ref AS "definitionRef",
|
||||
definition_digest AS "definitionDigest", required, status, version,
|
||||
attempt_count AS "attemptCount", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", approval_request_id AS "approvalRequestId",
|
||||
ready_at_ms AS "readyAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs", result_code AS "resultCode",
|
||||
error_summary AS "errorSummary", created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs", last_mutation_id AS "lastMutationId",
|
||||
step_run_digest AS "stepRunDigest", step_run_json AS "stepRunJson"
|
||||
`.trim();
|
||||
|
||||
function unavailable(cause?: unknown): PluginPackageWorkflowFrontierUnavailableError {
|
||||
return new PluginPackageWorkflowFrontierUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
const value = row[key];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function sqliteConstraint(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
const errcode = (error as { errcode?: unknown }).errcode;
|
||||
return (
|
||||
(typeof code === 'string' &&
|
||||
(code === 'ERR_SQLITE_CONSTRAINT' ||
|
||||
code.startsWith('SQLITE_CONSTRAINT') ||
|
||||
code.startsWith('ERR_SQLITE_CONSTRAINT'))) ||
|
||||
(typeof errcode === 'number' && (errcode & 0xff) === 19)
|
||||
);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageWorkflowFrontierError ||
|
||||
error instanceof PluginPackageWorkflowFrontierConflictError ||
|
||||
error instanceof PluginPackageWorkflowFrontierUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return sqliteConstraint(error)
|
||||
? new PluginPackageWorkflowFrontierConflictError()
|
||||
: unavailable(error);
|
||||
}
|
||||
|
||||
function pageLimit(value: unknown): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
`page limit must be between 1 and ${MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(
|
||||
value: Readonly<PluginPackageWorkflowFrontierCursor> | undefined,
|
||||
): Readonly<PluginPackageWorkflowFrontierCursor> | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Reflect.ownKeys(value).length !== 2 ||
|
||||
!Reflect.has(value, 'admittedAtMs') ||
|
||||
!Reflect.has(value, 'planDigest') ||
|
||||
!Number.isSafeInteger(value.admittedAtMs) ||
|
||||
value.admittedAtMs < 0 ||
|
||||
!DIGEST.test(value.planDigest)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
'frontier cursor is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
admittedAtMs: value.admittedAtMs,
|
||||
planDigest: value.planDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function runFromRow(row: Row): Readonly<RunRecord> {
|
||||
return Object.freeze({
|
||||
id: text(row, 'id'),
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
...(optionalText(row, 'taskName') === undefined
|
||||
? {}
|
||||
: { taskName: optionalText(row, 'taskName')! }),
|
||||
...(optionalText(row, 'taskSnapshotRef') === undefined
|
||||
? {}
|
||||
: { taskSnapshotRef: optionalText(row, 'taskSnapshotRef')! }),
|
||||
...(optionalInteger(row, 'legacyCronId') === undefined
|
||||
? {}
|
||||
: { legacyCronId: optionalInteger(row, 'legacyCronId')! }),
|
||||
...(optionalText(row, 'parentRunId') === undefined
|
||||
? {}
|
||||
: { parentRunId: optionalText(row, 'parentRunId')! }),
|
||||
...(optionalText(row, 'retryOfRunId') === undefined
|
||||
? {}
|
||||
: { retryOfRunId: optionalText(row, 'retryOfRunId')! }),
|
||||
...(optionalText(row, 'triggerId') === undefined
|
||||
? {}
|
||||
: { triggerId: optionalText(row, 'triggerId')! }),
|
||||
triggerType: text(row, 'triggerType'),
|
||||
executionOrigin: text(
|
||||
row,
|
||||
'executionOrigin',
|
||||
) as RunRecord['executionOrigin'],
|
||||
executionOwner: text(row, 'executionOwner') as RunRecord['executionOwner'],
|
||||
...(optionalText(row, 'triggeredBy') === undefined
|
||||
? {}
|
||||
: { triggeredBy: optionalText(row, 'triggeredBy')! }),
|
||||
...(optionalText(row, 'requestId') === undefined
|
||||
? {}
|
||||
: { requestId: optionalText(row, 'requestId')! }),
|
||||
...(optionalInteger(row, 'scheduledForMs') === undefined
|
||||
? {}
|
||||
: { scheduledForMs: optionalInteger(row, 'scheduledForMs')! }),
|
||||
status: text(row, 'status') as RunRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
eventSequence: integer(row, 'eventSequence'),
|
||||
priority: integer(row, 'priority'),
|
||||
...(optionalText(row, 'idempotencyKey') === undefined
|
||||
? {}
|
||||
: { idempotencyKey: optionalText(row, 'idempotencyKey')! }),
|
||||
...(optionalText(row, 'inputRef') === undefined
|
||||
? {}
|
||||
: { inputRef: optionalText(row, 'inputRef')! }),
|
||||
...(optionalText(row, 'outputRef') === undefined
|
||||
? {}
|
||||
: { outputRef: optionalText(row, 'outputRef')! }),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
...(optionalInteger(row, 'queuedAtMs') === undefined
|
||||
? {}
|
||||
: { queuedAtMs: optionalInteger(row, 'queuedAtMs')! }),
|
||||
...(optionalInteger(row, 'startedAtMs') === undefined
|
||||
? {}
|
||||
: { startedAtMs: optionalInteger(row, 'startedAtMs')! }),
|
||||
...(optionalInteger(row, 'finishedAtMs') === undefined
|
||||
? {}
|
||||
: { finishedAtMs: optionalInteger(row, 'finishedAtMs')! }),
|
||||
...(optionalInteger(row, 'cancelRequestedAtMs') === undefined
|
||||
? {}
|
||||
: {
|
||||
cancelRequestedAtMs: optionalInteger(row, 'cancelRequestedAtMs')!,
|
||||
}),
|
||||
...(optionalText(row, 'cancelReason') === undefined
|
||||
? {}
|
||||
: {
|
||||
cancelReason: optionalText(
|
||||
row,
|
||||
'cancelReason',
|
||||
)! as NonNullable<RunRecord['cancelReason']>,
|
||||
}),
|
||||
...(optionalText(row, 'errorCode') === undefined
|
||||
? {}
|
||||
: { errorCode: optionalText(row, 'errorCode')! }),
|
||||
...(optionalText(row, 'errorSummary') === undefined
|
||||
? {}
|
||||
: { errorSummary: optionalText(row, 'errorSummary')! }),
|
||||
});
|
||||
}
|
||||
|
||||
function stepRunFromRow(row: Row): Readonly<StepRunRecord> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
JSON.parse(text(row, 'stepRunJson')) as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
text(row, 'id') !== stepRun.id ||
|
||||
text(row, 'runId') !== stepRun.runId ||
|
||||
optionalText(row, 'parentStepRunId') !==
|
||||
(stepRun.parentStepRunId ?? undefined) ||
|
||||
text(row, 'stepKey') !== stepRun.stepKey ||
|
||||
text(row, 'kind') !== stepRun.kind ||
|
||||
text(row, 'definitionRef') !== stepRun.definitionRef ||
|
||||
text(row, 'definitionDigest') !== stepRun.definitionDigest ||
|
||||
integer(row, 'required') !== (stepRun.required ? 1 : 0) ||
|
||||
text(row, 'status') !== stepRun.status ||
|
||||
integer(row, 'version') !== stepRun.version ||
|
||||
integer(row, 'attemptCount') !== stepRun.attemptCount ||
|
||||
optionalText(row, 'inputRef') !== (stepRun.inputRef ?? undefined) ||
|
||||
optionalText(row, 'outputRef') !== (stepRun.outputRef ?? undefined) ||
|
||||
optionalText(row, 'approvalRequestId') !==
|
||||
(stepRun.approvalRequestId ?? undefined) ||
|
||||
optionalInteger(row, 'readyAtMs') !==
|
||||
(stepRun.readyAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'startedAtMs') !==
|
||||
(stepRun.startedAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'finishedAtMs') !==
|
||||
(stepRun.finishedAtMs ?? undefined) ||
|
||||
optionalText(row, 'resultCode') !==
|
||||
(stepRun.resultCode ?? undefined) ||
|
||||
optionalText(row, 'errorSummary') !==
|
||||
(stepRun.errorSummary ?? undefined) ||
|
||||
integer(row, 'createdAtMs') !== stepRun.createdAtMs ||
|
||||
integer(row, 'updatedAtMs') !== stepRun.updatedAtMs ||
|
||||
text(row, 'lastMutationId') !== stepRun.lastMutationId ||
|
||||
text(row, 'stepRunDigest') !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return stepRun;
|
||||
}
|
||||
|
||||
function assertRunIdentity(
|
||||
run: Readonly<RunRecord>,
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
): void {
|
||||
if (
|
||||
run.id !== plan.runId ||
|
||||
run.projectId !== plan.target.projectId ||
|
||||
run.taskId !== plan.target.workflowId ||
|
||||
run.taskRevision !== plan.target.publicationDigest ||
|
||||
run.triggerType !== 'plugin_package_workflow' ||
|
||||
run.executionOrigin !== 'system' ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
run.requestId !== plan.planId ||
|
||||
run.idempotencyKey !== `plugin-package-workflow:${plan.planId}` ||
|
||||
run.version !== run.eventSequence
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function updateStepRun(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
const stepRun = mutation.stepRun;
|
||||
const updated = client
|
||||
.prepare(
|
||||
`UPDATE "StepRuns"
|
||||
SET status = ?, version = ?, attempt_count = ?, output_ref = ?,
|
||||
approval_request_id = ?, ready_at_ms = ?, started_at_ms = ?,
|
||||
finished_at_ms = ?, result_code = ?, error_summary = ?,
|
||||
updated_at_ms = ?, last_mutation_id = ?, step_run_digest = ?,
|
||||
step_run_json = ?
|
||||
WHERE id = ? AND run_id = ? AND version = ?
|
||||
AND step_run_digest = ? AND status = ?`,
|
||||
)
|
||||
.run(
|
||||
stepRun.status,
|
||||
stepRun.version,
|
||||
stepRun.attemptCount,
|
||||
stepRun.outputRef,
|
||||
stepRun.approvalRequestId,
|
||||
stepRun.readyAtMs,
|
||||
stepRun.startedAtMs,
|
||||
stepRun.finishedAtMs,
|
||||
stepRun.resultCode,
|
||||
stepRun.errorSummary,
|
||||
stepRun.updatedAtMs,
|
||||
stepRun.lastMutationId,
|
||||
stepRun.stepRunDigest,
|
||||
JSON.stringify(stepRun),
|
||||
stepRun.id,
|
||||
stepRun.runId,
|
||||
mutation.expectedStepRunVersion,
|
||||
mutation.expectedStepRunDigest,
|
||||
mutation.previousStatus,
|
||||
);
|
||||
if (updated.changes !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function insertStepMutation(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
committedAtMs: number,
|
||||
): void {
|
||||
const event = mutation.event;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
mutation.stepRun.id,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "StepRunMutations" (
|
||||
mutation_id, mutation_digest, run_id, step_run_id,
|
||||
step_run_digest, event_id, event_sequence, run_version,
|
||||
step_run_json, committed_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
mutation.stepRun.id,
|
||||
mutation.stepRun.stepRunDigest,
|
||||
event.id,
|
||||
event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
JSON.stringify(mutation.stepRun),
|
||||
committedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageWorkflowFrontierRepository
|
||||
implements PluginPackageWorkflowFrontierRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => unavailable(),
|
||||
);
|
||||
}
|
||||
|
||||
listCandidates(queryValue: Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<PluginPackageWorkflowFrontierCursor>;
|
||||
}>): Promise<Readonly<PluginPackageWorkflowFrontierPage>> {
|
||||
if (
|
||||
!queryValue ||
|
||||
typeof queryValue !== 'object' ||
|
||||
Array.isArray(queryValue) ||
|
||||
!Reflect.has(queryValue, 'limit') ||
|
||||
Reflect.ownKeys(queryValue).some(
|
||||
(key) => key !== 'limit' && key !== 'after',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
'page query is invalid',
|
||||
);
|
||||
}
|
||||
const limit = pageLimit(queryValue.limit);
|
||||
const after = cursor(queryValue.after);
|
||||
return this.#enqueue(() => {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT admission.run_id AS "runId",
|
||||
admission.plan_digest AS "planDigest",
|
||||
admission.admitted_at_ms AS "admittedAtMs"
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions" AS admission
|
||||
JOIN "Runs" AS run ON run.id = admission.run_id
|
||||
WHERE run.status = 'running'
|
||||
AND run.cancel_requested_at_ms IS NULL
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissionSteps" AS step
|
||||
JOIN "StepRuns" AS current
|
||||
ON current.run_id = step.run_id
|
||||
AND current.id = step.step_run_id
|
||||
WHERE step.plan_digest = admission.plan_digest
|
||||
AND current.status = 'pending'
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(step.needs_json) AS need
|
||||
LEFT JOIN "QingLong3PluginPackageWorkflowAdmissionSteps"
|
||||
AS dependency_step
|
||||
ON dependency_step.plan_digest = step.plan_digest
|
||||
AND dependency_step.step_key = need.value
|
||||
LEFT JOIN "StepRuns" AS dependency
|
||||
ON dependency.run_id = dependency_step.run_id
|
||||
AND dependency.id = dependency_step.step_run_id
|
||||
WHERE dependency.id IS NULL
|
||||
OR dependency.status <> 'succeeded'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(step.needs_json) AS need
|
||||
JOIN "QingLong3PluginPackageWorkflowAdmissionSteps"
|
||||
AS dependency_step
|
||||
ON dependency_step.plan_digest = step.plan_digest
|
||||
AND dependency_step.step_key = need.value
|
||||
JOIN "StepRuns" AS dependency
|
||||
ON dependency.run_id = dependency_step.run_id
|
||||
AND dependency.id = dependency_step.step_run_id
|
||||
WHERE dependency.status IN (
|
||||
'failed', 'skipped', 'cancelled', 'timed_out'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissionSteps" AS step
|
||||
JOIN "StepRuns" AS current
|
||||
ON current.run_id = step.run_id
|
||||
AND current.id = step.step_run_id
|
||||
WHERE step.plan_digest = admission.plan_digest
|
||||
AND current.status NOT IN (
|
||||
'succeeded', 'failed', 'skipped', 'cancelled', 'timed_out'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
? IS NULL OR admission.admitted_at_ms > ? OR
|
||||
(admission.admitted_at_ms = ? AND admission.plan_digest > ?)
|
||||
)
|
||||
ORDER BY admission.admitted_at_ms, admission.plan_digest
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
after?.planDigest ?? null,
|
||||
after?.admittedAtMs ?? 0,
|
||||
after?.admittedAtMs ?? 0,
|
||||
after?.planDigest ?? '',
|
||||
limit + 1,
|
||||
) as Row[];
|
||||
const mapped = rows.map(
|
||||
(row): Readonly<PluginPackageWorkflowFrontierCandidate> =>
|
||||
Object.freeze({
|
||||
runId: identity(text(row, 'runId'), 'candidate runId'),
|
||||
planDigest: digest(row.planDigest),
|
||||
admittedAtMs: integer(row, 'admittedAtMs'),
|
||||
}),
|
||||
);
|
||||
const truncated = mapped.length > limit;
|
||||
const candidates = Object.freeze(mapped.slice(0, limit));
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
admittedAtMs: last.admittedAtMs,
|
||||
planDigest: last.planDigest,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
advance(
|
||||
runIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowFrontierAdvanceResult>> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
return this.#enqueue(() => {
|
||||
let began = false;
|
||||
try {
|
||||
const client = this.#authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const admission = client
|
||||
.prepare(
|
||||
`SELECT plan_digest AS "planDigest", plan_json AS "planJson"
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions"
|
||||
WHERE run_id = ? LIMIT 2`,
|
||||
)
|
||||
.all(runId) as Row[];
|
||||
if (admission.length !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
let plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
try {
|
||||
plan = normalizePluginPackageWorkflowExecutionPlan(
|
||||
JSON.parse(
|
||||
text(admission[0]!, 'planJson'),
|
||||
) as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (plan.planDigest !== digest(admission[0]!.planDigest)) {
|
||||
throw unavailable();
|
||||
}
|
||||
const runRows = client
|
||||
.prepare(`SELECT ${RUN_SELECT} FROM "Runs" WHERE id = ? LIMIT 2`)
|
||||
.all(runId) as Row[];
|
||||
if (runRows.length !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
const run = runFromRow(runRows[0]!);
|
||||
assertRunIdentity(run, plan);
|
||||
const stepRows = client
|
||||
.prepare(
|
||||
`SELECT ${STEP_RUN_SELECT}
|
||||
FROM "StepRuns" WHERE run_id = ?
|
||||
ORDER BY step_key, id`,
|
||||
)
|
||||
.all(runId) as Row[];
|
||||
const stepRuns = stepRows.map(stepRunFromRow);
|
||||
const clock = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER)
|
||||
AS "observedAtMs"`,
|
||||
)
|
||||
.get() as Row | undefined;
|
||||
if (!clock) throw unavailable();
|
||||
const observedAtMs = integer(clock, 'observedAtMs');
|
||||
const currentStatus = run.status;
|
||||
const resolution = resolvePluginPackageWorkflowFrontier({
|
||||
plan,
|
||||
run: {
|
||||
...run,
|
||||
...(TERMINAL_RUN_STATUSES.has(
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
)
|
||||
? { status: 'running' as const }
|
||||
: {}),
|
||||
},
|
||||
stepRuns,
|
||||
observedAtMs,
|
||||
});
|
||||
if (
|
||||
TERMINAL_RUN_STATUSES.has(
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
)
|
||||
) {
|
||||
if (
|
||||
resolution.stepMutations.length !== 0 ||
|
||||
resolution.terminalStatus !== currentStatus
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'settled' as const,
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: 0,
|
||||
readyStepRunIds: Object.freeze([]),
|
||||
terminalStatus:
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
runVersion: run.version,
|
||||
runEventSequence: run.eventSequence,
|
||||
observedAtMs,
|
||||
});
|
||||
}
|
||||
if (currentStatus !== 'running') throw unavailable();
|
||||
const increment =
|
||||
resolution.stepMutations.length +
|
||||
(resolution.terminalTransition === null ? 0 : 1);
|
||||
if (increment === 0) {
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'unchanged' as const,
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: 0,
|
||||
readyStepRunIds: resolution.readyStepRunIds,
|
||||
terminalStatus: null,
|
||||
runVersion: run.version,
|
||||
runEventSequence: run.eventSequence,
|
||||
observedAtMs,
|
||||
});
|
||||
}
|
||||
for (const mutation of resolution.stepMutations) {
|
||||
updateStepRun(client, mutation);
|
||||
}
|
||||
const terminal = resolution.terminalTransition;
|
||||
const updatedRun = client
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET status = ?, version = version + ?,
|
||||
event_sequence = event_sequence + ?,
|
||||
finished_at_ms = ?, error_code = ?, error_summary = NULL
|
||||
WHERE id = ? AND status = 'running'
|
||||
AND version = ? AND event_sequence = ?`,
|
||||
)
|
||||
.run(
|
||||
terminal?.status ?? 'running',
|
||||
increment,
|
||||
increment,
|
||||
terminal?.finishedAtMs ?? null,
|
||||
terminal?.errorCode ?? null,
|
||||
runId,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
);
|
||||
if (updatedRun.changes !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
for (const mutation of resolution.stepMutations) {
|
||||
insertStepMutation(client, mutation, observedAtMs);
|
||||
}
|
||||
if (terminal) {
|
||||
const event = terminal.event;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
);
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
const runVersion = run.version + increment;
|
||||
return Object.freeze({
|
||||
status: terminal ? ('terminal' as const) : ('advanced' as const),
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: resolution.stepMutations.length,
|
||||
readyStepRunIds: terminal
|
||||
? Object.freeze([])
|
||||
: resolution.readyStepRunIds,
|
||||
terminalStatus: terminal?.status ?? null,
|
||||
runVersion,
|
||||
runEventSequence: run.eventSequence + increment,
|
||||
observedAtMs,
|
||||
});
|
||||
} finally {
|
||||
if (began && this.#authority.client.isTransaction) {
|
||||
try {
|
||||
this.#authority.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original fail-closed error.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+783
@@ -0,0 +1,783 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
createPluginPackageWorkflowTaskAttemptAdmission,
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE,
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionCandidate,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionCursor,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionPage,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission';
|
||||
import {
|
||||
normalizeLocalTaskExecutionRevision,
|
||||
type LocalTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import {
|
||||
normalizePluginPackageTaskReconciliationReceipt,
|
||||
type PluginPackageTaskReconciliationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-reconciliation';
|
||||
import {
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import type { RunRecord } from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
const RUN_SELECT = `
|
||||
id, project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision", task_name AS "taskName",
|
||||
task_snapshot_ref AS "taskSnapshotRef", legacy_cron_id AS "legacyCronId",
|
||||
parent_run_id AS "parentRunId", retry_of_run_id AS "retryOfRunId",
|
||||
trigger_id AS "triggerId", trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner", triggered_by AS "triggeredBy",
|
||||
request_id AS "requestId", scheduled_for_ms AS "scheduledForMs",
|
||||
status, version, event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", created_at_ms AS "createdAtMs",
|
||||
queued_at_ms AS "queuedAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
cancel_reason AS "cancelReason", error_code AS "errorCode",
|
||||
error_summary AS "errorSummary"
|
||||
`.trim();
|
||||
|
||||
const STEP_RUN_SELECT = `
|
||||
id, run_id AS "runId", parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey", kind, definition_ref AS "definitionRef",
|
||||
definition_digest AS "definitionDigest", required, status, version,
|
||||
attempt_count AS "attemptCount", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", approval_request_id AS "approvalRequestId",
|
||||
ready_at_ms AS "readyAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs", result_code AS "resultCode",
|
||||
error_summary AS "errorSummary", created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs", last_mutation_id AS "lastMutationId",
|
||||
step_run_digest AS "stepRunDigest", step_run_json AS "stepRunJson"
|
||||
`.trim();
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageWorkflowTaskAttemptAdmissionUnavailableError {
|
||||
return new PluginPackageWorkflowTaskAttemptAdmissionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
const value = row[key];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return text(row, key);
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function json(row: Row, key: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text(row, key));
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sqliteConstraint(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
const errcode = (error as { errcode?: unknown }).errcode;
|
||||
return (
|
||||
(typeof code === 'string' &&
|
||||
(code === 'ERR_SQLITE_CONSTRAINT' ||
|
||||
code.startsWith('SQLITE_CONSTRAINT') ||
|
||||
code.startsWith('ERR_SQLITE_CONSTRAINT'))) ||
|
||||
(typeof errcode === 'number' && (errcode & 0xff) === 19)
|
||||
);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return sqliteConstraint(error)
|
||||
? new PluginPackageWorkflowTaskAttemptAdmissionConflictError()
|
||||
: unavailable(error);
|
||||
}
|
||||
|
||||
function pageLimit(value: unknown): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) >
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
`page limit must be between 1 and ${MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(
|
||||
value:
|
||||
| Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>
|
||||
| undefined,
|
||||
):
|
||||
| Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>
|
||||
| undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Reflect.ownKeys(value).length !== 2 ||
|
||||
!Reflect.has(value, 'readyAtMs') ||
|
||||
!Reflect.has(value, 'stepRunId') ||
|
||||
!Number.isSafeInteger(value.readyAtMs) ||
|
||||
value.readyAtMs < 0 ||
|
||||
typeof value.stepRunId !== 'string' ||
|
||||
!IDENTITY.test(value.stepRunId)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
'candidate cursor is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
readyAtMs: value.readyAtMs,
|
||||
stepRunId: value.stepRunId,
|
||||
});
|
||||
}
|
||||
|
||||
function runFromRow(row: Row): Readonly<RunRecord> {
|
||||
return Object.freeze({
|
||||
id: text(row, 'id'),
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
...(optionalText(row, 'taskName') === undefined
|
||||
? {}
|
||||
: { taskName: optionalText(row, 'taskName')! }),
|
||||
...(optionalText(row, 'taskSnapshotRef') === undefined
|
||||
? {}
|
||||
: { taskSnapshotRef: optionalText(row, 'taskSnapshotRef')! }),
|
||||
...(optionalInteger(row, 'legacyCronId') === undefined
|
||||
? {}
|
||||
: { legacyCronId: optionalInteger(row, 'legacyCronId')! }),
|
||||
...(optionalText(row, 'parentRunId') === undefined
|
||||
? {}
|
||||
: { parentRunId: optionalText(row, 'parentRunId')! }),
|
||||
...(optionalText(row, 'retryOfRunId') === undefined
|
||||
? {}
|
||||
: { retryOfRunId: optionalText(row, 'retryOfRunId')! }),
|
||||
...(optionalText(row, 'triggerId') === undefined
|
||||
? {}
|
||||
: { triggerId: optionalText(row, 'triggerId')! }),
|
||||
triggerType: text(row, 'triggerType'),
|
||||
executionOrigin: text(
|
||||
row,
|
||||
'executionOrigin',
|
||||
) as RunRecord['executionOrigin'],
|
||||
executionOwner: text(row, 'executionOwner') as RunRecord['executionOwner'],
|
||||
...(optionalText(row, 'triggeredBy') === undefined
|
||||
? {}
|
||||
: { triggeredBy: optionalText(row, 'triggeredBy')! }),
|
||||
...(optionalText(row, 'requestId') === undefined
|
||||
? {}
|
||||
: { requestId: optionalText(row, 'requestId')! }),
|
||||
...(optionalInteger(row, 'scheduledForMs') === undefined
|
||||
? {}
|
||||
: { scheduledForMs: optionalInteger(row, 'scheduledForMs')! }),
|
||||
status: text(row, 'status') as RunRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
eventSequence: integer(row, 'eventSequence'),
|
||||
priority: integer(row, 'priority'),
|
||||
...(optionalText(row, 'idempotencyKey') === undefined
|
||||
? {}
|
||||
: { idempotencyKey: optionalText(row, 'idempotencyKey')! }),
|
||||
...(optionalText(row, 'inputRef') === undefined
|
||||
? {}
|
||||
: { inputRef: optionalText(row, 'inputRef')! }),
|
||||
...(optionalText(row, 'outputRef') === undefined
|
||||
? {}
|
||||
: { outputRef: optionalText(row, 'outputRef')! }),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
...(optionalInteger(row, 'queuedAtMs') === undefined
|
||||
? {}
|
||||
: { queuedAtMs: optionalInteger(row, 'queuedAtMs')! }),
|
||||
...(optionalInteger(row, 'startedAtMs') === undefined
|
||||
? {}
|
||||
: { startedAtMs: optionalInteger(row, 'startedAtMs')! }),
|
||||
...(optionalInteger(row, 'finishedAtMs') === undefined
|
||||
? {}
|
||||
: { finishedAtMs: optionalInteger(row, 'finishedAtMs')! }),
|
||||
...(optionalInteger(row, 'cancelRequestedAtMs') === undefined
|
||||
? {}
|
||||
: {
|
||||
cancelRequestedAtMs: optionalInteger(row, 'cancelRequestedAtMs')!,
|
||||
}),
|
||||
...(optionalText(row, 'cancelReason') === undefined
|
||||
? {}
|
||||
: {
|
||||
cancelReason: optionalText(
|
||||
row,
|
||||
'cancelReason',
|
||||
)! as NonNullable<RunRecord['cancelReason']>,
|
||||
}),
|
||||
...(optionalText(row, 'errorCode') === undefined
|
||||
? {}
|
||||
: { errorCode: optionalText(row, 'errorCode')! }),
|
||||
...(optionalText(row, 'errorSummary') === undefined
|
||||
? {}
|
||||
: { errorSummary: optionalText(row, 'errorSummary')! }),
|
||||
});
|
||||
}
|
||||
|
||||
function stepRunFromRow(row: Row): Readonly<StepRunRecord> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
json(row, 'stepRunJson') as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
text(row, 'id') !== stepRun.id ||
|
||||
text(row, 'runId') !== stepRun.runId ||
|
||||
optionalText(row, 'parentStepRunId') !==
|
||||
(stepRun.parentStepRunId ?? undefined) ||
|
||||
text(row, 'stepKey') !== stepRun.stepKey ||
|
||||
text(row, 'kind') !== stepRun.kind ||
|
||||
text(row, 'definitionRef') !== stepRun.definitionRef ||
|
||||
text(row, 'definitionDigest') !== stepRun.definitionDigest ||
|
||||
integer(row, 'required') !== (stepRun.required ? 1 : 0) ||
|
||||
text(row, 'status') !== stepRun.status ||
|
||||
integer(row, 'version') !== stepRun.version ||
|
||||
integer(row, 'attemptCount') !== stepRun.attemptCount ||
|
||||
optionalText(row, 'inputRef') !== (stepRun.inputRef ?? undefined) ||
|
||||
optionalText(row, 'outputRef') !== (stepRun.outputRef ?? undefined) ||
|
||||
optionalText(row, 'approvalRequestId') !==
|
||||
(stepRun.approvalRequestId ?? undefined) ||
|
||||
optionalInteger(row, 'readyAtMs') !==
|
||||
(stepRun.readyAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'startedAtMs') !==
|
||||
(stepRun.startedAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'finishedAtMs') !==
|
||||
(stepRun.finishedAtMs ?? undefined) ||
|
||||
optionalText(row, 'resultCode') !==
|
||||
(stepRun.resultCode ?? undefined) ||
|
||||
optionalText(row, 'errorSummary') !==
|
||||
(stepRun.errorSummary ?? undefined) ||
|
||||
integer(row, 'createdAtMs') !== stepRun.createdAtMs ||
|
||||
integer(row, 'updatedAtMs') !== stepRun.updatedAtMs ||
|
||||
text(row, 'lastMutationId') !== stepRun.lastMutationId ||
|
||||
text(row, 'stepRunDigest') !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return stepRun;
|
||||
}
|
||||
|
||||
function executionFromRow(row: Row): Readonly<LocalTaskExecutionRevision> {
|
||||
try {
|
||||
return normalizeLocalTaskExecutionRevision({
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
executorType: 'local_process',
|
||||
command: json(
|
||||
row,
|
||||
'commandJson',
|
||||
) as LocalTaskExecutionRevision['command'],
|
||||
...(optionalText(row, 'workingDirectory') === undefined
|
||||
? {}
|
||||
: { workingDirectory: optionalText(row, 'workingDirectory')! }),
|
||||
...(optionalInteger(row, 'timeoutMs') === undefined
|
||||
? {}
|
||||
: { timeoutMs: optionalInteger(row, 'timeoutMs')! }),
|
||||
contextRef: text(row, 'contextRef'),
|
||||
contentDigest: text(row, 'contentDigest'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
});
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function receiptFromRow(
|
||||
row: Row,
|
||||
): Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt> {
|
||||
try {
|
||||
const receipt =
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt(
|
||||
json(
|
||||
row,
|
||||
'receiptJson',
|
||||
) as PluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
);
|
||||
if (
|
||||
row.receiptDigest !== undefined &&
|
||||
text(row, 'receiptDigest') !== receipt.receiptDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository
|
||||
implements PluginPackageWorkflowTaskAttemptAdmissionRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => unavailable(),
|
||||
);
|
||||
}
|
||||
|
||||
listCandidates(queryValue: Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>;
|
||||
}>): Promise<
|
||||
Readonly<PluginPackageWorkflowTaskAttemptAdmissionPage>
|
||||
> {
|
||||
if (
|
||||
!queryValue ||
|
||||
typeof queryValue !== 'object' ||
|
||||
Array.isArray(queryValue) ||
|
||||
!Reflect.has(queryValue, 'limit') ||
|
||||
Reflect.ownKeys(queryValue).some(
|
||||
(key) => key !== 'limit' && key !== 'after',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
'page query is invalid',
|
||||
);
|
||||
}
|
||||
const limit = pageLimit(queryValue.limit);
|
||||
const after = cursor(queryValue.after);
|
||||
return this.#enqueue(() => {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT current.run_id AS "runId",
|
||||
current.id AS "stepRunId",
|
||||
current.ready_at_ms AS "readyAtMs",
|
||||
admission.plan_digest AS "planDigest"
|
||||
FROM "StepRuns" AS current
|
||||
JOIN "QingLong3PluginPackageWorkflowAdmissionSteps" AS source
|
||||
ON source.run_id = current.run_id
|
||||
AND source.step_run_id = current.id
|
||||
JOIN "QingLong3PluginPackageWorkflowAdmissions" AS admission
|
||||
ON admission.plan_digest = source.plan_digest
|
||||
AND admission.run_id = source.run_id
|
||||
JOIN "Runs" AS run ON run.id = current.run_id
|
||||
WHERE run.status = 'running'
|
||||
AND run.cancel_requested_at_ms IS NULL
|
||||
AND current.kind = 'task'
|
||||
AND current.status = 'ready'
|
||||
AND current.ready_at_ms IS NOT NULL
|
||||
AND current.attempt_count < 64
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
|
||||
AS task_attempt
|
||||
WHERE task_attempt.run_id = current.run_id
|
||||
AND task_attempt.step_run_id = current.id
|
||||
AND task_attempt.step_run_version = current.version
|
||||
)
|
||||
AND (
|
||||
? IS NULL OR current.ready_at_ms > ? OR
|
||||
(current.ready_at_ms = ? AND current.id > ?)
|
||||
)
|
||||
ORDER BY current.ready_at_ms, current.id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
after?.stepRunId ?? null,
|
||||
after?.readyAtMs ?? 0,
|
||||
after?.readyAtMs ?? 0,
|
||||
after?.stepRunId ?? '',
|
||||
limit + 1,
|
||||
) as Row[];
|
||||
const mapped = rows.map(
|
||||
(row): Readonly<PluginPackageWorkflowTaskAttemptAdmissionCandidate> =>
|
||||
Object.freeze({
|
||||
runId: identity(text(row, 'runId'), 'candidate runId'),
|
||||
stepRunId: identity(
|
||||
text(row, 'stepRunId'),
|
||||
'candidate stepRunId',
|
||||
),
|
||||
readyAtMs: integer(row, 'readyAtMs'),
|
||||
planDigest: text(row, 'planDigest'),
|
||||
}),
|
||||
);
|
||||
if (mapped.some(({ planDigest }) => !DIGEST.test(planDigest))) {
|
||||
throw unavailable();
|
||||
}
|
||||
const truncated = mapped.length > limit;
|
||||
const candidates = Object.freeze(mapped.slice(0, limit));
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
readyAtMs: last.readyAtMs,
|
||||
stepRunId: last.stepRunId,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
admit(
|
||||
runIdValue: string,
|
||||
stepRunIdValue: string,
|
||||
): Promise<
|
||||
Readonly<PluginPackageWorkflowTaskAttemptAdmissionResult>
|
||||
> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
const stepRunId = identity(stepRunIdValue, 'stepRunId');
|
||||
return this.#enqueue(() => {
|
||||
let began = false;
|
||||
try {
|
||||
const client = this.#authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const stepRows = client
|
||||
.prepare(
|
||||
`SELECT ${STEP_RUN_SELECT}
|
||||
FROM "StepRuns"
|
||||
WHERE run_id = ? AND id = ? LIMIT 2`,
|
||||
)
|
||||
.all(runId, stepRunId) as Row[];
|
||||
if (stepRows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const stepRun = stepRunFromRow(stepRows[0]!);
|
||||
const existingRows = client
|
||||
.prepare(
|
||||
`SELECT receipt_digest AS "receiptDigest",
|
||||
receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
|
||||
WHERE run_id = ? AND step_run_id = ?
|
||||
AND step_run_version = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(runId, stepRunId, stepRun.version) as Row[];
|
||||
if (existingRows.length > 1) throw unavailable();
|
||||
if (existingRows.length === 1) {
|
||||
const receipt = receiptFromRow(existingRows[0]!);
|
||||
if (
|
||||
receipt.runId !== runId ||
|
||||
receipt.stepRunId !== stepRunId ||
|
||||
receipt.stepRunVersion !== stepRun.version ||
|
||||
receipt.stepRunDigest !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt,
|
||||
});
|
||||
}
|
||||
if (stepRun.status !== 'ready') {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const admissionRows = client
|
||||
.prepare(
|
||||
`SELECT plan_json AS "planJson"
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions"
|
||||
WHERE run_id = ? LIMIT 2`,
|
||||
)
|
||||
.all(runId) as Row[];
|
||||
if (admissionRows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
let plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
try {
|
||||
plan = normalizePluginPackageWorkflowExecutionPlan(
|
||||
json(
|
||||
admissionRows[0]!,
|
||||
'planJson',
|
||||
) as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
const runRows = client
|
||||
.prepare(`SELECT ${RUN_SELECT} FROM "Runs" WHERE id = ? LIMIT 2`)
|
||||
.all(runId) as Row[];
|
||||
if (runRows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const run = runFromRow(runRows[0]!);
|
||||
const reconciliationRows = client
|
||||
.prepare(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageTaskReconciliations"
|
||||
WHERE generation_digest = ? LIMIT 2`,
|
||||
)
|
||||
.all(plan.target.generationDigest) as Row[];
|
||||
if (reconciliationRows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
let taskReconciliation:
|
||||
Readonly<PluginPackageTaskReconciliationReceipt>;
|
||||
try {
|
||||
taskReconciliation =
|
||||
normalizePluginPackageTaskReconciliationReceipt(
|
||||
json(
|
||||
reconciliationRows[0]!,
|
||||
'receiptJson',
|
||||
) as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
const planStep = plan.steps.find(
|
||||
(candidate) => candidate.stepRunId === stepRun.id,
|
||||
);
|
||||
if (!planStep) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const runtimeTaskId =
|
||||
`pkg:${plan.target.packageName}:${planStep.taskId}`;
|
||||
const item = taskReconciliation.items.find(
|
||||
({ taskId }) => taskId === runtimeTaskId,
|
||||
);
|
||||
if (!item) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const taskRevision =
|
||||
`qltd:v1:${item.revision}:${item.contentDigest}`;
|
||||
const executionRows = client
|
||||
.prepare(
|
||||
`SELECT project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision",
|
||||
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 = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(plan.target.projectId, runtimeTaskId, taskRevision) as Row[];
|
||||
if (executionRows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const execution = executionFromRow(executionRows[0]!);
|
||||
const clock = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER)
|
||||
AS "admittedAtMs"`,
|
||||
)
|
||||
.get() as Row | undefined;
|
||||
if (!clock) throw unavailable();
|
||||
const attemptNumberRow = client
|
||||
.prepare(
|
||||
`SELECT COALESCE(MAX(attempt), 0) + 1 AS "attemptNumber"
|
||||
FROM "RunAttempts" WHERE run_id = ?`,
|
||||
)
|
||||
.get(runId) as Row | undefined;
|
||||
if (!attemptNumberRow) throw unavailable();
|
||||
const bundle = createPluginPackageWorkflowTaskAttemptAdmission({
|
||||
plan,
|
||||
run,
|
||||
stepRun,
|
||||
taskReconciliation,
|
||||
execution,
|
||||
attemptNumber: integer(attemptNumberRow, 'attemptNumber'),
|
||||
admittedAtMs: integer(clock, 'admittedAtMs'),
|
||||
});
|
||||
const updated = client
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET version = ?, event_sequence = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
AND cancel_requested_at_ms IS NULL
|
||||
AND version = ? AND event_sequence = ?`,
|
||||
)
|
||||
.run(
|
||||
bundle.run.version,
|
||||
bundle.run.eventSequence,
|
||||
run.id,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
);
|
||||
if (updated.changes !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const attempt = bundle.attempt;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunAttempts" (
|
||||
id, run_id, step_run_id, attempt, status, executor_type,
|
||||
callback_sequence, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
attempt.id,
|
||||
attempt.runId,
|
||||
attempt.stepRunId ?? null,
|
||||
attempt.attempt,
|
||||
attempt.status,
|
||||
attempt.executorType,
|
||||
attempt.callbackSequence,
|
||||
attempt.createdAtMs,
|
||||
);
|
||||
const event = bundle.event;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
event.attemptId ?? null,
|
||||
event.stepRunId ?? null,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
);
|
||||
const receipt = bundle.receipt;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO
|
||||
"QingLong3PluginPackageWorkflowTaskAttemptAdmissions" (
|
||||
receipt_digest, attempt_id, plan_digest, run_id,
|
||||
step_run_id, step_run_version, step_run_digest,
|
||||
generation_digest, resource_task_id,
|
||||
task_reconciliation_receipt_digest, project_id, task_id,
|
||||
task_revision, task_definition_digest, executor_type,
|
||||
execution_digest, attempt_number, event_id, run_version,
|
||||
run_event_sequence, admitted_at_ms, receipt_json
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)`,
|
||||
)
|
||||
.run(
|
||||
receipt.receiptDigest,
|
||||
receipt.attemptId,
|
||||
receipt.planDigest,
|
||||
receipt.runId,
|
||||
receipt.stepRunId,
|
||||
receipt.stepRunVersion,
|
||||
receipt.stepRunDigest,
|
||||
plan.target.generationDigest,
|
||||
receipt.resourceTaskId,
|
||||
receipt.taskReconciliationReceiptDigest,
|
||||
execution.projectId,
|
||||
receipt.taskId,
|
||||
receipt.taskRevision,
|
||||
receipt.taskDefinitionDigest,
|
||||
receipt.executorType,
|
||||
receipt.executionDigest,
|
||||
receipt.attemptNumber,
|
||||
receipt.eventId,
|
||||
receipt.runVersion,
|
||||
receipt.runEventSequence,
|
||||
receipt.admittedAtMs,
|
||||
JSON.stringify(receipt),
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt,
|
||||
});
|
||||
} finally {
|
||||
if (began && this.#authority.client.isTransaction) {
|
||||
try {
|
||||
this.#authority.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original fail-closed error.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+1457
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user