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:
@@ -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