feat(ql3): compare bounded task run outcomes

This commit is contained in:
whyour
2026-08-14 17:18:25 +08:00
parent a55613afaa
commit 828370c60b
22 changed files with 1802 additions and 22 deletions
+5
View File
@@ -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,
);
});