mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): atomically withdraw quarantined automation
This commit is contained in:
@@ -98,6 +98,8 @@ import { local0085PluginPackageWorkflowRunListIndexMigration } from '../migratio
|
||||
import { local0086CapabilityV43Migration } from '../migrations/0086-capability-v43';
|
||||
import { local0087RunAttemptLogRetentionMigration } from '../migrations/0087-run-attempt-log-retention';
|
||||
import { local0088CapabilityV44Migration } from '../migrations/0088-capability-v44';
|
||||
import { local0089PluginPackageAutomationDispositionEventsMigration } from '../migrations/0089-plugin-package-automation-disposition-events';
|
||||
import { local0090CapabilityV45Migration } from '../migrations/0090-capability-v45';
|
||||
import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration';
|
||||
import {
|
||||
LOCAL_SQLITE_MIGRATION_STREAM_ID,
|
||||
@@ -208,6 +210,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition<LocalSqli
|
||||
local0086CapabilityV43Migration,
|
||||
local0087RunAttemptLogRetentionMigration,
|
||||
local0088CapabilityV44Migration,
|
||||
local0089PluginPackageAutomationDispositionEventsMigration,
|
||||
local0090CapabilityV45Migration,
|
||||
]),
|
||||
});
|
||||
|
||||
|
||||
@@ -452,5 +452,15 @@ export const localSqliteMigrationManifest: MigrationStreamManifest =
|
||||
checksum:
|
||||
'c47a61b140b54d448c30ce7d5f7927c0d16fb897ab36dbe1d6010da2c39075a7',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: '0089-plugin-package-automation-disposition-events',
|
||||
checksum:
|
||||
'3eaff9c7621fc4a69a7605fd7de29d38dfde856df13b5e467ccc2bf1245e21aa',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: '0090-capability-v45',
|
||||
checksum:
|
||||
'1919987d29ef581e150116c590f1dc98f5d327791fc6425d5f1a27b8f6de5475',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -70,9 +70,7 @@ CREATE TABLE IF NOT EXISTS "QingLong3SchemaMigrations" (
|
||||
).map(record);
|
||||
}
|
||||
|
||||
async findById(
|
||||
migrationId: string,
|
||||
): Promise<MigrationStreamRecord | null> {
|
||||
async findById(migrationId: string): Promise<MigrationStreamRecord | null> {
|
||||
const row = this.client
|
||||
.prepare(
|
||||
`SELECT migration_id, stream_id, dialect, checksum, applied_at_ms
|
||||
@@ -88,6 +86,11 @@ CREATE TABLE IF NOT EXISTS "QingLong3SchemaMigrations" (
|
||||
transaction: MigrationStreamTransaction<LocalSqliteMigrationContext>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const foreignKeys = this.client.prepare('PRAGMA foreign_keys').get() as
|
||||
| { foreign_keys?: unknown }
|
||||
| undefined;
|
||||
const restoreForeignKeys = foreignKeys?.foreign_keys === 1;
|
||||
if (restoreForeignKeys) this.client.exec('PRAGMA foreign_keys = OFF');
|
||||
this.client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = await work({
|
||||
@@ -116,9 +119,11 @@ CREATE TABLE IF NOT EXISTS "QingLong3SchemaMigrations" (
|
||||
},
|
||||
});
|
||||
this.client.exec('COMMIT');
|
||||
if (restoreForeignKeys) this.client.exec('PRAGMA foreign_keys = ON');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.client.isTransaction) this.client.exec('ROLLBACK');
|
||||
if (restoreForeignKeys) this.client.exec('PRAGMA foreign_keys = ON');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { defineLocalSqliteMigration } from './sqlMigration';
|
||||
|
||||
export const local0089PluginPackageAutomationDispositionEventsMigration =
|
||||
defineLocalSqliteMigration({
|
||||
id: '0089-plugin-package-automation-disposition-events',
|
||||
statements: [
|
||||
`
|
||||
CREATE TABLE "QingLong3PluginPackageAutomationDispositionEvents" (
|
||||
event_digest TEXT PRIMARY KEY NOT NULL,
|
||||
event_kind TEXT NOT NULL
|
||||
CONSTRAINT ql3_plugin_package_automation_disposition_kind_check
|
||||
CHECK (event_kind IN ('lifecycle','quarantine')),
|
||||
CONSTRAINT ql3_plugin_package_automation_disposition_digest_check CHECK (
|
||||
length(event_digest) = 64 AND
|
||||
event_digest NOT GLOB '*[^0-9a-f]*'
|
||||
)
|
||||
)
|
||||
`,
|
||||
`INSERT INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) SELECT event_digest, 'lifecycle' FROM "QingLong3PluginPackageLifecycleEvents"`,
|
||||
`INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) SELECT event_digest, 'quarantine' FROM "QingLong3PluginPackageQuarantineEvents"`,
|
||||
`CREATE TRIGGER ql3_plugin_package_automation_lifecycle_disposition_insert AFTER INSERT ON "QingLong3PluginPackageLifecycleEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'lifecycle'); END`,
|
||||
`CREATE TRIGGER ql3_plugin_package_automation_quarantine_disposition_insert AFTER INSERT ON "QingLong3PluginPackageQuarantineEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'quarantine'); END`,
|
||||
`PRAGMA defer_foreign_keys = ON`,
|
||||
`PRAGMA legacy_alter_table = ON`,
|
||||
`ALTER TABLE "QingLong3PluginPackageAutomationPublications" RENAME TO "QingLong3PluginPackageAutomationPublicationsBeforeDisposition"`,
|
||||
`
|
||||
CREATE TABLE "QingLong3PluginPackageAutomationPublications" (
|
||||
publication_digest TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
package_name TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
lock_digest TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
generation_digest TEXT NOT NULL,
|
||||
materialized_revision_digest TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
previous_publication_digest TEXT,
|
||||
lifecycle_event_digest TEXT,
|
||||
published_at_ms INTEGER NOT NULL,
|
||||
publication_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_plugin_package_automation_publication_revision_fk
|
||||
FOREIGN KEY (generation_digest)
|
||||
REFERENCES "QingLong3PluginPackageMaterializedRevisions" (generation_digest)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_automation_publication_previous_fk
|
||||
FOREIGN KEY (previous_publication_digest)
|
||||
REFERENCES "QingLong3PluginPackageAutomationPublications" (publication_digest)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_automation_publication_disposition_fk
|
||||
FOREIGN KEY (lifecycle_event_digest)
|
||||
REFERENCES "QingLong3PluginPackageAutomationDispositionEvents" (event_digest)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_automation_publication_identity_check CHECK (
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(package_name) BETWEEN 1 AND 63 AND
|
||||
length(installation_id) BETWEEN 1 AND 128 AND
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
state IN ('active','withdrawn','absent') AND
|
||||
version BETWEEN 1 AND 2147483647 AND published_at_ms >= 0 AND
|
||||
(version = 1 AND state IN ('active','absent') AND
|
||||
previous_publication_digest IS NULL AND lifecycle_event_digest IS NULL OR
|
||||
version > 1 AND previous_publication_digest IS NOT NULL) AND
|
||||
(state <> 'withdrawn' OR lifecycle_event_digest IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_automation_publication_digest_check CHECK (
|
||||
length(publication_digest) = 64 AND publication_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(lock_digest) = 64 AND lock_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(generation_digest) = 64 AND generation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(materialized_revision_digest) = 64 AND materialized_revision_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
(previous_publication_digest IS NULL OR length(previous_publication_digest) = 64 AND previous_publication_digest NOT GLOB '*[^0-9a-f]*') AND
|
||||
(lifecycle_event_digest IS NULL OR length(lifecycle_event_digest) = 64 AND lifecycle_event_digest NOT GLOB '*[^0-9a-f]*')
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_automation_publication_json_check CHECK (
|
||||
length(CAST(publication_json AS BLOB)) BETWEEN 2 AND 12582912 AND
|
||||
json_valid(publication_json) AND json_type(publication_json) = 'object' AND
|
||||
json_extract(publication_json, '$.schema') = 'qinglong/plugin-package-automation-publication@v1' AND
|
||||
json_extract(publication_json, '$.target.projectId') = project_id AND
|
||||
json_extract(publication_json, '$.target.packageName') = package_name AND
|
||||
json_extract(publication_json, '$.target.installationId') = installation_id AND
|
||||
json_extract(publication_json, '$.target.lockDigest') = lock_digest AND
|
||||
json_extract(publication_json, '$.target.generation') = generation AND
|
||||
json_extract(publication_json, '$.target.generationDigest') = generation_digest AND
|
||||
json_extract(publication_json, '$.target.materializedRevisionDigest') = materialized_revision_digest AND
|
||||
json_extract(publication_json, '$.state') = state AND
|
||||
json_extract(publication_json, '$.version') = version AND
|
||||
(previous_publication_digest IS NULL AND json_type(publication_json, '$.previousPublicationDigest') = 'null' OR json_extract(publication_json, '$.previousPublicationDigest') = previous_publication_digest) AND
|
||||
(lifecycle_event_digest IS NULL AND json_type(publication_json, '$.lifecycleEventDigest') = 'null' OR json_extract(publication_json, '$.lifecycleEventDigest') = lifecycle_event_digest) AND
|
||||
json_extract(publication_json, '$.publishedAtMs') = published_at_ms AND
|
||||
json_extract(publication_json, '$.publicationDigest') = publication_digest AND
|
||||
json_type(publication_json, '$.definitions.workflows') = 'array' AND
|
||||
json_type(publication_json, '$.definitions.prompts') = 'array' AND
|
||||
(state = 'absent' AND json_array_length(json_extract(publication_json, '$.definitions.workflows')) + json_array_length(json_extract(publication_json, '$.definitions.prompts')) = 0 OR
|
||||
state <> 'absent' AND json_array_length(json_extract(publication_json, '$.definitions.workflows')) + json_array_length(json_extract(publication_json, '$.definitions.prompts')) > 0)
|
||||
)
|
||||
)
|
||||
`,
|
||||
`INSERT INTO "QingLong3PluginPackageAutomationPublications" SELECT * FROM "QingLong3PluginPackageAutomationPublicationsBeforeDisposition" ORDER BY project_id, package_name, version`,
|
||||
`DROP TABLE "QingLong3PluginPackageAutomationPublicationsBeforeDisposition"`,
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_automation_publication_version_uidx ON "QingLong3PluginPackageAutomationPublications" (project_id, package_name, version)`,
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_automation_publication_previous_uidx ON "QingLong3PluginPackageAutomationPublications" (previous_publication_digest) WHERE previous_publication_digest IS NOT NULL`,
|
||||
`CREATE INDEX ql3_plugin_package_automation_publication_generation_idx ON "QingLong3PluginPackageAutomationPublications" (generation_digest, publication_digest)`,
|
||||
`PRAGMA legacy_alter_table = OFF`,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CAPABILITIES_V44 } from './0088-capability-v44';
|
||||
import { defineLocalSqliteMigration } from './sqlMigration';
|
||||
|
||||
export const CAPABILITIES_V45 = CAPABILITIES_V44.replace(
|
||||
'"plugin_package_automation_publication":1,',
|
||||
'"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,',
|
||||
);
|
||||
|
||||
export const local0090CapabilityV45Migration = defineLocalSqliteMigration({
|
||||
id: '0090-capability-v45',
|
||||
statements: [
|
||||
`UPDATE "QingLong3SchemaCapabilities" SET contract_version = 45, migration_id = '0089-plugin-package-automation-disposition-events', capabilities = '${CAPABILITIES_V45}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 44 AND migration_id = '0087-run-attempt-log-retention' AND capabilities = '${CAPABILITIES_V44}'`,
|
||||
],
|
||||
});
|
||||
+66
-44
@@ -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,
|
||||
|
||||
@@ -8,7 +8,20 @@ import {
|
||||
} from '../run/stepRunSchemaContract';
|
||||
|
||||
export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core';
|
||||
export const LOCAL_SQLITE_CONTRACT_VERSION = 44;
|
||||
export const LOCAL_SQLITE_CONTRACT_VERSION = 45;
|
||||
|
||||
const PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS = Object.freeze([
|
||||
Object.freeze({
|
||||
name: 'ql3_plugin_package_automation_lifecycle_disposition_insert',
|
||||
tableName: 'QingLong3PluginPackageLifecycleEvents',
|
||||
sql: `CREATE TRIGGER ql3_plugin_package_automation_lifecycle_disposition_insert AFTER INSERT ON "QingLong3PluginPackageLifecycleEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'lifecycle'); END`,
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'ql3_plugin_package_automation_quarantine_disposition_insert',
|
||||
tableName: 'QingLong3PluginPackageQuarantineEvents',
|
||||
sql: `CREATE TRIGGER ql3_plugin_package_automation_quarantine_disposition_insert AFTER INSERT ON "QingLong3PluginPackageQuarantineEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'quarantine'); END`,
|
||||
}),
|
||||
]);
|
||||
|
||||
const OPTIONAL_FEATURE_TABLE_NAMES = new Set([
|
||||
'QingLong3AiSchemaMigrations',
|
||||
@@ -1007,6 +1020,10 @@ const REQUIRED_SCHEMA = Object.freeze({
|
||||
'ql3_plugin_package_automation_publication_generation_idx',
|
||||
]),
|
||||
}),
|
||||
QingLong3PluginPackageAutomationDispositionEvents: Object.freeze({
|
||||
columns: Object.freeze(['event_digest', 'event_kind']),
|
||||
indexes: Object.freeze([]),
|
||||
}),
|
||||
QingLong3PluginPackageAutomationPublicationHeads: Object.freeze({
|
||||
columns: Object.freeze([
|
||||
'project_id',
|
||||
@@ -1702,9 +1719,10 @@ function assertRequiredSchema(client: DatabaseSync): number {
|
||||
ORDER BY name`,
|
||||
)
|
||||
.all(...ownedTableNames) as unknown as TriggerRow[];
|
||||
const expectedTriggers = [...LOCAL_STEP_RUN_REFERENCE_TRIGGERS].sort(
|
||||
(left, right) => left.name.localeCompare(right.name),
|
||||
);
|
||||
const expectedTriggers = [
|
||||
...LOCAL_STEP_RUN_REFERENCE_TRIGGERS,
|
||||
...PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS,
|
||||
].sort((left, right) => left.name.localeCompare(right.name));
|
||||
if (
|
||||
triggerRows.length !== expectedTriggers.length ||
|
||||
triggerRows.some((row, index) => {
|
||||
@@ -2097,8 +2115,15 @@ function assertPluginPackageAutomationPublicationIntegrity(
|
||||
LEFT JOIN "QingLong3PluginPackageAutomationPublications" AS previous
|
||||
ON previous.publication_digest =
|
||||
publication.previous_publication_digest
|
||||
LEFT JOIN "QingLong3PluginPackageAutomationDispositionEvents"
|
||||
AS disposition
|
||||
ON disposition.event_digest = publication.lifecycle_event_digest
|
||||
LEFT JOIN "QingLong3PluginPackageLifecycleEvents" AS lifecycle
|
||||
ON lifecycle.event_digest = publication.lifecycle_event_digest
|
||||
AND disposition.event_kind = 'lifecycle'
|
||||
LEFT JOIN "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
ON quarantine.event_digest = publication.lifecycle_event_digest
|
||||
AND disposition.event_kind = 'quarantine'
|
||||
WHERE materialized.generation_digest IS NULL
|
||||
OR materialized.project_id <> publication.project_id
|
||||
OR materialized.package_name <> publication.package_name
|
||||
@@ -2129,14 +2154,25 @@ function assertPluginPackageAutomationPublicationIntegrity(
|
||||
)
|
||||
OR (
|
||||
publication.lifecycle_event_digest IS NOT NULL AND (
|
||||
lifecycle.event_digest IS NULL OR
|
||||
lifecycle.project_id <> publication.project_id OR
|
||||
lifecycle.package_name <> publication.package_name OR
|
||||
lifecycle.installation_id <> publication.installation_id OR
|
||||
lifecycle.lock_digest <> publication.lock_digest OR
|
||||
lifecycle.generation_digest <> publication.generation_digest OR
|
||||
lifecycle.materialized_revision_digest <>
|
||||
publication.materialized_revision_digest OR
|
||||
disposition.event_digest IS NULL OR
|
||||
disposition.event_kind = 'lifecycle' AND (
|
||||
lifecycle.event_digest IS NULL OR
|
||||
lifecycle.project_id <> publication.project_id OR
|
||||
lifecycle.package_name <> publication.package_name OR
|
||||
lifecycle.installation_id <> publication.installation_id OR
|
||||
lifecycle.lock_digest <> publication.lock_digest OR
|
||||
lifecycle.generation_digest <> publication.generation_digest OR
|
||||
lifecycle.materialized_revision_digest <>
|
||||
publication.materialized_revision_digest
|
||||
) OR
|
||||
disposition.event_kind = 'quarantine' AND (
|
||||
quarantine.event_digest IS NULL OR
|
||||
quarantine.project_id <> publication.project_id OR
|
||||
quarantine.package_name <> publication.package_name OR
|
||||
quarantine.installation_id <> publication.installation_id OR
|
||||
quarantine.lock_digest <> publication.lock_digest OR
|
||||
publication.state <> 'withdrawn'
|
||||
) OR
|
||||
previous.installation_id <> publication.installation_id OR
|
||||
previous.lock_digest <> publication.lock_digest OR
|
||||
previous.generation_digest <> publication.generation_digest OR
|
||||
@@ -2496,10 +2532,11 @@ export async function auditLocalSqliteReadiness(
|
||||
!capability ||
|
||||
capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME ||
|
||||
capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION ||
|
||||
capability.migration_id !== '0087-run-attempt-log-retention' ||
|
||||
capability.migration_id !==
|
||||
'0089-plugin-package-automation-disposition-events' ||
|
||||
typeof capability.capabilities !== 'string' ||
|
||||
capability.capabilities !==
|
||||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
|
||||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
|
||||
typeof capability.updated_at_ms !== 'number' ||
|
||||
!Number.isSafeInteger(capability.updated_at_ms) ||
|
||||
capability.updated_at_ms < 0
|
||||
|
||||
@@ -4486,6 +4486,24 @@ export const pluginPackageLifecycleTasks = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const pluginPackageAutomationDispositionEvents = sqliteTable(
|
||||
'QingLong3PluginPackageAutomationDispositionEvents',
|
||||
{
|
||||
eventDigest: text('event_digest').primaryKey(),
|
||||
eventKind: text('event_kind').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
'ql3_plugin_package_automation_disposition_kind_check',
|
||||
sql`${table.eventKind} in ('lifecycle','quarantine')`,
|
||||
),
|
||||
check(
|
||||
'ql3_plugin_package_automation_disposition_digest_check',
|
||||
sql`length(${table.eventDigest}) = 64 and ${table.eventDigest} not glob '*[^0-9a-f]*'`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const pluginPackageAutomationPublications = sqliteTable(
|
||||
'QingLong3PluginPackageAutomationPublications',
|
||||
{
|
||||
@@ -4510,7 +4528,7 @@ export const pluginPackageAutomationPublications = sqliteTable(
|
||||
{ onDelete: 'restrict', onUpdate: 'restrict' },
|
||||
),
|
||||
lifecycleEventDigest: text('lifecycle_event_digest').references(
|
||||
() => pluginPackageLifecycleEvents.eventDigest,
|
||||
() => pluginPackageAutomationDispositionEvents.eventDigest,
|
||||
{ onDelete: 'restrict', onUpdate: 'restrict' },
|
||||
),
|
||||
publishedAtMs: integer('published_at_ms').notNull(),
|
||||
@@ -4877,6 +4895,7 @@ export const localSqliteSchema = Object.freeze({
|
||||
pluginPackageLifecycleHeads,
|
||||
pluginPackageLifecycleReceipts,
|
||||
pluginPackageLifecycleTasks,
|
||||
pluginPackageAutomationDispositionEvents,
|
||||
pluginPackageAutomationPublications,
|
||||
pluginPackageAutomationPublicationHeads,
|
||||
pluginPackageWorkflowAdmissions,
|
||||
|
||||
@@ -138,9 +138,11 @@ test('creates a reviewed edge database and opens runtime only after readiness',
|
||||
'0086-capability-v43',
|
||||
'0087-run-attempt-log-retention',
|
||||
'0088-capability-v44',
|
||||
'0089-plugin-package-automation-disposition-events',
|
||||
'0090-capability-v45',
|
||||
]);
|
||||
assert.equal(migrated.readiness.contractName, 'local-control-core');
|
||||
assert.equal(migrated.readiness.contractVersion, 44);
|
||||
assert.equal(migrated.readiness.contractVersion, 45);
|
||||
assert.equal(migrated.readiness.journalMode, 'delete');
|
||||
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
|
||||
|
||||
@@ -503,8 +505,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
contract_version: 44,
|
||||
migration_id: '0087-run-attempt-log-retention',
|
||||
contract_version: 45,
|
||||
migration_id: '0089-plugin-package-automation-disposition-events',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
@@ -691,19 +693,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 78);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 79);
|
||||
client.exec(
|
||||
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
|
||||
);
|
||||
client.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 78);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 79);
|
||||
|
||||
const unknownClient = new DatabaseSync(databasePath);
|
||||
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
|
||||
unknownClient.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 79);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 80);
|
||||
|
||||
const triggerClient = new DatabaseSync(databasePath);
|
||||
triggerClient.exec(`
|
||||
|
||||
+56
@@ -4,6 +4,9 @@ const { DatabaseSync } = require('node:sqlite');
|
||||
const {
|
||||
createPluginPackageQuarantineEvent,
|
||||
} = require('@qinglong/runtime-core/plugin-package-quarantine');
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
@@ -14,6 +17,9 @@ const {
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
@@ -30,6 +36,11 @@ const DIGEST_D = 'd'.repeat(64);
|
||||
const DIGEST_E = 'e'.repeat(64);
|
||||
|
||||
const CRASH_POINTS = Object.freeze({
|
||||
after_automation_withdrawal: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageAutomationPublications"',
|
||||
durable: false,
|
||||
}),
|
||||
after_task_disable: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3TaskDefinitionRevisions"',
|
||||
@@ -60,6 +71,24 @@ const CRASH_POINTS = Object.freeze({
|
||||
function fixture(profile) {
|
||||
return pluginPackageTaskReconciliationFixture(`quarantine-crash-${profile}`, {
|
||||
profile,
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [{ id: 'run', task: 'alpha', needs: [] }],
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: 'operator',
|
||||
name: 'Operator prompt',
|
||||
template: 'Run {{task}}',
|
||||
parameters: [{ name: 'task', required: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,6 +148,15 @@ async function setupScenario({ databasePath, profile }) {
|
||||
);
|
||||
await activateInstall(install, value);
|
||||
await materialized.publish(value.revision);
|
||||
await new LocalSqlitePluginPackageAutomationPublicationRepository(
|
||||
authority,
|
||||
).publish(
|
||||
createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
value.install.active.updatedAtMs,
|
||||
),
|
||||
);
|
||||
await reconciliation.reconcile(value.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.revision.generation;
|
||||
@@ -259,6 +297,24 @@ async function verifyScenario({ databasePath, pointName, profile }) {
|
||||
) {
|
||||
throw new Error(`${profile}/${pointName} Task withdrawal is incomplete`);
|
||||
}
|
||||
const automation = database
|
||||
.prepare(
|
||||
`SELECT publication.state,
|
||||
publication.lifecycle_event_digest AS "lifecycleEventDigest"
|
||||
FROM "QingLong3PluginPackageAutomationPublicationHeads" AS head
|
||||
JOIN "QingLong3PluginPackageAutomationPublications" AS publication
|
||||
ON publication.publication_digest = head.publication_digest
|
||||
WHERE head.project_id = ? AND head.package_name = ?`,
|
||||
)
|
||||
.get(value.projectId, value.packageName);
|
||||
if (
|
||||
automation?.state !== 'withdrawn' ||
|
||||
automation.lifecycleEventDigest !== quarantineEvent.eventDigest
|
||||
) {
|
||||
throw new Error(
|
||||
`${profile}/${pointName} automation withdrawal is incomplete`,
|
||||
);
|
||||
}
|
||||
await auditLocalSqliteReadiness(database);
|
||||
const integrity = database.prepare('PRAGMA integrity_check').get();
|
||||
const foreignKey = database
|
||||
|
||||
@@ -60,10 +60,10 @@ test(
|
||||
assert.equal(reports.at(-1).durableAfterCrash, point.durable);
|
||||
}
|
||||
}
|
||||
assert.equal(reports.length, 10);
|
||||
assert.equal(reports.length, 12);
|
||||
assert.equal(
|
||||
reports.filter(({ crashBeforeCommit }) => crashBeforeCommit).length,
|
||||
8,
|
||||
10,
|
||||
);
|
||||
assert.equal(
|
||||
reports.filter(({ durableAfterCrash }) => durableAfterCrash).length,
|
||||
|
||||
@@ -7,6 +7,9 @@ const {
|
||||
PluginPackageQuarantineUnavailableError,
|
||||
createPluginPackageQuarantineEvent,
|
||||
} = require('@qinglong/runtime-core/plugin-package-quarantine');
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
RunRepositoryConstraintError,
|
||||
} = require('@qinglong/runtime-core/run-repository');
|
||||
@@ -17,13 +20,18 @@ const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
@@ -46,6 +54,24 @@ const digest = (value) => value.repeat(64);
|
||||
async function harness(t, namespace) {
|
||||
const fixture = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
profile: 'edge',
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [{ id: 'run', task: 'alpha', needs: [] }],
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: 'operator',
|
||||
name: 'Operator prompt',
|
||||
template: 'Run {{task}}',
|
||||
parameters: [{ name: 'task', required: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
@@ -67,6 +93,9 @@ async function harness(t, namespace) {
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
automation: new LocalSqlitePluginPackageAutomationPublicationRepository(
|
||||
authority,
|
||||
),
|
||||
reconciliation: new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
@@ -108,6 +137,13 @@ function quarantineEvent(fixture, record = fixture.install.active) {
|
||||
async function publishActivePackage(value) {
|
||||
await activateInstall(value.install, value.fixture);
|
||||
await value.materialized.publish(value.fixture.revision);
|
||||
await value.automation.publish(
|
||||
createInitialPluginPackageAutomationPublication(
|
||||
value.fixture.revision,
|
||||
value.fixture.registry,
|
||||
value.fixture.install.active.updatedAtMs,
|
||||
),
|
||||
);
|
||||
await value.reconciliation.reconcile(value.fixture.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.fixture.revision.generation;
|
||||
@@ -199,6 +235,13 @@ test('withdraws active Package Tasks and Tool source in one exact replayable tra
|
||||
.snapshotDigest,
|
||||
created.receipt.capability.currentToolSnapshotDigest,
|
||||
);
|
||||
const automation = await value.automation.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(automation.state, 'withdrawn');
|
||||
assert.equal(automation.lifecycleEventDigest, event.eventDigest);
|
||||
assert.equal(automation.version, 2);
|
||||
|
||||
const replay = await value.quarantine.quarantine(event, () => {
|
||||
authorizationChecks += 1;
|
||||
@@ -264,6 +307,51 @@ test('rolls back every withdrawal fact when the target install advanced', async
|
||||
.get(value.fixture.projectId).count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await value.automation.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
)
|
||||
).state,
|
||||
'active',
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when quarantine automation withdrawal evidence is rewound', async (t) => {
|
||||
const value = await harness(t, 'sqlite-quarantine-automation-corrupt');
|
||||
await publishActivePackage(value);
|
||||
const event = quarantineEvent(value.fixture);
|
||||
await value.quarantine.quarantine(event, () => {});
|
||||
const active = value.client
|
||||
.prepare(
|
||||
`SELECT publication_digest AS "publicationDigest"
|
||||
FROM "QingLong3PluginPackageAutomationPublications"
|
||||
WHERE project_id = ? AND package_name = ? AND state = 'active'`,
|
||||
)
|
||||
.get(value.fixture.projectId, value.fixture.packageName);
|
||||
value.client.exec('PRAGMA foreign_keys = OFF');
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageAutomationPublicationHeads"
|
||||
SET publication_digest = ?, state = 'active', version = 1
|
||||
WHERE project_id = ? AND package_name = ?`,
|
||||
)
|
||||
.run(
|
||||
active.publicationDigest,
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
value.client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3PluginPackageAutomationPublications"
|
||||
WHERE project_id = ? AND package_name = ? AND state = 'withdrawn'`,
|
||||
)
|
||||
.run(value.fixture.projectId, value.fixture.packageName);
|
||||
await assert.rejects(
|
||||
value.quarantine.findByEventDigest(event.eventDigest),
|
||||
PluginPackageQuarantineUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rolls back withdrawal when the in-transaction Owner fence changes before commit', async (t) => {
|
||||
|
||||
@@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it
|
||||
},
|
||||
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 44);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
|
||||
});
|
||||
|
||||
test('runs an optional authorization guard inside new and replay transactions', async (t) => {
|
||||
@@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances',
|
||||
},
|
||||
{ status: 'running', version: 5, eventSequence: 5 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 44);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
|
||||
});
|
||||
|
||||
test('fails closed before writing when the exact installation is not active', async (t) => {
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it'
|
||||
stepAttemptCount: 0,
|
||||
},
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 44);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
|
||||
});
|
||||
|
||||
test('bounds candidate paging before SQL and fences cancellation', async (t) => {
|
||||
|
||||
@@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => {
|
||||
await migrateLocalSqlitePath(state);
|
||||
const prepared = await createLocalSqliteRolloutBackup(state);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.contractVersion, 44);
|
||||
assert.equal(prepared.writeContractVersion, 44);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 44);
|
||||
assert.equal(prepared.contractVersion, 45);
|
||||
assert.equal(prepared.writeContractVersion, 45);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 45);
|
||||
assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(prepared.bytes > 0, true);
|
||||
assert.equal(prepared.pageCount > 0, true);
|
||||
|
||||
Reference in New Issue
Block a user