mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
createNextPluginPackageAutomationPublication,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackageAutomationPublication');
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('./pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
function automationOptions(name = 'daily') {
|
||||
return {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: name,
|
||||
name: `Workflow ${name}`,
|
||||
enabled: true,
|
||||
steps: [{ id: 'run', task: 'alpha', needs: [] }],
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: `${name}-prompt`,
|
||||
name: `Prompt ${name}`,
|
||||
template: 'Hello {{name}}',
|
||||
parameters: [{ name: 'name', required: true }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(namespace, options = {}) {
|
||||
return pluginPackageTaskReconciliationFixture(namespace, {
|
||||
profile: options.profile,
|
||||
previous: options.previous,
|
||||
...automationOptions(options.name),
|
||||
});
|
||||
}
|
||||
|
||||
function registerPluginPackageAutomationPublicationRepositoryContract(options) {
|
||||
test(`${options.name} publishes and replays one exact current head`, async (t) => {
|
||||
const value = fixture(`${options.namespace}-initial`, {
|
||||
profile: options.profile,
|
||||
name: 'daily',
|
||||
});
|
||||
const harness = await options.createRepository(t, value);
|
||||
t.after(() => harness.close?.());
|
||||
await harness.materializedRepository.publish(value.revision);
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
1_000,
|
||||
);
|
||||
const created = await harness.repository.publish(publication);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.deepEqual(created.publication, publication);
|
||||
assert.equal((await harness.repository.publish(publication)).status, 'existing');
|
||||
assert.deepEqual(
|
||||
await harness.repository.findByDigest(publication.publicationDigest),
|
||||
publication,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(value.projectId, value.packageName),
|
||||
publication,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${options.name} advances generations with one CAS publication chain`, async (t) => {
|
||||
const first = fixture(`${options.namespace}-upgrade`, {
|
||||
profile: options.profile,
|
||||
name: 'daily',
|
||||
});
|
||||
const harness = await options.createRepository(t, first);
|
||||
t.after(() => harness.close?.());
|
||||
await harness.materializedRepository.publish(first.revision);
|
||||
const initial = createInitialPluginPackageAutomationPublication(
|
||||
first.revision,
|
||||
first.registry,
|
||||
1_000,
|
||||
);
|
||||
await harness.repository.publish(initial);
|
||||
|
||||
const second = fixture(first.namespace, {
|
||||
profile: options.profile,
|
||||
previous: first,
|
||||
name: 'hourly',
|
||||
});
|
||||
await harness.materializedRepository.publish(second.revision);
|
||||
const next = createNextPluginPackageAutomationPublication(
|
||||
second.revision,
|
||||
second.registry,
|
||||
initial,
|
||||
1_100,
|
||||
);
|
||||
assert.equal((await harness.repository.publish(next)).status, 'created');
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(first.projectId, first.packageName),
|
||||
next,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await harness.repository.findByDigest(initial.publicationDigest),
|
||||
initial,
|
||||
);
|
||||
|
||||
const staleFork = createNextPluginPackageAutomationPublication(
|
||||
second.revision,
|
||||
second.registry,
|
||||
initial,
|
||||
1_101,
|
||||
);
|
||||
await assert.rejects(
|
||||
harness.repository.publish(staleFork),
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(first.projectId, first.packageName),
|
||||
next,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${options.name} persists absent tombstones across Package generations`, async (t) => {
|
||||
const first = fixture(`${options.namespace}-tombstone`, {
|
||||
profile: options.profile,
|
||||
name: 'daily',
|
||||
});
|
||||
const harness = await options.createRepository(t, first);
|
||||
t.after(() => harness.close?.());
|
||||
await harness.materializedRepository.publish(first.revision);
|
||||
const initial = createInitialPluginPackageAutomationPublication(
|
||||
first.revision,
|
||||
first.registry,
|
||||
1_000,
|
||||
);
|
||||
await harness.repository.publish(initial);
|
||||
|
||||
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
|
||||
profile: options.profile,
|
||||
previous: first,
|
||||
});
|
||||
await harness.materializedRepository.publish(second.revision);
|
||||
const absent = createNextPluginPackageAutomationPublication(
|
||||
second.revision,
|
||||
second.registry,
|
||||
initial,
|
||||
1_100,
|
||||
);
|
||||
assert.equal(absent.state, 'absent');
|
||||
assert.equal((await harness.repository.publish(absent)).status, 'created');
|
||||
|
||||
const third = fixture(first.namespace, {
|
||||
profile: options.profile,
|
||||
previous: second,
|
||||
name: 'restored',
|
||||
});
|
||||
await harness.materializedRepository.publish(third.revision);
|
||||
const restored = createNextPluginPackageAutomationPublication(
|
||||
third.revision,
|
||||
third.registry,
|
||||
absent,
|
||||
1_200,
|
||||
);
|
||||
assert.equal(restored.state, 'active');
|
||||
assert.equal((await harness.repository.publish(restored)).status, 'created');
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(first.projectId, first.packageName),
|
||||
restored,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await harness.repository.findByDigest(absent.publicationDigest),
|
||||
absent,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pluginPackageAutomationPublicationFixture: fixture,
|
||||
registerPluginPackageAutomationPublicationRepositoryContract,
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PluginPackageInstallMutationConflictError,
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCommit,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackage');
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function manifest(packageName) {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: packageName,
|
||||
displayName: packageName,
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: {
|
||||
tasks: [],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment() {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(namespace, overrides = {}) {
|
||||
const packageName = overrides.packageName ?? `${namespace}-package`;
|
||||
const packageManifest = manifest(packageName);
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const source = {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
|
||||
artifactDigest: ARTIFACT_DIGEST,
|
||||
artifactBytes: 2048,
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
};
|
||||
const lockId = overrides.lockId ?? `lock-${namespace}`;
|
||||
const actionInput = {
|
||||
lockId,
|
||||
projectId: 'default',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: `approval-${lockId}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${lockId}`,
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const install = createPluginPackageInstall(lock, {
|
||||
installationId: overrides.installationId ?? `install-${namespace}`,
|
||||
mutationId: overrides.mutationId ?? `mutation-create-${namespace}`,
|
||||
occurredAtMs: overrides.occurredAtMs ?? 201,
|
||||
});
|
||||
return { lock, install };
|
||||
}
|
||||
|
||||
function stage(lock, install, overrides = {}) {
|
||||
return transitionPluginPackageInstall(lock, install, {
|
||||
type: 'stage_completed',
|
||||
mutationId:
|
||||
overrides.mutationId ?? `mutation-stage-${install.installationId}`,
|
||||
occurredAtMs: overrides.occurredAtMs ?? install.updatedAtMs + 1,
|
||||
stageRef: `stage:${lock.lockDigest}`,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
function registerPluginPackageInstallRepositoryContract({
|
||||
name,
|
||||
createRepository,
|
||||
}) {
|
||||
let sequence = 0;
|
||||
|
||||
function nextNamespace(suffix) {
|
||||
sequence += 1;
|
||||
return `ql3-${process.pid.toString(36)}-${Date.now().toString(
|
||||
36,
|
||||
)}-${sequence.toString(36)}-${suffix}`;
|
||||
}
|
||||
|
||||
async function setup(t, suffix) {
|
||||
const namespace = nextNamespace(suffix);
|
||||
const harness = await createRepository({ namespace });
|
||||
if (harness.close) t.after(() => harness.close());
|
||||
return { namespace, repository: harness.repository };
|
||||
}
|
||||
|
||||
test(`${name}: creates, finds, and exactly replays one durable head`, async (t) => {
|
||||
const { namespace, repository } = await setup(t, 'create');
|
||||
const value = fixture(namespace);
|
||||
const command = pluginPackageInstallCreate(value.lock, value.install, null);
|
||||
const created = await repository.create(command);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.deepEqual(
|
||||
await repository.find('default', value.install.packageName),
|
||||
value.install,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await repository.findLock(value.lock.lockDigest),
|
||||
value.lock,
|
||||
);
|
||||
assert.equal(await repository.findLock('f'.repeat(64)), null);
|
||||
const replay = await repository.create(command);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.record, value.install);
|
||||
});
|
||||
|
||||
test(`${name}: rejects mutation reuse with different locked facts`, async (t) => {
|
||||
const { namespace, repository } = await setup(t, 'mutation');
|
||||
const value = fixture(namespace);
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(value.lock, value.install, null),
|
||||
);
|
||||
const drift = fixture(namespace, {
|
||||
lockId: `lock-${namespace}-drift`,
|
||||
installationId: value.install.installationId,
|
||||
mutationId: value.install.lastMutationId,
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.create(
|
||||
pluginPackageInstallCreate(drift.lock, drift.install, null),
|
||||
),
|
||||
PluginPackageInstallMutationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: commits exact CAS transitions and rejects stale state`, async (t) => {
|
||||
const { namespace, repository } = await setup(t, 'cas');
|
||||
const value = fixture(namespace);
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(value.lock, value.install, null),
|
||||
);
|
||||
const staged = stage(value.lock, value.install);
|
||||
const command = pluginPackageInstallCommit(value.install, staged);
|
||||
const committed = await repository.commit(command);
|
||||
assert.equal(committed.status, 'committed');
|
||||
assert.deepEqual(
|
||||
await repository.find('default', value.install.packageName),
|
||||
staged,
|
||||
);
|
||||
assert.equal((await repository.commit(command)).status, 'existing');
|
||||
|
||||
const competing = stage(value.lock, value.install, {
|
||||
mutationId: `mutation-competing-${namespace}`,
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.commit(pluginPackageInstallCommit(value.install, competing)),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: replays an old mutation to the current advanced record`, async (t) => {
|
||||
const { namespace, repository } = await setup(t, 'advance');
|
||||
const value = fixture(namespace);
|
||||
const create = pluginPackageInstallCreate(value.lock, value.install, null);
|
||||
await repository.create(create);
|
||||
const staged = stage(value.lock, value.install);
|
||||
await repository.commit(pluginPackageInstallCommit(value.install, staged));
|
||||
const activating = transitionPluginPackageInstall(value.lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: `mutation-activate-${namespace}`,
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
await repository.commit(pluginPackageInstallCommit(staged, activating));
|
||||
|
||||
const replay = await repository.create(create);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.record, activating);
|
||||
});
|
||||
|
||||
test(`${name}: replaces only an exact terminal head`, async (t) => {
|
||||
const { namespace, repository } = await setup(t, 'replace');
|
||||
const first = fixture(namespace);
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(first.lock, first.install, null),
|
||||
);
|
||||
const failed = transitionPluginPackageInstall(first.lock, first.install, {
|
||||
type: 'failed',
|
||||
mutationId: `mutation-fail-${namespace}`,
|
||||
occurredAtMs: 202,
|
||||
reason: 'stage_failed',
|
||||
});
|
||||
await repository.commit(pluginPackageInstallCommit(first.install, failed));
|
||||
|
||||
const retry = fixture(namespace, {
|
||||
lockId: `lock-${namespace}-retry`,
|
||||
installationId: `install-${namespace}-retry`,
|
||||
mutationId: `mutation-${namespace}-retry`,
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(retry.lock, retry.install, failed),
|
||||
);
|
||||
assert.deepEqual(
|
||||
await repository.find('default', first.install.packageName),
|
||||
retry.install,
|
||||
);
|
||||
|
||||
const stale = fixture(namespace, {
|
||||
lockId: `lock-${namespace}-stale`,
|
||||
installationId: `install-${namespace}-stale`,
|
||||
mutationId: `mutation-${namespace}-stale`,
|
||||
occurredAtMs: 204,
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.create(
|
||||
pluginPackageInstallCreate(stale.lock, stale.install, failed),
|
||||
),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: paginates only current recoverable heads with a stable cursor`, async (t) => {
|
||||
const { namespace, repository } = await setup(t, 'recovery');
|
||||
const alpha = fixture(namespace, {
|
||||
packageName: `${namespace}-alpha`,
|
||||
lockId: `lock-${namespace}-alpha`,
|
||||
installationId: `install-${namespace}-alpha`,
|
||||
mutationId: `mutation-${namespace}-alpha`,
|
||||
});
|
||||
const beta = fixture(namespace, {
|
||||
packageName: `${namespace}-beta`,
|
||||
lockId: `lock-${namespace}-beta`,
|
||||
installationId: `install-${namespace}-beta`,
|
||||
mutationId: `mutation-${namespace}-beta`,
|
||||
});
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(alpha.lock, alpha.install, null),
|
||||
);
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(beta.lock, beta.install, null),
|
||||
);
|
||||
const beforeNamespace = {
|
||||
packageName: namespace,
|
||||
installationId: `cursor-${namespace}`,
|
||||
};
|
||||
const first = await repository.listRecoveryPage({
|
||||
limit: 1,
|
||||
after: beforeNamespace,
|
||||
});
|
||||
assert.deepEqual(first.records, [alpha.install]);
|
||||
assert.equal(first.truncated, true);
|
||||
assert.deepEqual(first.next, {
|
||||
packageName: alpha.install.packageName,
|
||||
installationId: alpha.install.installationId,
|
||||
});
|
||||
const second = await repository.listRecoveryPage({
|
||||
limit: 1,
|
||||
after: first.next,
|
||||
});
|
||||
assert.deepEqual(second.records, [beta.install]);
|
||||
|
||||
const failed = transitionPluginPackageInstall(alpha.lock, alpha.install, {
|
||||
type: 'failed',
|
||||
mutationId: `mutation-${namespace}-alpha-fail`,
|
||||
occurredAtMs: 202,
|
||||
reason: 'source_unavailable',
|
||||
});
|
||||
await repository.commit(pluginPackageInstallCommit(alpha.install, failed));
|
||||
const recovery = await repository.listRecoveryPage({
|
||||
limit: 64,
|
||||
after: beforeNamespace,
|
||||
});
|
||||
assert.deepEqual(
|
||||
recovery.records.filter((record) =>
|
||||
record.packageName.startsWith(namespace),
|
||||
),
|
||||
[beta.install],
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: lists every current head for one project with a stable cursor`, async (t) => {
|
||||
const { namespace, repository } = await setup(t, 'inventory');
|
||||
const alpha = fixture(namespace, {
|
||||
packageName: `${namespace}-alpha`,
|
||||
lockId: `lock-${namespace}-inventory-alpha`,
|
||||
installationId: `install-${namespace}-inventory-alpha`,
|
||||
mutationId: `mutation-${namespace}-inventory-alpha`,
|
||||
});
|
||||
const beta = fixture(namespace, {
|
||||
packageName: `${namespace}-beta`,
|
||||
lockId: `lock-${namespace}-inventory-beta`,
|
||||
installationId: `install-${namespace}-inventory-beta`,
|
||||
mutationId: `mutation-${namespace}-inventory-beta`,
|
||||
});
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(alpha.lock, alpha.install, null),
|
||||
);
|
||||
await repository.create(
|
||||
pluginPackageInstallCreate(beta.lock, beta.install, null),
|
||||
);
|
||||
const failed = transitionPluginPackageInstall(alpha.lock, alpha.install, {
|
||||
type: 'failed',
|
||||
mutationId: `mutation-${namespace}-inventory-alpha-failed`,
|
||||
occurredAtMs: 202,
|
||||
reason: 'source_unavailable',
|
||||
});
|
||||
await repository.commit(pluginPackageInstallCommit(alpha.install, failed));
|
||||
|
||||
const first = await repository.listCurrentPage({
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
after: { packageName: namespace },
|
||||
});
|
||||
assert.deepEqual(first.items, [{ record: failed, quarantine: null }]);
|
||||
assert.equal(first.truncated, true);
|
||||
assert.deepEqual(first.next, { packageName: alpha.install.packageName });
|
||||
const second = await repository.listCurrentPage({
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
after: first.next,
|
||||
});
|
||||
assert.deepEqual(second.items, [
|
||||
{ record: beta.install, quarantine: null },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fixture,
|
||||
registerPluginPackageInstallRepositoryContract,
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackageBundle');
|
||||
const {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
materializePluginPackageResources,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('../../packages/ql3-runtime-core/dist/task-definition/taskSpecSemantic');
|
||||
|
||||
function materializedRevisionFixture(namespace, profile = 'edge') {
|
||||
const projectId = `project-${namespace}`;
|
||||
const packageName = `package-${namespace}`;
|
||||
const resourcePath = 'prompts/report.json';
|
||||
const resourceBytes = Buffer.from(
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
||||
id: 'report',
|
||||
name: 'Report',
|
||||
template: 'Hello {{name}}\n',
|
||||
parameters: [{ name: 'name', required: true }],
|
||||
}),
|
||||
);
|
||||
const sourceDigest = createHash('sha256').update(resourceBytes).digest('hex');
|
||||
const contentDigest = pluginPackageContentTreeDigest([
|
||||
{ path: resourcePath, bytes: resourceBytes.byteLength, digest: sourceDigest },
|
||||
]);
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: packageName,
|
||||
displayName: packageName,
|
||||
version: '1.0.0',
|
||||
description: 'One immutable semantic revision fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: [profile],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: {
|
||||
tasks: [],
|
||||
workflows: [],
|
||||
prompts: [resourcePath],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: profile,
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
const actionInput = {
|
||||
lockId: `lock-${namespace}`,
|
||||
projectId,
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2048,
|
||||
contentDigest,
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: profile,
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: `approval-${namespace}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${namespace}`,
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: `install-${namespace}`,
|
||||
projectId,
|
||||
packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const revision = materializePluginPackageResources({
|
||||
generation,
|
||||
lock,
|
||||
manifestBytes: Buffer.from(serializePluginPackageManifest(manifest)),
|
||||
resources: [
|
||||
{
|
||||
reference: generation.resources[0],
|
||||
bytes: resourceBytes,
|
||||
},
|
||||
],
|
||||
taskSpecSemanticRegistry: registry,
|
||||
});
|
||||
return { projectId, packageName, registry, revision };
|
||||
}
|
||||
|
||||
function registerPluginPackageMaterializedRevisionRepositoryContract(options) {
|
||||
test(`${options.name} publishes once and replays the exact revision`, async (t) => {
|
||||
const fixture = materializedRevisionFixture(
|
||||
options.namespace ?? 'materialized',
|
||||
options.profile,
|
||||
);
|
||||
const harness = await options.createRepository(t, fixture);
|
||||
t.after(() => harness.close?.());
|
||||
const first = await harness.repository.publish(fixture.revision);
|
||||
assert.equal(first.status, 'created');
|
||||
assert.deepEqual(first.revision, fixture.revision);
|
||||
const replay = await harness.repository.publish(fixture.revision);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.revision, fixture.revision);
|
||||
assert.deepEqual(
|
||||
await harness.repository.find(
|
||||
fixture.revision.generation.generationDigest,
|
||||
),
|
||||
fixture.revision,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${options.name} returns absence and rejects invalid lookup identity`, async (t) => {
|
||||
const fixture = materializedRevisionFixture(
|
||||
`${options.namespace ?? 'materialized'}-absence`,
|
||||
options.profile,
|
||||
);
|
||||
const harness = await options.createRepository(t, fixture);
|
||||
t.after(() => harness.close?.());
|
||||
assert.equal(await harness.repository.find('f'.repeat(64)), null);
|
||||
await assert.rejects(
|
||||
harness.repository.find('../generation'),
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
materializedRevisionFixture,
|
||||
registerPluginPackageMaterializedRevisionRepositoryContract,
|
||||
};
|
||||
@@ -0,0 +1,479 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
createPluginPackageInstall,
|
||||
pluginPackageActivationIntentDigest,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCommit,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackageBundle');
|
||||
const {
|
||||
createPluginPackagePublisherProvenance,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/publisher/pluginPackagePublisherProvenance');
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const {
|
||||
materializePluginPackageResources,
|
||||
} = require('../../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('../../packages/ql3-runtime-core/dist/task-definition/taskSpecSemantic');
|
||||
|
||||
function environment(profile) {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: profile,
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function taskValue(id, argument = id) {
|
||||
const command =
|
||||
typeof argument === 'string'
|
||||
? {
|
||||
kind: 'argv',
|
||||
file: '/usr/bin/printf',
|
||||
args: [argument],
|
||||
}
|
||||
: argument;
|
||||
return {
|
||||
schema: 'qinglong/plugin-package-task-resource@v1',
|
||||
id,
|
||||
name: `Task ${id}`,
|
||||
labels: { 'plugin.qinglong.io/source': 'contract' },
|
||||
enabled: true,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function activeRecord(lock, namespace, previousHead = null) {
|
||||
const queued = createPluginPackageInstall(lock, {
|
||||
installationId: `install-${namespace}-${lock.targetGeneration}`,
|
||||
mutationId: `create-${namespace}-${lock.targetGeneration}`,
|
||||
occurredAtMs: 1_000 + lock.targetGeneration * 10,
|
||||
});
|
||||
const staged = transitionPluginPackageInstall(lock, queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: `stage-${namespace}-${lock.targetGeneration}`,
|
||||
occurredAtMs: queued.updatedAtMs + 1,
|
||||
stageRef: `stage:${lock.lockDigest}`,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: createHash('sha256')
|
||||
.update(`evidence-${namespace}-${lock.targetGeneration}`)
|
||||
.digest('hex'),
|
||||
});
|
||||
const activating = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: `activate-${namespace}-${lock.targetGeneration}`,
|
||||
occurredAtMs: staged.updatedAtMs + 1,
|
||||
});
|
||||
const active = transitionPluginPackageInstall(lock, activating, {
|
||||
type: 'activation_committed',
|
||||
mutationId: `commit-${namespace}-${lock.targetGeneration}`,
|
||||
occurredAtMs: activating.updatedAtMs + 1,
|
||||
activationRef: `activation:${lock.lockDigest}`,
|
||||
intentDigest: pluginPackageActivationIntentDigest(lock, activating),
|
||||
generation: lock.targetGeneration,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
});
|
||||
return {
|
||||
queued,
|
||||
staged,
|
||||
activating,
|
||||
active,
|
||||
create: pluginPackageInstallCreate(lock, queued, previousHead),
|
||||
commits: [
|
||||
pluginPackageInstallCommit(queued, staged),
|
||||
pluginPackageInstallCommit(staged, activating),
|
||||
pluginPackageInstallCommit(activating, active),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(namespace, options = {}) {
|
||||
const profile = options.profile ?? 'edge';
|
||||
const previous = options.previous ?? null;
|
||||
const generation = previous ? previous.generation + 1 : 1;
|
||||
const packageName = `package-${namespace}`;
|
||||
const projectId = `project-${namespace}`;
|
||||
const tasks = options.tasks ?? [
|
||||
['alpha', 'alpha'],
|
||||
['beta', 'beta'],
|
||||
];
|
||||
const workflows = options.workflows ?? [];
|
||||
const prompts = options.prompts ?? [];
|
||||
const tools = options.tools ?? [];
|
||||
const taskResources = tasks.map(([id, argument]) => {
|
||||
const path = `tasks/${id}.json`;
|
||||
const bytes = Buffer.from(JSON.stringify(taskValue(id, argument)));
|
||||
return {
|
||||
reference: { kind: 'task', path },
|
||||
bytes,
|
||||
descriptor: {
|
||||
path,
|
||||
bytes: bytes.byteLength,
|
||||
digest: createHash('sha256').update(bytes).digest('hex'),
|
||||
},
|
||||
};
|
||||
});
|
||||
const toolResources = tools.map((definition, index) => {
|
||||
const path = `tools/tool-${index}.json`;
|
||||
const bytes = Buffer.from(
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/plugin-package-tool-resource@v1',
|
||||
definition,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
reference: { kind: 'tool', path },
|
||||
bytes,
|
||||
descriptor: {
|
||||
path,
|
||||
bytes: bytes.byteLength,
|
||||
digest: createHash('sha256').update(bytes).digest('hex'),
|
||||
},
|
||||
};
|
||||
});
|
||||
const workflowResources = workflows.map((value) => {
|
||||
const path = `workflows/${value.id}.json`;
|
||||
const bytes = Buffer.from(JSON.stringify(value));
|
||||
return {
|
||||
reference: { kind: 'workflow', path },
|
||||
bytes,
|
||||
descriptor: {
|
||||
path,
|
||||
bytes: bytes.byteLength,
|
||||
digest: createHash('sha256').update(bytes).digest('hex'),
|
||||
},
|
||||
};
|
||||
});
|
||||
const promptResources = prompts.map((value) => {
|
||||
const path = `prompts/${value.id}.json`;
|
||||
const bytes = Buffer.from(JSON.stringify(value));
|
||||
return {
|
||||
reference: { kind: 'prompt', path },
|
||||
bytes,
|
||||
descriptor: {
|
||||
path,
|
||||
bytes: bytes.byteLength,
|
||||
digest: createHash('sha256').update(bytes).digest('hex'),
|
||||
},
|
||||
};
|
||||
});
|
||||
const resources = [
|
||||
...taskResources,
|
||||
...workflowResources,
|
||||
...promptResources,
|
||||
...toolResources,
|
||||
].sort((left, right) =>
|
||||
left.reference.path.localeCompare(right.reference.path),
|
||||
);
|
||||
const contentDigest = pluginPackageContentTreeDigest(
|
||||
resources.map(({ descriptor }) => descriptor),
|
||||
);
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: packageName,
|
||||
displayName: packageName,
|
||||
version: `${generation}.0.0`,
|
||||
description: 'Task reconciliation contract package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: [profile],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [
|
||||
...new Set([
|
||||
'system.command',
|
||||
...tools.flatMap(
|
||||
({ requiredPermissions = [] }) => requiredPermissions,
|
||||
),
|
||||
]),
|
||||
].sort(),
|
||||
},
|
||||
contents: {
|
||||
tasks: taskResources.map(({ reference }) => reference.path),
|
||||
workflows: workflowResources.map(({ reference }) => reference.path),
|
||||
prompts: promptResources.map(({ reference }) => reference.path),
|
||||
tools: toolResources.map(({ reference }) => reference.path),
|
||||
},
|
||||
},
|
||||
};
|
||||
const installEnvironment = environment(profile);
|
||||
const plan = planPluginPackageInstall(
|
||||
manifest,
|
||||
installEnvironment,
|
||||
previous?.manifest,
|
||||
);
|
||||
const action = {
|
||||
lockId: `lock-${namespace}-${generation}`,
|
||||
projectId,
|
||||
manifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
...(previous ? { previousManifest: previous.manifest } : {}),
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${createHash('sha256')
|
||||
.update(`artifact-${namespace}-${generation}`)
|
||||
.digest('hex')}`,
|
||||
artifactDigest: createHash('sha256')
|
||||
.update(`artifact-${namespace}-${generation}`)
|
||||
.digest('hex'),
|
||||
artifactBytes: 2048,
|
||||
contentDigest,
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: profile,
|
||||
targetGeneration: generation,
|
||||
...(previous ? { previousLockDigest: previous.lock.lockDigest } : {}),
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: `approval-${namespace}-${generation}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${namespace}-${generation}`,
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 100_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200 + generation,
|
||||
});
|
||||
const generationRecord = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: `install-${namespace}-${generation}`,
|
||||
projectId,
|
||||
packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation,
|
||||
previousActiveLockDigest: previous?.lock.lockDigest ?? null,
|
||||
contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
const registry =
|
||||
previous?.registry ?? createBuiltInTaskSpecSemanticRegistry();
|
||||
const revision = materializePluginPackageResources({
|
||||
generation: generationRecord,
|
||||
lock,
|
||||
manifestBytes: Buffer.from(serializePluginPackageManifest(manifest)),
|
||||
resources: resources.map(({ reference, bytes }) => ({ reference, bytes })),
|
||||
taskSpecSemanticRegistry: registry,
|
||||
});
|
||||
const install = activeRecord(
|
||||
lock,
|
||||
namespace,
|
||||
previous?.install.active ?? null,
|
||||
);
|
||||
return {
|
||||
namespace,
|
||||
profile,
|
||||
projectId,
|
||||
packageName,
|
||||
generation,
|
||||
manifest,
|
||||
lock,
|
||||
revision,
|
||||
registry,
|
||||
install,
|
||||
manifestBytes: Buffer.from(serializePluginPackageManifest(manifest)),
|
||||
resourceEntries: resources.map(({ reference, bytes }) => ({
|
||||
reference,
|
||||
bytes,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function activateInstall(repository, value) {
|
||||
await repository.create(value.install.create);
|
||||
for (const command of value.install.commits) {
|
||||
await repository.commit(command);
|
||||
}
|
||||
}
|
||||
|
||||
function publisherProvenanceInstallRepository(repository, provenance) {
|
||||
return Object.freeze({
|
||||
find: (...args) => repository.find(...args),
|
||||
findLock: (...args) => repository.findLock(...args),
|
||||
create: (...args) => repository.create(...args),
|
||||
listRecoveryPage: (...args) => repository.listRecoveryPage(...args),
|
||||
async commit(command) {
|
||||
if (command.record.state !== 'staged') {
|
||||
return repository.commit(command);
|
||||
}
|
||||
const lock = await repository.findLock(command.record.lockDigest);
|
||||
assert.ok(lock);
|
||||
assert.ok(command.record.stageReceipt);
|
||||
return provenance.commitStage(
|
||||
command,
|
||||
createPluginPackagePublisherProvenance({
|
||||
projectId: command.record.projectId,
|
||||
packageName: command.record.packageName,
|
||||
installationId: command.record.installationId,
|
||||
lockDigest: command.record.lockDigest,
|
||||
artifactDigest: command.record.stageReceipt.artifactDigest,
|
||||
manifestDigest: command.record.stageReceipt.manifestDigest,
|
||||
contentDigest: command.record.stageReceipt.contentDigest,
|
||||
stageEvidenceDigest: command.record.stageReceipt.evidenceDigest,
|
||||
signature: {
|
||||
publisher: 'packages.contract.qinglong.dev',
|
||||
keyId: 'contract-key-1',
|
||||
signatureDigest: createHash('sha256')
|
||||
.update(`signature:${command.record.installationId}`)
|
||||
.digest('hex'),
|
||||
keyNotBeforeMs: 0,
|
||||
keyNotAfterMs: 100_000,
|
||||
verifiedAtMs: command.record.updatedAtMs,
|
||||
},
|
||||
}),
|
||||
'cluster',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function registerPluginPackageTaskReconciliationRepositoryContract(options) {
|
||||
test(`${options.name} reconciles one complete generation and exact replay`, async (t) => {
|
||||
const first = fixture(`${options.namespace}-create`, {
|
||||
profile: options.profile,
|
||||
});
|
||||
const harness = await options.createRepository(t, first);
|
||||
t.after(() => harness.close?.());
|
||||
await activateInstall(harness.installRepository, first);
|
||||
await harness.materializedRepository.publish(first.revision);
|
||||
assert.deepEqual(await harness.repository.listPendingPage({ limit: 1 }), {
|
||||
candidates: [
|
||||
{ projectId: first.projectId, packageName: first.packageName },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const source = {
|
||||
async findActiveResourceGeneration() {
|
||||
return first.revision.generation;
|
||||
},
|
||||
};
|
||||
const created = await harness.repository.reconcile(first.revision, source);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.deepEqual(
|
||||
created.receipt.items.map(({ disposition }) => disposition),
|
||||
['created', 'created'],
|
||||
);
|
||||
assert.equal(
|
||||
(await harness.repository.reconcile(first.revision, source)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await harness.repository.find(first.revision.generation.generationDigest),
|
||||
created.receipt,
|
||||
);
|
||||
assert.deepEqual(await harness.repository.listPendingPage({ limit: 1 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
await options.assertGenericWriteRejected?.(harness, first);
|
||||
});
|
||||
|
||||
test(`${options.name} retains, disables and creates as one next generation`, async (t) => {
|
||||
const first = fixture(`${options.namespace}-upgrade`, {
|
||||
profile: options.profile,
|
||||
});
|
||||
const harness = await options.createRepository(t, first);
|
||||
t.after(() => harness.close?.());
|
||||
await activateInstall(harness.installRepository, first);
|
||||
await harness.materializedRepository.publish(first.revision);
|
||||
await harness.repository.reconcile(first.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return first.revision.generation;
|
||||
},
|
||||
});
|
||||
|
||||
const second = fixture(first.namespace, {
|
||||
profile: options.profile,
|
||||
previous: first,
|
||||
tasks: [
|
||||
['alpha', 'alpha'],
|
||||
['gamma', 'gamma'],
|
||||
],
|
||||
});
|
||||
await activateInstall(harness.installRepository, second);
|
||||
await harness.materializedRepository.publish(second.revision);
|
||||
const reconciled = await harness.repository.reconcile(second.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return second.revision.generation;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
reconciled.receipt.items.map(({ taskId, revision, disposition }) => ({
|
||||
taskId,
|
||||
revision,
|
||||
disposition,
|
||||
})),
|
||||
[
|
||||
{
|
||||
taskId: `pkg:${first.packageName}:alpha`,
|
||||
revision: 1,
|
||||
disposition: 'retained',
|
||||
},
|
||||
{
|
||||
taskId: `pkg:${first.packageName}:beta`,
|
||||
revision: 2,
|
||||
disposition: 'disabled',
|
||||
},
|
||||
{
|
||||
taskId: `pkg:${first.packageName}:gamma`,
|
||||
revision: 1,
|
||||
disposition: 'created',
|
||||
},
|
||||
],
|
||||
);
|
||||
await options.assertDurableUpgrade?.(harness, second);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture: fixture,
|
||||
publisherProvenanceInstallRepository,
|
||||
registerPluginPackageTaskReconciliationRepositoryContract,
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
InvalidProjectToolDefinitionSnapshotError,
|
||||
ProjectToolDefinitionSnapshotConflictError,
|
||||
} = require('../../packages/ql3-runtime-core/dist/tool-execution/tool-registry/projectToolDefinitionSnapshot');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('../../packages/ql3-runtime-core/dist/task-definition/taskSpecSemantic');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('./pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
function snapshotFor(value) {
|
||||
return createProjectToolDefinitionSnapshot({
|
||||
projectId: value.projectId,
|
||||
contributions: [
|
||||
projectToolDefinitionSnapshotContribution(value.revision, value.registry),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function pendingProjectIds(repository) {
|
||||
const projects = [];
|
||||
let after;
|
||||
for (let pageNumber = 0; pageNumber < 8; pageNumber += 1) {
|
||||
const page = await repository.listPendingProjectPage({
|
||||
limit: 64,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
projects.push(...page.projectIds);
|
||||
if (!page.truncated) return projects;
|
||||
after = page.next;
|
||||
}
|
||||
throw new Error('snapshot pending Project contract did not converge');
|
||||
}
|
||||
|
||||
function registerProjectToolDefinitionSnapshotRepositoryContract(options) {
|
||||
test(`${options.name}: publishes and exactly replays an empty active vector`, async (t) => {
|
||||
const projectId = `${options.namespace}-empty`;
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const harness = await options.createRepository(t, { projectId, registry });
|
||||
t.after(() => harness.close?.());
|
||||
const snapshot = createProjectToolDefinitionSnapshot({
|
||||
projectId,
|
||||
contributions: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await harness.repository.listActiveSourcePage({
|
||||
projectId,
|
||||
limit: 1,
|
||||
}),
|
||||
{ sources: [], truncated: false },
|
||||
);
|
||||
assert.equal(
|
||||
(await pendingProjectIds(harness.repository)).includes(projectId),
|
||||
true,
|
||||
);
|
||||
assert.equal(await harness.repository.findCurrent(projectId), null);
|
||||
const created = await harness.repository.publish(snapshot);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.deepEqual(created.record.snapshot, snapshot);
|
||||
assert.ok(Number.isSafeInteger(created.record.committedAtMs));
|
||||
const replay = await harness.repository.publish(snapshot);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.record, created.record);
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(projectId),
|
||||
created.record,
|
||||
);
|
||||
assert.equal(
|
||||
(await pendingProjectIds(harness.repository)).includes(projectId),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${options.name}: binds one active Package to its immutable revision`, async (t) => {
|
||||
const value = pluginPackageTaskReconciliationFixture(
|
||||
`${options.namespace}-active`,
|
||||
{ profile: options.profile },
|
||||
);
|
||||
const harness = await options.createRepository(t, value);
|
||||
t.after(() => harness.close?.());
|
||||
await activateInstall(harness.installRepository, value);
|
||||
await harness.materializedRepository.publish(value.revision);
|
||||
const snapshot = snapshotFor(value);
|
||||
|
||||
const sourcePage = await harness.repository.listActiveSourcePage({
|
||||
projectId: value.projectId,
|
||||
limit: 1,
|
||||
});
|
||||
assert.deepEqual(sourcePage, {
|
||||
sources: snapshot.sources,
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(
|
||||
(await pendingProjectIds(harness.repository)).includes(value.projectId),
|
||||
true,
|
||||
);
|
||||
const created = await harness.repository.publish(snapshot);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(value.projectId),
|
||||
created.record,
|
||||
);
|
||||
assert.deepEqual(created.record.snapshot.sources, [
|
||||
{
|
||||
installationId: value.install.active.installationId,
|
||||
packageName: value.packageName,
|
||||
generation: value.generation,
|
||||
generationDigest: value.revision.generation.generationDigest,
|
||||
lockDigest: value.lock.lockDigest,
|
||||
revisionDigest: value.revision.revisionDigest,
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(await pendingProjectIds(harness.repository)).includes(value.projectId),
|
||||
false,
|
||||
);
|
||||
await options.assertDurableSource?.(harness, value, created.record);
|
||||
});
|
||||
|
||||
test(`${options.name}: keeps the old snapshot during staging and fails closed after activation`, async (t) => {
|
||||
const first = pluginPackageTaskReconciliationFixture(
|
||||
`${options.namespace}-upgrade`,
|
||||
{ profile: options.profile },
|
||||
);
|
||||
const harness = await options.createRepository(t, first);
|
||||
t.after(() => harness.close?.());
|
||||
await activateInstall(harness.installRepository, first);
|
||||
await harness.materializedRepository.publish(first.revision);
|
||||
const firstSnapshot = snapshotFor(first);
|
||||
const firstRecord = (await harness.repository.publish(firstSnapshot))
|
||||
.record;
|
||||
|
||||
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
|
||||
profile: options.profile,
|
||||
previous: first,
|
||||
tasks: [['alpha', 'next']],
|
||||
});
|
||||
await harness.materializedRepository.publish(second.revision);
|
||||
await harness.installRepository.create(second.install.create);
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(first.projectId),
|
||||
firstRecord,
|
||||
);
|
||||
await harness.installRepository.commit(second.install.commits[0]);
|
||||
await harness.installRepository.commit(second.install.commits[1]);
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(first.projectId),
|
||||
firstRecord,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await harness.repository.listActiveSourcePage({
|
||||
projectId: first.projectId,
|
||||
limit: 1,
|
||||
})
|
||||
).sources,
|
||||
firstRecord.snapshot.sources,
|
||||
);
|
||||
await harness.installRepository.commit(second.install.commits[2]);
|
||||
|
||||
assert.equal(await harness.repository.findCurrent(first.projectId), null);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await harness.repository.listActiveSourcePage({
|
||||
projectId: first.projectId,
|
||||
limit: 1,
|
||||
})
|
||||
).sources,
|
||||
secondSnapshotSources(second),
|
||||
);
|
||||
assert.equal(
|
||||
(await pendingProjectIds(harness.repository)).includes(first.projectId),
|
||||
true,
|
||||
);
|
||||
await assert.rejects(
|
||||
harness.repository.publish(firstSnapshot),
|
||||
ProjectToolDefinitionSnapshotConflictError,
|
||||
);
|
||||
|
||||
const secondSnapshot = snapshotFor(second);
|
||||
const secondRecord = (await harness.repository.publish(secondSnapshot))
|
||||
.record;
|
||||
assert.deepEqual(
|
||||
await harness.repository.findCurrent(first.projectId),
|
||||
secondRecord,
|
||||
);
|
||||
assert.notEqual(
|
||||
firstRecord.snapshot.activeVectorDigest,
|
||||
secondRecord.snapshot.activeVectorDigest,
|
||||
);
|
||||
assert.equal(
|
||||
(await pendingProjectIds(harness.repository)).includes(first.projectId),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${options.name}: rejects invalid Project identity`, async (t) => {
|
||||
const projectId = `${options.namespace}-invalid`;
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const harness = await options.createRepository(t, { projectId, registry });
|
||||
t.after(() => harness.close?.());
|
||||
await assert.rejects(
|
||||
Promise.resolve().then(() =>
|
||||
harness.repository.findCurrent('project\0invalid'),
|
||||
),
|
||||
InvalidProjectToolDefinitionSnapshotError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function secondSnapshotSources(value) {
|
||||
return snapshotFor(value).sources;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerProjectToolDefinitionSnapshotRepositoryContract,
|
||||
projectToolDefinitionSnapshotForFixture: snapshotFor,
|
||||
};
|
||||
@@ -0,0 +1,392 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
DuplicateIdempotencyKeyError,
|
||||
DuplicateRunAttemptError,
|
||||
DuplicateRunEventError,
|
||||
RunEventPayloadTooLargeError,
|
||||
} = require('../../back/runtime/domain/repositoryErrors');
|
||||
const {
|
||||
MAX_CANCELLATION_RECOVERY_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAYLOAD_BYTES,
|
||||
} = require('../../back/runtime/ports/runRepository');
|
||||
|
||||
const legacyRepositoryContract = Object.freeze({
|
||||
DuplicateIdempotencyKeyError,
|
||||
DuplicateRunAttemptError,
|
||||
DuplicateRunEventError,
|
||||
RunEventPayloadTooLargeError,
|
||||
MAX_CANCELLATION_RECOVERY_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAYLOAD_BYTES,
|
||||
});
|
||||
|
||||
function registerRunRepositoryContract({
|
||||
name,
|
||||
createRepository,
|
||||
defaultExecutionOwner = 'legacy',
|
||||
contract = legacyRepositoryContract,
|
||||
}) {
|
||||
const {
|
||||
DuplicateIdempotencyKeyError,
|
||||
DuplicateRunAttemptError,
|
||||
DuplicateRunEventError,
|
||||
RunEventPayloadTooLargeError,
|
||||
MAX_CANCELLATION_RECOVERY_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAYLOAD_BYTES,
|
||||
} = contract;
|
||||
let idSequence = 100;
|
||||
|
||||
function nextId() {
|
||||
idSequence += 1;
|
||||
return `019f70b0-0000-7000-8000-${String(idSequence).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
function createRun(overrides = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
projectId: 'default',
|
||||
taskId: 'legacy-cron:1',
|
||||
taskRevision: 'revision-1',
|
||||
taskName: 'test cron',
|
||||
legacyCronId: 1,
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: defaultExecutionOwner,
|
||||
triggeredBy: 'user:1',
|
||||
status: 'created',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1_750_000_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAttempt(runId, overrides = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
runId,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'legacy_local',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 1_750_000_000_001,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createEvent(runId, overrides = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
runId,
|
||||
sequence: 1,
|
||||
type: 'run.created',
|
||||
dedupeKey: 'run.created',
|
||||
actorType: 'compatibility',
|
||||
payload: { source: 'repository-contract-test' },
|
||||
createdAtMs: 1_750_000_000_002,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRetryPolicy(runId, overrides = {}) {
|
||||
return {
|
||||
runId,
|
||||
maxAttempts: 3,
|
||||
retryOnLost: true,
|
||||
safety: 'idempotent',
|
||||
backoffBaseMs: 1_000,
|
||||
backoffMaxMs: 30_000,
|
||||
version: 0,
|
||||
createdAtMs: 1_750_000_000_002,
|
||||
updatedAtMs: 1_750_000_000_002,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t) {
|
||||
const harness = await createRepository();
|
||||
if (harness.close) {
|
||||
t.after(() => harness.close());
|
||||
}
|
||||
return harness.repository;
|
||||
}
|
||||
|
||||
test(`${name}: persists a Run aggregate atomically and returns plain records`, async (t) => {
|
||||
const repository = await setup(t);
|
||||
const run = createRun({ idempotencyKey: 'manual-request-1' });
|
||||
const attempt = createAttempt(run.id, {
|
||||
deadlineAtMs: 1_750_000_030_000,
|
||||
});
|
||||
const event = createEvent(run.id, { attemptId: attempt.id });
|
||||
|
||||
await repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run);
|
||||
await transaction.insertAttempt(attempt);
|
||||
await transaction.appendEvent(event);
|
||||
|
||||
assert.deepEqual(await transaction.findRunById(run.id), run);
|
||||
assert.deepEqual(await transaction.findAttemptById(attempt.id), attempt);
|
||||
});
|
||||
|
||||
assert.deepEqual(await repository.findRunById(run.id), run);
|
||||
assert.deepEqual(await repository.findAttemptById(attempt.id), attempt);
|
||||
assert.deepEqual(await repository.listEvents(run.id), [event]);
|
||||
});
|
||||
|
||||
test(`${name}: rolls back every aggregate write when a transaction fails`, async (t) => {
|
||||
const repository = await setup(t);
|
||||
const run = createRun();
|
||||
const attempt = createAttempt(run.id);
|
||||
|
||||
await assert.rejects(
|
||||
repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run);
|
||||
await transaction.insertAttempt(attempt);
|
||||
throw new Error('force rollback');
|
||||
}),
|
||||
/force rollback/,
|
||||
);
|
||||
|
||||
assert.equal(await repository.findRunById(run.id), null);
|
||||
assert.equal(await repository.findAttemptById(attempt.id), null);
|
||||
});
|
||||
|
||||
test(`${name}: enforces Run and Attempt compare-and-set predicates`, async (t) => {
|
||||
const repository = await setup(t);
|
||||
const run = createRun();
|
||||
const attempt = createAttempt(run.id);
|
||||
await repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run);
|
||||
await transaction.insertAttempt(attempt);
|
||||
});
|
||||
|
||||
const updatedRun = {
|
||||
...run,
|
||||
status: 'queued',
|
||||
version: 1,
|
||||
queuedAtMs: 1_750_000_000_003,
|
||||
};
|
||||
const updatedAttempt = {
|
||||
...attempt,
|
||||
status: 'starting',
|
||||
callbackSequence: 1,
|
||||
startedAtMs: 1_750_000_000_004,
|
||||
};
|
||||
await repository.transaction(async (transaction) => {
|
||||
assert.equal(await transaction.compareAndSetRun(updatedRun, 0), true);
|
||||
assert.equal(await transaction.compareAndSetRun(updatedRun, 0), false);
|
||||
assert.equal(
|
||||
await transaction.compareAndSetAttempt(updatedAttempt, {
|
||||
status: 'claimed',
|
||||
callbackSequence: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await transaction.compareAndSetAttempt(updatedAttempt, {
|
||||
status: 'claimed',
|
||||
callbackSequence: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(await repository.findRunById(run.id), updatedRun);
|
||||
assert.deepEqual(
|
||||
await repository.findAttemptById(attempt.id),
|
||||
updatedAttempt,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: maps stable uniqueness violations to domain errors`, async (t) => {
|
||||
const repository = await setup(t);
|
||||
const run = createRun({ idempotencyKey: 'manual-request-2' });
|
||||
const attempt = createAttempt(run.id);
|
||||
const event = createEvent(run.id);
|
||||
|
||||
await repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run);
|
||||
await transaction.insertAttempt(attempt);
|
||||
await transaction.appendEvent(event);
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
repository.transaction((transaction) =>
|
||||
transaction.insertRun(
|
||||
createRun({ idempotencyKey: 'manual-request-2' }),
|
||||
),
|
||||
),
|
||||
DuplicateIdempotencyKeyError,
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.transaction((transaction) =>
|
||||
transaction.insertAttempt(
|
||||
createAttempt(run.id, { attempt: attempt.attempt }),
|
||||
),
|
||||
),
|
||||
DuplicateRunAttemptError,
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.transaction((transaction) =>
|
||||
transaction.appendEvent(
|
||||
createEvent(run.id, {
|
||||
sequence: 2,
|
||||
dedupeKey: event.dedupeKey,
|
||||
}),
|
||||
),
|
||||
),
|
||||
DuplicateRunEventError,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: persists and compare-and-sets the admitted retry policy`, async (t) => {
|
||||
const repository = await setup(t);
|
||||
const run = createRun();
|
||||
const policy = createRetryPolicy(run.id);
|
||||
await repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run);
|
||||
await transaction.insertRetryPolicy(policy);
|
||||
assert.deepEqual(
|
||||
await transaction.findRetryPolicyByRunId(run.id),
|
||||
policy,
|
||||
);
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...policy,
|
||||
nextAttemptAtMs: 1_750_000_010_000,
|
||||
version: 1,
|
||||
updatedAtMs: 1_750_000_000_003,
|
||||
};
|
||||
await repository.transaction(async (transaction) => {
|
||||
assert.equal(
|
||||
await transaction.compareAndSetRetryPolicy(updated, 0),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await transaction.compareAndSetRetryPolicy(updated, 0),
|
||||
false,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await repository.findRetryPolicyByRunId(run.id), updated);
|
||||
|
||||
await assert.rejects(
|
||||
repository.transaction((transaction) =>
|
||||
transaction.compareAndSetRetryPolicy(
|
||||
{ ...updated, version: 3, updatedAtMs: 1_750_000_000_004 },
|
||||
1,
|
||||
),
|
||||
),
|
||||
(error) => error.code === 'RUN_REPOSITORY_CONSTRAINT',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: bounds event pages and rejects oversized payloads`, async (t) => {
|
||||
const repository = await setup(t);
|
||||
const run = createRun();
|
||||
|
||||
await repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run);
|
||||
await transaction.appendEvent(createEvent(run.id));
|
||||
await transaction.appendEvent(
|
||||
createEvent(run.id, {
|
||||
sequence: 2,
|
||||
dedupeKey: 'run.queued',
|
||||
type: 'run.queued',
|
||||
}),
|
||||
);
|
||||
await transaction.appendEvent(
|
||||
createEvent(run.id, {
|
||||
sequence: 3,
|
||||
dedupeKey: 'attempt.claimed',
|
||||
type: 'attempt.claimed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const page = await repository.listEvents(run.id, {
|
||||
afterSequence: 1,
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
page.map((event) => event.sequence),
|
||||
[2, 3],
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.listEvents(run.id, { limit: MAX_RUN_EVENT_PAGE_SIZE + 1 }),
|
||||
RangeError,
|
||||
);
|
||||
|
||||
const oversized = createEvent(run.id, {
|
||||
sequence: 4,
|
||||
dedupeKey: 'oversized',
|
||||
payload: { value: 'x'.repeat(MAX_RUN_EVENT_PAYLOAD_BYTES) },
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.transaction((transaction) =>
|
||||
transaction.appendEvent(oversized),
|
||||
),
|
||||
RunEventPayloadTooLargeError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await repository.listEvents(run.id)).map((event) => event.sequence),
|
||||
[1, 2, 3],
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: returns bounded cancellation requests in recovery order`, async (t) => {
|
||||
const repository = await setup(t);
|
||||
const earlier = createRun({
|
||||
status: 'running',
|
||||
cancelRequestedAtMs: 1_750_000_000_010,
|
||||
cancelReason: 'user',
|
||||
});
|
||||
const later = createRun({
|
||||
status: 'dispatching',
|
||||
cancelRequestedAtMs: 1_750_000_000_020,
|
||||
cancelReason: 'shutdown',
|
||||
});
|
||||
const terminal = createRun({
|
||||
status: 'cancelled',
|
||||
cancelRequestedAtMs: 1_750_000_000_005,
|
||||
cancelReason: 'policy',
|
||||
finishedAtMs: 1_750_000_000_006,
|
||||
});
|
||||
const untouched = createRun({ status: 'running' });
|
||||
await repository.transaction(async (transaction) => {
|
||||
for (const run of [later, terminal, untouched, earlier]) {
|
||||
await transaction.insertRun(run);
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
(await repository.listCancellationRequested()).map((run) => run.id),
|
||||
[earlier.id, later.id],
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await repository.listCancellationRequested({
|
||||
beforeMs: 1_750_000_000_015,
|
||||
limit: 1,
|
||||
})
|
||||
).map((run) => run.id),
|
||||
[earlier.id],
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.listCancellationRequested({ beforeMs: -1 }),
|
||||
RangeError,
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.listCancellationRequested({
|
||||
limit: MAX_CANCELLATION_RECOVERY_PAGE_SIZE + 1,
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerRunRepositoryContract };
|
||||
Reference in New Issue
Block a user