feat(ql3): add bounded legacy panel cron adapter

This commit is contained in:
whyour
2026-09-02 06:18:44 +08:00
parent d6595912a2
commit 15d970be41
12 changed files with 691 additions and 17 deletions
@@ -303,6 +303,17 @@ function fixture(overrides = {}) {
return { statusCode: 201, body: { status: 'inserted' } };
},
},
panelCronListRoute: {
async handle(value) {
events.push(
`panel-crons:${value.projectId}:${value.page}:${value.size}:${value.maximumRows}`,
);
return {
statusCode: 200,
body: { code: 200, data: { data: [], total: 0 } },
};
},
},
now: () => 10_000,
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
...overrides,
@@ -508,6 +519,35 @@ test('uses task.read with a route-owned task.get audit identity', async () => {
]);
});
test('uses task.read and the exact panel Cron audit identity for the compatibility projection', async () => {
const { admission, events } = fixture();
assert.deepEqual(
await execute(
admission,
request({
operation: Object.freeze({
operationId: 'panel.cron.list',
projectId: 'default',
page: 1,
size: 20,
maximumRows: 64,
}),
}),
),
{
statusCode: 200,
body: { code: 200, data: { data: [], total: 0 } },
},
);
assert.deepEqual(events, [
'authenticate',
'authorize:task.read:default',
'audit:allowed:panel.cron.list',
'confirm',
'panel-crons:default:1:20:64',
]);
});
test('authorizes and audits run.stop before exposing the cancellation body handler', async () => {
const { admission, events } = fixture();
const prepared = await admission.prepare(
@@ -152,6 +152,16 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
body: { secrets: [], truncated: false },
};
}
if (value.operation.operationId === 'panel.cron.list') {
return {
statusCode: 200,
body: {
code: 200,
data: { data: [], total: 0 },
input: value.operation,
},
};
}
return {
statusCode: 200,
body: { runs: [], hasMore: false, input: value.operation.input },
@@ -249,6 +259,34 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
taskId: 'task_1',
});
const panelCrons = await request(
port,
'/api/crons?searchValue=&page=1&size=20&filters=%7B%7D&t=100',
);
assert.equal(panelCrons.statusCode, 200);
assert.deepEqual(panelCrons.body, {
code: 200,
data: { data: [], total: 0 },
input: {
operationId: 'panel.cron.list',
projectId: 'default',
page: 1,
size: 20,
maximumRows: 64,
},
});
assert.deepEqual(observed[6].operation, panelCrons.body.input);
const unsupportedPanelQuery = await request(
port,
'/api/crons?searchValue=private&page=1&size=20&filters=%7B%7D',
);
assert.deepEqual(unsupportedPanelQuery.body, {
code: 'invalid_panel_cron_list_query',
});
assert.equal(unsupportedPanelQuery.statusCode, 400);
assert.equal(observed.length, 7);
const cancellationBody = JSON.stringify({
schema: 'qinglong/run-cancellation@v1',
mutationId: 'mutation-1',
@@ -268,7 +306,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
);
assert.equal(cancellation.statusCode, 202);
assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
assert.deepEqual(observed[6].operation, {
assert.deepEqual(observed[7].operation, {
operationId: 'run.cancel',
projectId: 'prj_default',
runId: 'run_123',
@@ -295,7 +333,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
);
assert.equal(taskStart.statusCode, 202);
assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
assert.deepEqual(observed[7].operation, {
assert.deepEqual(observed[8].operation, {
operationId: 'task.start',
projectId: 'prj_default',
taskId: 'task_1',
@@ -319,20 +357,20 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
);
assert.equal(taskPut.statusCode, 202);
assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
assert.deepEqual(observed[8].operation, {
assert.deepEqual(observed[9].operation, {
operationId: 'task.put',
projectId: 'prj_default',
taskId: 'task_1',
});
assert.equal(observed[8].localPresence, 'ql3p_request_bound_proof');
assert.equal(observed[8].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof');
assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
const log = await request(
port,
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32',
);
assert.deepEqual(log.body, { range: { offset: 4, length: 32 } });
assert.deepEqual(observed[9].operation, {
assert.deepEqual(observed[10].operation, {
operationId: 'run.log.read',
projectId: 'prj_default',
runId: 'run_123',
@@ -361,19 +399,19 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
},
);
assert.equal(authoring.statusCode, 200);
assert.deepEqual(observed[11].operation, {
assert.deepEqual(observed[12].operation, {
operationId: 'task.authoring',
projectId: 'prj_default',
taskId: 'task_1',
});
assert.equal(observed[11].localPresence, 'ql3p_authoring_read_proof');
assert.equal(observed[12].localPresence, 'ql3p_authoring_read_proof');
const secrets = await request(
port,
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
);
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
assert.deepEqual(observed[12].operation, {
assert.deepEqual(observed[13].operation, {
operationId: 'secret.list',
projectId: 'prj_default',
limit: 8,
@@ -397,11 +435,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
);
assert.equal(secretPut.statusCode, 202);
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
assert.deepEqual(observed[13].operation, {
assert.deepEqual(observed[14].operation, {
operationId: 'secret.put',
projectId: 'prj_default',
});
assert.equal(observed[13].localPresence, 'ql3p_secret_bound_proof');
assert.equal(observed[14].localPresence, 'ql3p_secret_bound_proof');
for (const invalidPath of [
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
@@ -483,7 +521,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
}
assert.equal(observed.length, 14);
assert.equal(observed.length, 15);
assert.deepEqual(
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
['stopped', 'stopped'],
@@ -0,0 +1,245 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
createPanelCronListRoute,
} = require('../dist/panel-compatibility/panelCronListRoute.js');
const {
createTaskDefinitionRecord,
} = require('@qinglong/runtime-core/task-definition');
const {
createTriggerRecord,
createBuiltInTriggerSpecSemanticRegistry,
} = require('@qinglong/runtime-core/trigger');
function task(taskId, revision = 2, enabled = true) {
return createTaskDefinitionRecord(
{
projectId: 'default',
taskId,
expectedRevision: revision - 1,
mutationId: `019f7300-0000-4000-8000-${String(revision).padStart(
12,
'0',
)}`,
name: `Task ${taskId}`,
kind: 'command',
spec: {
schema: 'qinglong/command@v1',
config: { command: ['/bin/private'] },
},
labels: { private: 'redacted' },
enabled,
occurredAtMs: 200,
},
100,
);
}
function trigger(taskRecord, triggerId, enabled = true) {
const semantics = createBuiltInTriggerSpecSemanticRegistry();
const spec = semantics.normalize({
projectId: 'default',
triggerId,
taskId: taskRecord.taskId,
taskRevision: taskRecord.revision,
spec: {
schema: 'qinglong/cron@v1',
config: {
expression: '0 * * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
},
},
});
return createTriggerRecord(
{
projectId: 'default',
triggerId,
expectedRevision: null,
mutationId: `019f7300-0000-4000-8001-${
triggerId.endsWith('b') ? '000000000002' : '000000000001'
}`,
taskId: taskRecord.taskId,
taskRevision: taskRecord.revision,
taskContentDigest: taskRecord.contentDigest,
spec,
enabled,
occurredAtMs: 400,
},
300,
);
}
test('projects one bounded page of pinned QL3 cron triggers into the legacy panel envelope', async () => {
const taskA = task('task-a');
const taskB = task('task-b', 3, false);
const triggers = [
trigger(taskA, 'cron:task-a'),
trigger(taskB, 'cron:task-b'),
];
const calls = [];
const route = createPanelCronListRoute({
tasks: {
async findTaskDefinitionRevision(projectId, taskId, revision) {
calls.push(['task', projectId, taskId, revision]);
return [taskA, taskB].find(
(entry) => entry.taskId === taskId && entry.revision === revision,
);
},
},
triggers: {
async listTriggers(input) {
calls.push(['trigger', input]);
return {
triggers: triggers.slice(0, input.limit),
truncated: triggers.length > input.limit,
...(triggers.length > input.limit
? { next: { triggerId: triggers[input.limit - 1].triggerId } }
: {}),
};
},
},
});
const first = await route.handle({
projectId: 'default',
page: 1,
size: 1,
maximumRows: 64,
});
assert.equal(first.statusCode, 200);
assert.equal(first.body.code, 200);
assert.equal(first.body.data.total, 2);
assert.deepEqual(first.body.data.data, [
{
id: 'cron:task-a',
name: 'Task task-a',
command: 'ql3:command:task-a@2',
schedule: '0 * * * *',
extra_schedules: [],
status: 1,
isDisabled: 0,
isPinned: 0,
createdAt: new Date(300).toISOString(),
updatedAt: new Date(400).toISOString(),
ql3: {
projectId: 'default',
taskId: 'task-a',
taskRevision: 2,
triggerId: 'cron:task-a',
triggerRevision: 1,
timezone: 'UTC',
misfirePolicy: 'skip',
readOnly: true,
},
},
]);
const second = await route.handle({
projectId: 'default',
page: 2,
size: 1,
maximumRows: 64,
});
assert.equal(second.body.data.total, 2);
assert.equal(second.body.data.data[0].id, 'cron:task-b');
assert.equal(second.body.data.data[0].status, 2);
assert.equal(second.body.data.data[0].isDisabled, 1);
assert.deepEqual(calls[0], ['trigger', { projectId: 'default', limit: 1 }]);
assert.deepEqual(calls[2], ['trigger', { projectId: 'default', limit: 2 }]);
const afterEnd = await route.handle({
projectId: 'default',
page: 3,
size: 1,
maximumRows: 64,
});
assert.deepEqual(afterEnd.body.data, { data: [], total: 2 });
});
test('fails closed for detached pins, unsupported triggers, invalid budgets and unavailable storage', async () => {
const pinned = task('task-a');
const cron = trigger(pinned, 'cron:task-a');
for (const sources of [
{
tasks: {
async findTaskDefinitionRevision() {
return null;
},
},
triggers: {
async listTriggers() {
return { triggers: [cron], truncated: false };
},
},
},
{
tasks: {
async findTaskDefinitionRevision() {
return pinned;
},
},
triggers: {
async listTriggers() {
return {
triggers: [
{ ...cron, spec: { schema: 'vendor/event@v1', config: {} } },
],
truncated: false,
};
},
},
},
{
tasks: {
async findTaskDefinitionRevision() {
return pinned;
},
},
triggers: {
async listTriggers() {
throw new Error('offline');
},
},
},
]) {
const route = createPanelCronListRoute(sources);
assert.deepEqual(
await route.handle({
projectId: 'default',
page: 1,
size: 1,
maximumRows: 64,
}),
{
statusCode: 503,
body: { code: 503, message: 'QL3 面板兼容视图暂不可用' },
},
);
}
const route = createPanelCronListRoute({
tasks: {
async findTaskDefinitionRevision() {
return pinned;
},
},
triggers: {
async listTriggers() {
return { triggers: [], truncated: false };
},
},
});
assert.equal(
(
await route.handle({
projectId: 'default',
page: 5,
size: 20,
maximumRows: 64,
})
).statusCode,
400,
);
assert.throws(() => createPanelCronListRoute({}), TypeError);
});
@@ -836,6 +836,46 @@ test('serves an authenticated Run through one real SQLite authority and durable
assert.equal(triggerList.body.triggers[0].triggerId, 'cron:task-1');
assert.equal(triggerList.body.triggers[0].spec, undefined);
const panelCrons = await request(
port,
`Bearer ${TOKEN}`,
'/api/crons?searchValue=&page=1&size=20&filters=%7B%7D',
);
assert.equal(panelCrons.statusCode, 200, JSON.stringify(panelCrons));
assert.equal(panelCrons.body.code, 200);
assert.equal(panelCrons.body.data.total, 1);
assert.equal(panelCrons.body.data.data.length, 1);
assert.deepEqual(
{
id: panelCrons.body.data.data[0].id,
name: panelCrons.body.data.data[0].name,
command: panelCrons.body.data.data[0].command,
schedule: panelCrons.body.data.data[0].schedule,
status: panelCrons.body.data.data[0].status,
isDisabled: panelCrons.body.data.data[0].isDisabled,
ql3: panelCrons.body.data.data[0].ql3,
},
{
id: 'cron:task-1',
name: 'Local API Task updated',
command: 'ql3:command:task-1@2',
schedule: '0 * * * *',
status: 1,
isDisabled: 0,
ql3: {
projectId: 'default',
taskId: 'task-1',
taskRevision: 2,
triggerId: 'cron:task-1',
triggerRevision: 1,
timezone: 'UTC',
misfirePolicy: 'skip',
readOnly: true,
},
},
);
assert.equal(JSON.stringify(panelCrons).includes('/bin/echo'), false);
const triggerRead = await request(port, `Bearer ${TOKEN}`, triggerPath);
assert.equal(triggerRead.statusCode, 200);
assert.deepEqual(triggerRead.body.trigger.spec, {