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
@@ -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(`
@@ -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) => {
@@ -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);