feat(ql3): expose redacted log tails over local mcp

This commit is contained in:
whyour
2026-08-14 18:59:29 +08:00
parent fef0fe2bd1
commit 82adb9ace2
20 changed files with 750 additions and 354 deletions
@@ -50,6 +50,12 @@ import {
BUILTIN_RUN_COMPARE_TOOL_DEFINITION,
executeBuiltInRunCompareTool,
} from '@qinglong/runtime-core/builtin-run-compare-projection';
import {
BUILTIN_RUN_LOG_EXCERPT_TOOL,
BUILTIN_RUN_LOG_EXCERPT_TOOL_DEFINITION,
executeBuiltInRunLogExcerptTool,
type RunAttemptLogReadPort,
} from '@qinglong/runtime-core/builtin-run-log-excerpt-projection';
import {
BUILTIN_TASK_RUN_OUTCOME_COMPARE_TOOL,
BUILTIN_TASK_RUN_OUTCOME_COMPARE_TOOL_DEFINITION,
@@ -102,10 +108,12 @@ export interface AuthenticatedLocalMcpRequest {
export interface QingLongLocalMcpServerDependencies {
readonly projectId: string;
readonly profile: 'edge' | 'standalone';
readonly authenticate: () => Promise<Readonly<AuthenticatedLocalMcpRequest> | null>;
readonly policy: ToolPolicyAuthorizer;
readonly audit: SecurityAuditSink;
readonly runs: LocalMcpRunReader;
readonly runAttemptLogs: RunAttemptLogReadPort;
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
readonly taskDefinitions: LocalMcpTaskReader;
readonly triggers: LocalMcpTriggerReader;
@@ -133,7 +141,9 @@ type LocalMcpApprovalReader = Pick<
Pick<ApprovalRequestDetailSource, 'getApprovalRequestDetail'>;
interface LocalMcpReadAuthority {
readonly profile: 'edge' | 'standalone';
readonly runs: LocalMcpRunReader;
readonly runAttemptLogs: RunAttemptLogReadPort;
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
readonly taskDefinitions: LocalMcpTaskReader;
readonly triggers: LocalMcpTriggerReader;
@@ -189,6 +199,24 @@ const LOCAL_MCP_READ_TOOLS: readonly LocalMcpReadToolDescriptor[] =
input: ToolJsonValue,
) => executeBuiltInRunReadTool(authority.runs, projectId, input),
}),
Object.freeze({
tool: BUILTIN_RUN_LOG_EXCERPT_TOOL,
definition: BUILTIN_RUN_LOG_EXCERPT_TOOL_DEFINITION,
title: 'QingLong Run Log Tail',
auditReason: 'tool_qinglong_run_log_excerpt',
unavailableCode: 'run_log_excerpt_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) =>
executeBuiltInRunLogExcerptTool(
authority.runAttemptLogs,
authority.profile,
projectId,
input,
),
}),
Object.freeze({
tool: BUILTIN_RUN_COMPARE_TOOL,
definition: BUILTIN_RUN_COMPARE_TOOL_DEFINITION,
@@ -322,12 +350,15 @@ function validateDependencies(
typeof dependencies !== 'object' ||
Array.isArray(dependencies) ||
typeof dependencies.projectId !== 'string' ||
(dependencies.profile !== 'edge' &&
dependencies.profile !== 'standalone') ||
typeof dependencies.authenticate !== 'function' ||
typeof dependencies.policy?.authorize !== 'function' ||
typeof dependencies.audit?.record !== 'function' ||
typeof dependencies.runs?.listRunsByProject !== 'function' ||
typeof dependencies.runs?.findRunById !== 'function' ||
typeof dependencies.runs?.listEvents !== 'function' ||
typeof dependencies.runAttemptLogs?.read !== 'function' ||
typeof dependencies.stepRuns?.listByRun !== 'function' ||
typeof dependencies.taskDefinitions?.findCurrentTaskDefinition !==
'function' ||
@@ -4,7 +4,7 @@ import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { assertProjectPolicyProjectId } from '@qinglong/runtime-core/project-policy';
export const LOCAL_MCP_SERVER_CONFIG_SCHEMA =
'qinglong/local-mcp-server@v1' as const;
'qinglong/local-mcp-server@v2' as const;
const MAX_PATH_BYTES = 4_096;
@@ -14,6 +14,7 @@ export interface LocalMcpServerConfig {
readonly projectId: string;
readonly deploymentRoot: string;
readonly databasePath: string;
readonly artifactRoot: string;
readonly ownerPepperKeyringDirectory: string;
readonly credentialFilePath: string;
readonly busyTimeoutMs?: number;
@@ -40,6 +41,7 @@ function exactRecord(value: unknown): Record<string, unknown> {
}
const record = value as Record<string, unknown>;
const expected = [
'artifactRoot',
'credentialFilePath',
'databasePath',
'deploymentRoot',
@@ -110,6 +112,7 @@ export function normalizeLocalMcpServerConfig(
'deploymentRoot',
);
const databasePath = absolutePath(record.databasePath, 'databasePath');
const artifactRoot = absolutePath(record.artifactRoot, 'artifactRoot');
const ownerPepperKeyringDirectory = absolutePath(
record.ownerPepperKeyringDirectory,
'ownerPepperKeyringDirectory',
@@ -119,6 +122,7 @@ export function normalizeLocalMcpServerConfig(
'credentialFilePath',
);
descendant(deploymentRoot, databasePath, 'databasePath');
descendant(deploymentRoot, artifactRoot, 'artifactRoot');
descendant(
deploymentRoot,
ownerPepperKeyringDirectory,
@@ -128,9 +132,10 @@ export function normalizeLocalMcpServerConfig(
if (
new Set([
databasePath,
artifactRoot,
ownerPepperKeyringDirectory,
credentialFilePath,
]).size !== 3
]).size !== 4
) {
throw new LocalMcpServerConfigError('authority paths must be distinct');
}
@@ -149,6 +154,7 @@ export function normalizeLocalMcpServerConfig(
projectId: record.projectId as string,
deploymentRoot,
databasePath,
artifactRoot,
ownerPepperKeyringDirectory,
credentialFilePath,
...(busyTimeoutMs === undefined
@@ -1,9 +1,11 @@
import { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
import { LocalRunAttemptLogRangeReader } from '@qinglong/local-command-file/artifact-read';
import {
openLocalSqliteMcpReadDatabase,
type LocalSqliteMcpReadDatabase,
} from '@qinglong/local-sqlite/mcp-read-database';
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
import { RunAttemptLogReadService } from '@qinglong/runtime-core/run-attempt-log-read';
import {
createQingLongLocalMcpServer,
@@ -75,8 +77,19 @@ export async function openProductionLocalMcpServer(
});
const activeDatabase = database;
const policy = new ProjectPolicyEngine(activeDatabase.projectPolicy);
const runAttemptLogs = new RunAttemptLogReadService(
activeDatabase.runs,
new LocalRunAttemptLogRangeReader(config.artifactRoot),
{
executorType: 'local_process',
artifactIdPattern: /^local-[a-f0-9]{30}$/,
maximumReadBytes: 32 * 1024,
},
activeDatabase.runAttemptLogRetention,
);
const serverDependencies: QingLongLocalMcpServerDependencies = {
projectId: config.projectId,
profile: config.profile,
authenticate: () =>
adapters.authenticate(activeDatabase, {
deploymentRoot: config.deploymentRoot,
@@ -88,6 +101,7 @@ export async function openProductionLocalMcpServer(
policy,
audit: activeDatabase.securityAudit,
runs: activeDatabase.runs,
runAttemptLogs,
stepRuns: activeDatabase.stepRuns,
taskDefinitions: activeDatabase.taskDefinitions,
triggers: activeDatabase.triggers,