feat(ql3): atomically withdraw quarantined automation

This commit is contained in:
whyour
2026-08-13 03:42:38 +08:00
parent a6ad636251
commit 4c2a0b6adf
39 changed files with 983 additions and 264 deletions
@@ -44,7 +44,10 @@ function integer(row: Row, key: string): number {
return value as number;
}
function targetIdentity(projectId: unknown, packageName: unknown): {
function targetIdentity(
projectId: unknown,
packageName: unknown,
): {
readonly projectId: string;
readonly packageName: string;
} {
@@ -119,19 +122,18 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
#parse(row: Row): Readonly<PluginPackageAutomationPublication> {
try {
const publication = normalizePluginPackageAutomationPublication(
JSON.parse(text(row, 'publicationJson')) as
PluginPackageAutomationPublication,
JSON.parse(
text(row, 'publicationJson'),
) as PluginPackageAutomationPublication,
);
if (
publication.publicationDigest !==
text(row, 'publicationDigest') ||
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.generationDigest !== text(row, 'generationDigest') ||
publication.target.materializedRevisionDigest !==
text(row, 'materializedRevisionDigest') ||
publication.state !== text(row, 'state') ||
@@ -142,9 +144,7 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
}
return publication;
} catch (error) {
if (
error instanceof PluginPackageAutomationPublicationUnavailableError
) {
if (error instanceof PluginPackageAutomationPublicationUnavailableError) {
throw error;
}
throw new PluginPackageAutomationPublicationUnavailableError();
@@ -389,8 +389,9 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
return this.#findCurrent(projectId, packageName);
}
publishInTransaction(
#publishInTransaction(
value: Readonly<PluginPackageAutomationPublication>,
securityWithdrawal: boolean,
): Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
@@ -430,10 +431,7 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
);
}
try {
assertPluginPackageAutomationPublicationSuccessor(
current,
publication,
);
assertPluginPackageAutomationPublicationSuccessor(current, publication);
} catch (error) {
if (error instanceof InvalidPluginPackageAutomationPublicationError) {
throw new PluginPackageAutomationPublicationConflictError(
@@ -467,33 +465,35 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
'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',
);
if (!securityWithdrawal) {
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(
@@ -570,9 +570,31 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
});
}
publish(
publishInTransaction(
value: Readonly<PluginPackageAutomationPublication>,
): Promise<
): Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}> {
return this.#publishInTransaction(value, false);
}
publishSecurityWithdrawalInTransaction(
value: Readonly<PluginPackageAutomationPublication>,
): Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}> {
const publication = normalizePluginPackageAutomationPublication(value);
if (publication.state !== 'withdrawn') {
throw new PluginPackageAutomationPublicationConflictError(
'security withdrawal must narrow automation state',
);
}
return this.#publishInTransaction(publication, true);
}
publish(value: Readonly<PluginPackageAutomationPublication>): Promise<
Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
@@ -20,6 +20,7 @@ import {
normalizePluginPackageInstallRecord,
type PluginPackageInstallRecord,
} from '@qinglong/runtime-core/plugin-package-install';
import { createPluginPackageAutomationLifecyclePublication } from '@qinglong/runtime-core/plugin-package-automation-publication';
import {
createProjectToolDefinitionSnapshot,
normalizeProjectToolDefinitionSnapshot,
@@ -40,6 +41,7 @@ import {
} from '@qinglong/runtime-core/task-spec-semantic';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import { LocalSqlitePluginPackageAutomationPublicationRepository } from './pluginPackageAutomationPublicationRepository';
type Row = Record<string, unknown>;
@@ -314,6 +316,30 @@ export class LocalSqlitePluginPackageQuarantineRepository
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
throw new PluginPackageQuarantineUnavailableError();
}
const automation = this.#authority.client
.prepare(
`SELECT state,
lifecycle_event_digest AS "lifecycleEventDigest"
FROM "QingLong3PluginPackageAutomationPublications"
WHERE project_id = ? AND package_name = ?
AND installation_id = ? AND lock_digest = ?
ORDER BY version DESC
LIMIT 1`,
)
.get(
receipt.target.projectId,
receipt.target.packageName,
receipt.target.installationId,
receipt.target.lockDigest,
) as Row | undefined;
if (
automation &&
(text(automation, 'state') === 'active' ||
(automation.lifecycleEventDigest === receipt.eventDigest &&
text(automation, 'state') !== 'withdrawn'))
) {
throw new PluginPackageQuarantineUnavailableError();
}
}
#findStored(
@@ -433,6 +459,40 @@ export class LocalSqlitePluginPackageQuarantineRepository
return record;
}
#withdrawAutomation(
event: Readonly<PluginPackageQuarantineEvent>,
record: Readonly<PluginPackageInstallRecord>,
committedAtMs: number,
): void {
const publications =
new LocalSqlitePluginPackageAutomationPublicationRepository(
this.#authority,
);
const current = publications.findCurrentInTransaction(
event.target.projectId,
event.target.packageName,
);
if (!current) return;
if (
current.target.installationId !== event.target.installationId ||
current.target.lockDigest !== event.target.lockDigest ||
current.target.generation !== record.targetGeneration
) {
throw new PluginPackageQuarantineConflictError(
'Workflow/Prompt publication does not match the quarantined Package generation',
);
}
if (current.state === 'absent' || current.state === 'withdrawn') return;
publications.publishSecurityWithdrawalInTransaction(
createPluginPackageAutomationLifecyclePublication({
previous: current,
state: 'withdrawn',
lifecycleEventDigest: event.eventDigest,
publishedAtMs: committedAtMs,
}),
);
}
#activeContributions(
projectId: string,
): readonly Readonly<ProjectToolDefinitionSnapshotContribution>[] {
@@ -792,7 +852,7 @@ export class LocalSqlitePluginPackageQuarantineRepository
'target lock is already quarantined by another event',
);
}
this.#install(event);
const install = this.#install(event);
const clock = client
.prepare(
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS "nowMs"`,
@@ -857,6 +917,7 @@ export class LocalSqlitePluginPackageQuarantineRepository
),
);
this.#insertEvent(event);
this.#withdrawAutomation(event, install, committedAtMs);
this.#publishSnapshot(snapshot, committedAtMs);
const receipt = createPluginPackageWithdrawalReceipt({
eventDigest: event.eventDigest,