mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): compare bounded task run outcomes
This commit is contained in:
@@ -205,6 +205,11 @@
|
||||
"require": "./dist/tool-execution/localMcpReadDatabase.js",
|
||||
"default": "./dist/tool-execution/localMcpReadDatabase.js"
|
||||
},
|
||||
"./task-run-outcome-window": {
|
||||
"types": "./dist/run/outcome-comparison/taskRunOutcomeWindowReader.d.ts",
|
||||
"require": "./dist/run/outcome-comparison/taskRunOutcomeWindowReader.js",
|
||||
"default": "./dist/run/outcome-comparison/taskRunOutcomeWindowReader.js"
|
||||
},
|
||||
"./approved-action": {
|
||||
"types": "./dist/approved-action/approvalRequestRepository.d.ts",
|
||||
"require": "./dist/approved-action/approvalRequestRepository.js",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { RUN_STATUSES } from '@qinglong/runtime-core/run';
|
||||
import {
|
||||
normalizeTaskRunOutcomeWindowQuery,
|
||||
type TaskRunOutcomeWindowQuery,
|
||||
type TaskRunOutcomeWindowReader,
|
||||
type TaskRunOutcomeWindowRecord,
|
||||
} from '@qinglong/runtime-core/task-run-outcome-window';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
queryRows,
|
||||
requiredEnum,
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
} from '../runPersistence';
|
||||
|
||||
export class LocalSqliteTaskRunOutcomeWindowReader
|
||||
implements TaskRunOutcomeWindowReader
|
||||
{
|
||||
constructor(private readonly client: DatabaseSync) {}
|
||||
|
||||
async listRecentRunsByTask(
|
||||
value: Readonly<TaskRunOutcomeWindowQuery>,
|
||||
): Promise<readonly Readonly<TaskRunOutcomeWindowRecord>[]> {
|
||||
const query = normalizeTaskRunOutcomeWindowQuery(value);
|
||||
return queryRows(
|
||||
this.client,
|
||||
`SELECT
|
||||
"id" AS "id",
|
||||
"project_id" AS "projectId",
|
||||
"task_id" AS "taskId",
|
||||
"status" AS "status",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
FROM "Runs"
|
||||
WHERE "project_id" = ? AND "task_id" = ?
|
||||
ORDER BY "created_at_ms" DESC, "id" DESC
|
||||
LIMIT ?`,
|
||||
[query.projectId, query.taskId, query.limit],
|
||||
).map((row) =>
|
||||
Object.freeze({
|
||||
id: requiredString(row, 'id'),
|
||||
projectId: requiredString(row, 'projectId'),
|
||||
taskId: requiredString(row, 'taskId'),
|
||||
status: requiredEnum(row, 'status', RUN_STATUSES),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
ProjectRunListQuery,
|
||||
ProjectRunListReader,
|
||||
} from '@qinglong/runtime-core/project-run-list';
|
||||
import type {
|
||||
TaskRunOutcomeWindowQuery,
|
||||
TaskRunOutcomeWindowReader,
|
||||
} from '@qinglong/runtime-core/task-run-outcome-window';
|
||||
import {
|
||||
RunRepositoryBusyError,
|
||||
RunRepositoryOperationError,
|
||||
@@ -26,6 +30,7 @@ import {
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '../readiness/readiness';
|
||||
import { LocalSqliteRunReader } from '../run/runReader';
|
||||
import { LocalSqliteTaskRunOutcomeWindowReader } from '../run/outcome-comparison/taskRunOutcomeWindowReader';
|
||||
import { LocalSqliteStepRunRepository } from '../run/stepRunRepository';
|
||||
import {
|
||||
assertLocalSqliteOptions,
|
||||
@@ -44,7 +49,8 @@ export interface LocalSqliteMcpReadDatabase {
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
readonly runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'> &
|
||||
ProjectRunListReader;
|
||||
ProjectRunListReader &
|
||||
TaskRunOutcomeWindowReader;
|
||||
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
|
||||
readonly taskDefinitions: Pick<
|
||||
TaskDefinitionSource,
|
||||
@@ -76,13 +82,17 @@ export async function openLocalSqliteMcpReadDatabase(
|
||||
const readiness = await auditLocalSqliteReadiness(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
const reader = new LocalSqliteRunReader(client);
|
||||
const outcomeWindowReader = new LocalSqliteTaskRunOutcomeWindowReader(
|
||||
client,
|
||||
);
|
||||
const stepRunRepository = new LocalSqliteStepRunRepository(authority);
|
||||
const taskRepository = new LocalSqliteTaskDefinitionRepository(authority);
|
||||
const triggerRepository = new LocalSqliteTriggerRepository(authority);
|
||||
const approvalSource = new LocalSqliteApprovalRequestSource(authority);
|
||||
const security = new LocalSqliteSecurityAuthorityStore(authority);
|
||||
const runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'> &
|
||||
ProjectRunListReader = Object.freeze({
|
||||
ProjectRunListReader &
|
||||
TaskRunOutcomeWindowReader = Object.freeze({
|
||||
listRunsByProject(query: Readonly<ProjectRunListQuery>) {
|
||||
return authority.enqueue(
|
||||
() => reader.listRunsByProject(query),
|
||||
@@ -94,6 +104,17 @@ export async function openLocalSqliteMcpReadDatabase(
|
||||
),
|
||||
);
|
||||
},
|
||||
listRecentRunsByTask(query: Readonly<TaskRunOutcomeWindowQuery>) {
|
||||
return authority.enqueue(
|
||||
() => outcomeWindowReader.listRecentRunsByTask(query),
|
||||
(reason) =>
|
||||
reason === 'busy'
|
||||
? new RunRepositoryBusyError()
|
||||
: new RunRepositoryOperationError(
|
||||
new Error('Local SQLite MCP read database is closed'),
|
||||
),
|
||||
);
|
||||
},
|
||||
findRunById(runId: string) {
|
||||
return authority.enqueue(
|
||||
() => reader.findRunById(runId),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
const {
|
||||
openLocalSqliteMcpReadDatabase,
|
||||
} = require('@qinglong/local-sqlite/mcp-read-database');
|
||||
const {
|
||||
LocalSqliteTaskRunOutcomeWindowReader,
|
||||
} = require('@qinglong/local-sqlite/task-run-outcome-window');
|
||||
|
||||
function run(id, projectId, taskId, status, createdAtMs) {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
taskId,
|
||||
taskRevision: 'revision-1',
|
||||
taskName: id,
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
triggeredBy: 'test',
|
||||
status,
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
test('reads one fixed Project Task outcome window through the existing indexed SQLite authority', async (t) => {
|
||||
assert.equal(typeof LocalSqliteTaskRunOutcomeWindowReader, 'function');
|
||||
assert.equal(
|
||||
require('@qinglong/local-sqlite').LocalSqliteTaskRunOutcomeWindowReader,
|
||||
undefined,
|
||||
);
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-outcome-window-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const options = {
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
};
|
||||
await migrateLocalSqlitePath(options);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
await runtime.runRepository.transaction(async (transaction) => {
|
||||
for (const value of [
|
||||
run('run-old-success', 'default', 'task-a', 'succeeded', 10),
|
||||
run('run-failure', 'default', 'task-a', 'failed', 20),
|
||||
run('run-running', 'default', 'task-a', 'running', 30),
|
||||
run('run-other-task', 'default', 'task-b', 'failed', 40),
|
||||
run('run-other-project', 'other', 'task-a', 'failed', 50),
|
||||
]) {
|
||||
await transaction.insertRun(value);
|
||||
}
|
||||
});
|
||||
await runtime.close();
|
||||
|
||||
const database = await openLocalSqliteMcpReadDatabase(options);
|
||||
t.after(() => database.close());
|
||||
const rows = await database.runs.listRecentRunsByTask({
|
||||
projectId: 'default',
|
||||
taskId: 'task-a',
|
||||
limit: 3,
|
||||
});
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
id: 'run-running',
|
||||
projectId: 'default',
|
||||
taskId: 'task-a',
|
||||
status: 'running',
|
||||
createdAtMs: 30,
|
||||
},
|
||||
{
|
||||
id: 'run-failure',
|
||||
projectId: 'default',
|
||||
taskId: 'task-a',
|
||||
status: 'failed',
|
||||
createdAtMs: 20,
|
||||
},
|
||||
{
|
||||
id: 'run-old-success',
|
||||
projectId: 'default',
|
||||
taskId: 'task-a',
|
||||
status: 'succeeded',
|
||||
createdAtMs: 10,
|
||||
},
|
||||
]);
|
||||
await assert.rejects(
|
||||
database.runs.listRecentRunsByTask({
|
||||
projectId: 'default',
|
||||
taskId: 'task-a',
|
||||
limit: 66,
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
|
||||
await database.close();
|
||||
const client = new DatabaseSync(options.databasePath, { readOnly: true });
|
||||
t.after(() => {
|
||||
if (client.isOpen) client.close();
|
||||
});
|
||||
const plan = client
|
||||
.prepare(
|
||||
`EXPLAIN QUERY PLAN
|
||||
SELECT "id", "project_id", "task_id", "status", "created_at_ms"
|
||||
FROM "Runs"
|
||||
WHERE "project_id" = ? AND "task_id" = ?
|
||||
ORDER BY "created_at_ms" DESC, "id" DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all('default', 'task-a', 65);
|
||||
assert.match(
|
||||
plan.map((entry) => entry.detail).join('\n'),
|
||||
/ql3_local_runs_task_created_idx/u,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user