feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,549 @@
import { randomUUID } from 'node:crypto';
import type {
ApprovalRequestDetailSource,
ApprovalRequestSource,
} from '@qinglong/runtime-core/approval-discovery';
import {
BUILTIN_APPROVAL_GET_TOOL,
BUILTIN_APPROVAL_GET_TOOL_DEFINITION,
executeBuiltInApprovalGetTool,
} from '../tool-projection/approvalGet';
import {
BUILTIN_APPROVAL_LIST_TOOL,
BUILTIN_APPROVAL_LIST_TOOL_DEFINITION,
executeBuiltInApprovalListTool,
} from '../tool-projection/approvalList';
import {
BUILTIN_TASK_LIST_TOOL,
BUILTIN_TASK_LIST_TOOL_DEFINITION,
executeBuiltInTaskListTool,
} from '../tool-projection/taskList';
import {
BUILTIN_TASK_GET_TOOL,
BUILTIN_TASK_GET_TOOL_DEFINITION,
executeBuiltInTaskGetTool,
} from '../tool-projection/taskGet';
import {
McpServer,
fromJsonSchema,
type CallToolResult,
type JsonSchemaType,
} from '@modelcontextprotocol/server';
import {
BUILTIN_RUN_EVENT_LIST_TOOL,
BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION,
executeBuiltInRunEventListTool,
} from '../tool-projection/runEventList';
import {
BUILTIN_RUN_LIST_TOOL,
BUILTIN_RUN_LIST_TOOL_DEFINITION,
executeBuiltInRunListTool,
} from '../tool-projection/runList';
import {
BUILTIN_RUN_STEP_LIST_TOOL,
BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION,
executeBuiltInRunStepListTool,
} from '../tool-projection/runStepList';
import {
BUILTIN_RUN_READ_TOOL,
BUILTIN_RUN_READ_TOOL_DEFINITION,
executeBuiltInRunReadTool,
} from '@qinglong/runtime-core/builtin-run-read-projection';
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
import type { ProjectRunListReader } from '@qinglong/runtime-core/project-run-list';
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
import type { TriggerSource } from '@qinglong/runtime-core/trigger';
import {
normalizeSecurityAuditRecord,
type SecurityAuditOutcome,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import {
InvalidToolJsonValueError,
ToolDefinitionRegistry,
ToolPolicySnapshotConflictError,
ToolPolicyUnavailableError,
prepareToolInvocation,
type ToolDefinition,
type ToolJsonValue,
type ToolPolicyAuthorizer,
} from '@qinglong/runtime-core/tool-registry';
import {
BUILTIN_TRIGGER_LIST_TOOL,
BUILTIN_TRIGGER_LIST_TOOL_DEFINITION,
executeBuiltInTriggerListTool,
} from '../tool-projection/triggerList';
export const QINGLONG_LOCAL_MCP_SERVER = Object.freeze({
name: 'qinglong-local',
version: '3.0.0-alpha.0',
});
const MCP_OPERATION_ID = 'mcp.tool.call';
export interface AuthenticatedLocalMcpRequest {
readonly principal: Readonly<SecurityPrincipal>;
confirm(): Promise<void>;
}
export interface QingLongLocalMcpServerDependencies {
readonly projectId: string;
readonly authenticate: () => Promise<Readonly<AuthenticatedLocalMcpRequest> | null>;
readonly policy: ToolPolicyAuthorizer;
readonly audit: SecurityAuditSink;
readonly runs: LocalMcpRunReader;
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
readonly taskDefinitions: LocalMcpTaskReader;
readonly triggers: LocalMcpTriggerReader;
readonly approvals: LocalMcpApprovalReader;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
type LocalMcpRunReader = Pick<
RunRepositoryReader,
'findRunById' | 'listEvents'
> &
ProjectRunListReader;
type LocalMcpTaskReader = Pick<
TaskDefinitionSource,
'findCurrentTaskDefinition' | 'listTaskDefinitions'
>;
type LocalMcpTriggerReader = Pick<TriggerSource, 'listTriggers'>;
type LocalMcpApprovalReader = Pick<
ApprovalRequestSource,
'listApprovalRequests'
> &
Pick<ApprovalRequestDetailSource, 'getApprovalRequestDetail'>;
interface LocalMcpReadAuthority {
readonly runs: LocalMcpRunReader;
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
readonly taskDefinitions: LocalMcpTaskReader;
readonly triggers: LocalMcpTriggerReader;
readonly approvals: LocalMcpApprovalReader;
}
export class LocalMcpAdmissionError extends Error {
readonly code: string;
constructor(code: string) {
super('Local MCP Tool admission failed');
this.name = 'LocalMcpAdmissionError';
this.code = code;
}
}
interface LocalMcpReadToolDescriptor {
readonly tool: Readonly<{ name: string; version: string }>;
readonly definition: Readonly<ToolDefinition>;
readonly title: string;
readonly auditReason: string;
readonly unavailableCode: string;
execute(
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>>;
}
const LOCAL_MCP_READ_TOOLS: readonly LocalMcpReadToolDescriptor[] =
Object.freeze([
Object.freeze({
tool: BUILTIN_RUN_LIST_TOOL,
definition: BUILTIN_RUN_LIST_TOOL_DEFINITION,
title: 'QingLong Runs',
auditReason: 'tool_qinglong_run_list',
unavailableCode: 'run_list_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) => executeBuiltInRunListTool(authority.runs, projectId, input),
}),
Object.freeze({
tool: BUILTIN_RUN_READ_TOOL,
definition: BUILTIN_RUN_READ_TOOL_DEFINITION,
title: 'QingLong Run',
auditReason: 'tool_qinglong_run_get',
unavailableCode: 'run_query_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) => executeBuiltInRunReadTool(authority.runs, projectId, input),
}),
Object.freeze({
tool: BUILTIN_RUN_EVENT_LIST_TOOL,
definition: BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION,
title: 'QingLong Run Events',
auditReason: 'tool_qinglong_run_events_list',
unavailableCode: 'run_event_query_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) => executeBuiltInRunEventListTool(authority.runs, projectId, input),
}),
Object.freeze({
tool: BUILTIN_RUN_STEP_LIST_TOOL,
definition: BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION,
title: 'QingLong Run Steps',
auditReason: 'tool_qinglong_run_steps_list',
unavailableCode: 'run_step_query_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) =>
executeBuiltInRunStepListTool(
authority.runs,
authority.stepRuns,
projectId,
input,
),
}),
Object.freeze({
tool: BUILTIN_TASK_GET_TOOL,
definition: BUILTIN_TASK_GET_TOOL_DEFINITION,
title: 'QingLong Task',
auditReason: 'tool_qinglong_task_get',
unavailableCode: 'task_query_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) =>
executeBuiltInTaskGetTool(authority.taskDefinitions, projectId, input),
}),
Object.freeze({
tool: BUILTIN_TASK_LIST_TOOL,
definition: BUILTIN_TASK_LIST_TOOL_DEFINITION,
title: 'QingLong Tasks',
auditReason: 'tool_qinglong_task_list',
unavailableCode: 'task_list_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) =>
executeBuiltInTaskListTool(authority.taskDefinitions, projectId, input),
}),
Object.freeze({
tool: BUILTIN_TRIGGER_LIST_TOOL,
definition: BUILTIN_TRIGGER_LIST_TOOL_DEFINITION,
title: 'QingLong Triggers',
auditReason: 'tool_qinglong_trigger_list',
unavailableCode: 'trigger_list_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) => executeBuiltInTriggerListTool(authority.triggers, projectId, input),
}),
Object.freeze({
tool: BUILTIN_APPROVAL_LIST_TOOL,
definition: BUILTIN_APPROVAL_LIST_TOOL_DEFINITION,
title: 'QingLong Approvals',
auditReason: 'tool_qinglong_approval_list',
unavailableCode: 'approval_list_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) =>
executeBuiltInApprovalListTool(authority.approvals, projectId, input),
}),
Object.freeze({
tool: BUILTIN_APPROVAL_GET_TOOL,
definition: BUILTIN_APPROVAL_GET_TOOL_DEFINITION,
title: 'QingLong Approval',
auditReason: 'tool_qinglong_approval_get',
unavailableCode: 'approval_query_unavailable',
execute: (
authority: LocalMcpReadAuthority,
projectId: string,
input: ToolJsonValue,
) => executeBuiltInApprovalGetTool(authority.approvals, projectId, input),
}),
]);
function validateDependencies(
dependencies: QingLongLocalMcpServerDependencies,
): void {
if (
!dependencies ||
typeof dependencies !== 'object' ||
Array.isArray(dependencies) ||
typeof dependencies.projectId !== 'string' ||
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.stepRuns?.listByRun !== 'function' ||
typeof dependencies.taskDefinitions?.findCurrentTaskDefinition !==
'function' ||
typeof dependencies.taskDefinitions?.listTaskDefinitions !== 'function' ||
typeof dependencies.triggers?.listTriggers !== 'function' ||
typeof dependencies.approvals?.listApprovalRequests !== 'function' ||
typeof dependencies.approvals?.getApprovalRequestDetail !== 'function' ||
(dependencies.now !== undefined &&
typeof dependencies.now !== 'function') ||
(dependencies.randomUuid !== undefined &&
typeof dependencies.randomUuid !== 'function')
) {
throw new TypeError('Local MCP server dependencies are invalid');
}
}
function toolError(code: string): CallToolResult {
return {
isError: true,
content: [{ type: 'text' as const, text: JSON.stringify({ code }) }],
};
}
function timestamp(now: () => number): number {
let value: number;
try {
value = now();
} catch {
throw new LocalMcpAdmissionError('clock_unavailable');
}
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalMcpAdmissionError('clock_unavailable');
}
return value;
}
async function recordAudit(
dependencies: QingLongLocalMcpServerDependencies,
requestId: string,
outcome: SecurityAuditOutcome,
reasons: readonly string[],
principal: Readonly<SecurityPrincipal> | null,
fence: Readonly<{
projectVersion: number;
bindingVersion: number | null;
}> | null,
now: () => number,
uuid: () => string,
): Promise<void> {
try {
await dependencies.audit.record(
normalizeSecurityAuditRecord({
eventId: uuid(),
requestId,
operationId: MCP_OPERATION_ID,
projectId: dependencies.projectId,
subject: principal?.subject ?? null,
authenticationId: principal?.authenticationId ?? null,
outcome,
reasons,
fence,
occurredAtMs: timestamp(now),
}),
);
} catch {
throw new LocalMcpAdmissionError('security_audit_unavailable');
}
}
/**
* Creates one read-only MCP endpoint. Every Tool call re-authenticates, passes
* the shared Tool Registry and Project Policy, records durable admission,
* confirms the credential fence, and only then performs a bounded read.
*/
export function createQingLongLocalMcpServer(
dependencies: QingLongLocalMcpServerDependencies,
): McpServer {
validateDependencies(dependencies);
const now = dependencies.now ?? Date.now;
const uuid = dependencies.randomUuid ?? randomUUID;
const registry = new ToolDefinitionRegistry(
LOCAL_MCP_READ_TOOLS.map(({ definition }) => definition),
);
const server = new McpServer(QINGLONG_LOCAL_MCP_SERVER, {
capabilities: { tools: {} },
});
for (const descriptor of LOCAL_MCP_READ_TOOLS) {
const definition = registry.resolve(
descriptor.tool.name,
descriptor.tool.version,
);
const inputSchema = fromJsonSchema<Record<string, unknown>>(
definition.inputSchema as unknown as JsonSchemaType,
);
const outputSchema = fromJsonSchema<Record<string, unknown>>(
definition.outputSchema as unknown as JsonSchemaType,
);
server.registerTool(
definition.name,
{
title: descriptor.title,
description: definition.description,
inputSchema,
outputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async (argumentsValue): Promise<CallToolResult> => {
const requestId = `mcp:${uuid()}`;
let authenticated: Readonly<AuthenticatedLocalMcpRequest> | null;
try {
authenticated = await dependencies.authenticate();
} catch {
try {
await recordAudit(
dependencies,
requestId,
'authentication_unavailable',
['authentication_unavailable', descriptor.auditReason],
null,
null,
now,
uuid,
);
} catch (error) {
return toolError((error as LocalMcpAdmissionError).code);
}
return toolError('authentication_unavailable');
}
if (!authenticated) {
try {
await recordAudit(
dependencies,
requestId,
'authentication_rejected',
['authentication_rejected', descriptor.auditReason],
null,
null,
now,
uuid,
);
} catch (error) {
return toolError((error as LocalMcpAdmissionError).code);
}
return toolError('authentication_required');
}
let plan;
try {
plan = await prepareToolInvocation(
registry,
{
projectId: dependencies.projectId,
principal: authenticated.principal,
nowMs: timestamp(now),
tool: descriptor.tool,
input: argumentsValue,
},
dependencies.policy,
);
} catch (error) {
const code =
error instanceof InvalidToolJsonValueError
? 'invalid_tool_input'
: error instanceof ToolPolicySnapshotConflictError
? 'policy_fence_conflict'
: error instanceof ToolPolicyUnavailableError
? 'authorization_unavailable'
: 'authorization_unavailable';
try {
await recordAudit(
dependencies,
requestId,
'authorization_unavailable',
[code, descriptor.auditReason],
authenticated.principal,
null,
now,
uuid,
);
} catch (auditError) {
return toolError((auditError as LocalMcpAdmissionError).code);
}
return toolError(code);
}
if (plan.status === 'denied' || plan.status === 'approval_required') {
const approvalRequired = plan.status === 'approval_required';
try {
await recordAudit(
dependencies,
requestId,
approvalRequired ? 'approval_required' : 'denied',
[
approvalRequired
? 'tool_approval_required'
: 'tool_invocation_denied',
descriptor.auditReason,
],
authenticated.principal,
plan.status === 'denied' ? null : plan.fence,
now,
uuid,
);
} catch (error) {
return toolError((error as LocalMcpAdmissionError).code);
}
return toolError(
approvalRequired ? 'approval_required' : 'forbidden',
);
}
try {
await recordAudit(
dependencies,
requestId,
'allowed',
['tool_invocation_allowed', descriptor.auditReason],
authenticated.principal,
plan.fence,
now,
uuid,
);
await authenticated.confirm();
const output = registry.normalizeOutput(
definition.name,
definition.version,
await descriptor.execute(
dependencies,
dependencies.projectId,
plan.input,
),
);
if (!output || typeof output !== 'object' || Array.isArray(output)) {
return toolError(descriptor.unavailableCode);
}
const structuredContent = output as Record<string, ToolJsonValue>;
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(structuredContent),
},
],
structuredContent,
};
} catch (error) {
if (error instanceof LocalMcpAdmissionError) {
return toolError(error.code);
}
return toolError(descriptor.unavailableCode);
}
},
);
}
return server;
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { openProductionLocalMcpServer } from './production-process/processApplication';
const USAGE = 'Usage: ql3-mcp --config /absolute/private-config.json';
function configFileArgument(argv: readonly string[]): string | null {
if (argv.length !== 2 || argv[0] !== '--config' || !argv[1]) return null;
return argv[1];
}
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
return Object.freeze({
schemaVersion: 1,
component: 'qinglong3-local-mcp',
level: 'error',
event: 'process_failed',
name:
typeof candidate?.name === 'string' && candidate.name.length <= 128
? candidate.name
: 'Error',
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
? { code: candidate.code }
: {}),
});
}
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
const configFilePath = configFileArgument(argv);
if (configFilePath === null) {
process.stderr.write(
`${JSON.stringify({
code: 'LOCAL_MCP_SERVER_CLI_USAGE_INVALID',
message: USAGE,
})}\n`,
);
process.exitCode = 64;
return;
}
let active: Awaited<ReturnType<typeof openProductionLocalMcpServer>> | undefined;
let handle: ReturnType<typeof serveStdio> | undefined;
let stopPromise: Promise<void> | undefined;
const stop = () => {
if (stopPromise) return stopPromise;
stopPromise = (async () => {
if (handle) await handle.close();
if (active) await active.close();
})();
return stopPromise;
};
try {
active = await openProductionLocalMcpServer({ configFilePath });
handle = serveStdio(() => active!.createServer(), {
onerror: () => {
process.stderr.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-local-mcp',
level: 'error',
event: 'transport_error',
})}\n`,
);
},
maxSubscriptions: 1,
});
const shutdown = () => {
void stop().catch((error) => {
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
process.exitCode = 1;
});
};
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
process.stdin.once('end', shutdown);
} catch (error) {
try {
await stop();
} catch {
// Preserve the startup failure.
}
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
process.exitCode = 1;
}
}
void main(process.argv.slice(2));
@@ -0,0 +1,173 @@
import path from 'node:path';
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;
const MAX_PATH_BYTES = 4_096;
export interface LocalMcpServerConfig {
readonly schema: typeof LOCAL_MCP_SERVER_CONFIG_SCHEMA;
readonly profile: 'edge' | 'standalone';
readonly projectId: string;
readonly deploymentRoot: string;
readonly databasePath: string;
readonly ownerPepperKeyringDirectory: string;
readonly credentialFilePath: string;
readonly busyTimeoutMs?: number;
}
export class LocalMcpServerConfigError extends TypeError {
readonly code = 'LOCAL_MCP_SERVER_CONFIG_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local MCP server configuration is invalid: ${message}`, options);
this.name = 'LocalMcpServerConfigError';
}
}
function exactRecord(value: unknown): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
throw new LocalMcpServerConfigError('configuration must be an object');
}
const record = value as Record<string, unknown>;
const expected = [
'credentialFilePath',
'databasePath',
'deploymentRoot',
'ownerPepperKeyringDirectory',
'profile',
'projectId',
'schema',
...(Object.hasOwn(record, 'busyTimeoutMs') ? ['busyTimeoutMs'] : []),
].sort();
const actual = Object.keys(record).sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
throw new LocalMcpServerConfigError('configuration shape is invalid');
}
return record;
}
function absolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
value.includes('\0') ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value
) {
throw new LocalMcpServerConfigError(
`${label} must be a normalized bounded absolute non-root path`,
);
}
return value;
}
function descendant(root: string, value: string, label: string): void {
const relative = path.relative(root, value);
if (
relative.length === 0 ||
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new LocalMcpServerConfigError(
`${label} must be a descendant of deploymentRoot`,
);
}
}
export function normalizeLocalMcpServerConfig(
value: unknown,
): Readonly<LocalMcpServerConfig> {
const record = exactRecord(value);
if (record.schema !== LOCAL_MCP_SERVER_CONFIG_SCHEMA) {
throw new LocalMcpServerConfigError('schema is unsupported');
}
if (record.profile !== 'edge' && record.profile !== 'standalone') {
throw new LocalMcpServerConfigError('profile is invalid');
}
try {
assertProjectPolicyProjectId(record.projectId as string);
} catch (error) {
throw new LocalMcpServerConfigError('projectId is invalid', { cause: error });
}
const deploymentRoot = absolutePath(
record.deploymentRoot,
'deploymentRoot',
);
const databasePath = absolutePath(record.databasePath, 'databasePath');
const ownerPepperKeyringDirectory = absolutePath(
record.ownerPepperKeyringDirectory,
'ownerPepperKeyringDirectory',
);
const credentialFilePath = absolutePath(
record.credentialFilePath,
'credentialFilePath',
);
descendant(deploymentRoot, databasePath, 'databasePath');
descendant(
deploymentRoot,
ownerPepperKeyringDirectory,
'ownerPepperKeyringDirectory',
);
descendant(deploymentRoot, credentialFilePath, 'credentialFilePath');
if (
new Set([
databasePath,
ownerPepperKeyringDirectory,
credentialFilePath,
]).size !== 3
) {
throw new LocalMcpServerConfigError('authority paths must be distinct');
}
const busyTimeoutMs = record.busyTimeoutMs;
if (
busyTimeoutMs !== undefined &&
(!Number.isSafeInteger(busyTimeoutMs) ||
(busyTimeoutMs as number) < 100 ||
(busyTimeoutMs as number) > 30_000)
) {
throw new LocalMcpServerConfigError('busyTimeoutMs is invalid');
}
return Object.freeze({
schema: LOCAL_MCP_SERVER_CONFIG_SCHEMA,
profile: record.profile,
projectId: record.projectId as string,
deploymentRoot,
databasePath,
ownerPepperKeyringDirectory,
credentialFilePath,
...(busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: busyTimeoutMs as number }),
});
}
export function readLocalMcpServerConfig(
configFilePath: string,
): Readonly<LocalMcpServerConfig> {
try {
return normalizeLocalMcpServerConfig(
readPrivateLocalCommandFile(configFilePath),
);
} catch (error) {
if (error instanceof LocalMcpServerConfigError) throw error;
throw new LocalMcpServerConfigError('private config cannot be read', {
cause: error,
});
}
}
@@ -0,0 +1,116 @@
import { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
import {
openLocalSqliteMcpReadDatabase,
type LocalSqliteMcpReadDatabase,
} from '@qinglong/local-sqlite/mcp-read-database';
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
import {
createQingLongLocalMcpServer,
type QingLongLocalMcpServerDependencies,
} from '../application-runtime/mcpServer';
import { readLocalMcpServerConfig, type LocalMcpServerConfig } from './config';
export interface OpenProductionLocalMcpServerOptions {
readonly configFilePath: string;
}
export interface ActiveProductionLocalMcpServer {
readonly config: Readonly<LocalMcpServerConfig>;
createServer(): ReturnType<typeof createQingLongLocalMcpServer>;
close(): Promise<void>;
}
export interface ProductionLocalMcpServerAdapters {
readonly readConfig: typeof readLocalMcpServerConfig;
readonly openDatabase: typeof openLocalSqliteMcpReadDatabase;
readonly authenticate: typeof establishAuthenticatedLocalCommand;
}
function validateOptions(options: OpenProductionLocalMcpServerOptions): void {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).length !== 1 ||
typeof options.configFilePath !== 'string'
) {
throw new TypeError('Production local MCP server options are invalid');
}
}
function validateAdapters(adapters: ProductionLocalMcpServerAdapters): void {
if (
!adapters ||
typeof adapters !== 'object' ||
Array.isArray(adapters) ||
typeof adapters.readConfig !== 'function' ||
typeof adapters.openDatabase !== 'function' ||
typeof adapters.authenticate !== 'function'
) {
throw new TypeError('Production local MCP server adapters are invalid');
}
}
/** Opens one optional MCP process authority without starting a network listener. */
export async function openProductionLocalMcpServer(
options: OpenProductionLocalMcpServerOptions,
adapters: ProductionLocalMcpServerAdapters = {
readConfig: readLocalMcpServerConfig,
openDatabase: openLocalSqliteMcpReadDatabase,
authenticate: establishAuthenticatedLocalCommand,
},
): Promise<Readonly<ActiveProductionLocalMcpServer>> {
validateOptions(options);
validateAdapters(adapters);
const config = adapters.readConfig(options.configFilePath);
let database: LocalSqliteMcpReadDatabase | undefined;
try {
database = await adapters.openDatabase({
databasePath: config.databasePath,
profile: config.profile,
...(config.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: config.busyTimeoutMs }),
});
const activeDatabase = database;
const policy = new ProjectPolicyEngine(activeDatabase.projectPolicy);
const serverDependencies: QingLongLocalMcpServerDependencies = {
projectId: config.projectId,
authenticate: () =>
adapters.authenticate(activeDatabase, {
deploymentRoot: config.deploymentRoot,
databasePath: config.databasePath,
ownerPepperKeyringDirectory: config.ownerPepperKeyringDirectory,
credentialFilePath: config.credentialFilePath,
authenticationNamespace: 'mcp_read',
}),
policy,
audit: activeDatabase.securityAudit,
runs: activeDatabase.runs,
stepRuns: activeDatabase.stepRuns,
taskDefinitions: activeDatabase.taskDefinitions,
triggers: activeDatabase.triggers,
approvals: activeDatabase.approvals,
};
let closePromise: Promise<void> | undefined;
return Object.freeze({
config,
createServer: () => createQingLongLocalMcpServer(serverDependencies),
close() {
const closing = closePromise ?? activeDatabase.close();
closePromise = closing;
return closing;
},
});
} catch (error) {
if (database) {
try {
await database.close();
} catch {
// Preserve the startup failure.
}
}
throw error;
}
}
@@ -0,0 +1,265 @@
import {
APPROVAL_DECISION_MODES,
APPROVAL_REQUEST_STATES,
APPROVAL_RISKS,
normalizeApprovalRequestRecord,
} from '@qinglong/runtime-core/approved-action';
import type {
ApprovalRequestDetailSource,
} from '@qinglong/runtime-core/approval-discovery';
import {
normalizeApprovalDetailPreview,
} from '@qinglong/runtime-core/approval-discovery';
import { normalizeProjectPermission } from '@qinglong/runtime-core/project-policy';
import { SECURITY_SUBJECT_TYPES } from '@qinglong/runtime-core/security';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_APPROVAL_GET_TOOL = Object.freeze({
name: 'qinglong.approval.get',
version: '1.0.0',
});
export const BUILTIN_APPROVAL_GET_TIMEOUT_SECONDS = 5;
const MAX_INT = 2_147_483_647;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
const PREVIEW_FIELD_KINDS = ['count', 'identifier', 'redacted', 'text'] as const;
const PREVIEW_SCHEMA = Object.freeze({
type: 'object',
properties: {
title: { type: 'string', minLength: 1, maxLength: 256 },
summary: { type: 'string', minLength: 1, maxLength: 2048 },
fields: {
type: 'array',
maxItems: 16,
items: {
type: 'object',
properties: {
kind: {
type: 'string',
maxLength: 16,
enum: [...PREVIEW_FIELD_KINDS],
},
label: { type: 'string', minLength: 1, maxLength: 128 },
value: { type: 'string', minLength: 1, maxLength: 512 },
},
required: ['kind', 'label'],
additionalProperties: false,
},
},
warnings: {
type: 'array',
maxItems: 8,
items: { type: 'string', minLength: 1, maxLength: 64 },
},
},
required: ['title', 'summary', 'fields', 'warnings'],
additionalProperties: false,
});
export const BUILTIN_APPROVAL_GET_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_APPROVAL_GET_TOOL.name,
version: BUILTIN_APPROVAL_GET_TOOL.version,
description:
'Get one Approval and its bounded redacted Tool preview in the authenticated Project',
inputSchema: {
type: 'object',
properties: {
requestId: { type: 'string', minLength: 1, maxLength: 128 },
},
required: ['requestId'],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
found: { type: 'boolean' },
approval: {
type: 'object',
properties: {
requestId: { type: 'string', minLength: 1, maxLength: 128 },
version: { type: 'integer', minimum: 1, maximum: MAX_INT },
state: {
type: 'string',
maxLength: 32,
enum: [...APPROVAL_REQUEST_STATES],
},
risk: {
type: 'string',
maxLength: 32,
enum: [...APPROVAL_RISKS],
},
decisionMode: {
type: 'string',
maxLength: 32,
enum: [...APPROVAL_DECISION_MODES],
},
permission: { type: 'string', minLength: 1, maxLength: 255 },
actionType: { type: 'string', minLength: 1, maxLength: 128 },
requestedByType: {
type: 'string',
maxLength: 32,
enum: [...SECURITY_SUBJECT_TYPES],
},
requestedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
expiresAtMs: {
type: 'integer',
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
},
previewAvailable: { type: 'boolean' },
preview: PREVIEW_SCHEMA,
},
required: [
'requestId',
'version',
'state',
'risk',
'decisionMode',
'permission',
'actionType',
'requestedByType',
'requestedAtMs',
'expiresAtMs',
'previewAvailable',
],
additionalProperties: false,
},
},
required: ['found'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['approval.read', 'artifact.read'],
timeoutSeconds: BUILTIN_APPROVAL_GET_TIMEOUT_SECONDS,
});
export class InvalidBuiltInApprovalGetToolError extends TypeError {
readonly code = 'BUILTIN_APPROVAL_GET_TOOL_INVALID';
constructor(message: string) {
super(`Built-in Approval get Tool is invalid: ${message}`);
this.name = 'InvalidBuiltInApprovalGetToolError';
}
}
export class BuiltInApprovalGetToolUnavailableError extends Error {
readonly code = 'BUILTIN_APPROVAL_GET_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Approval get Tool is unavailable');
this.name = 'BuiltInApprovalGetToolUnavailableError';
}
}
function invalid(message: string): never {
throw new InvalidBuiltInApprovalGetToolError(message);
}
function boundedText(value: unknown, maximumBytes: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
!CONTROL_PATTERN.test(value)
);
}
export async function executeBuiltInApprovalGetTool(
source: Pick<ApprovalRequestDetailSource, 'getApprovalRequestDetail'>,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const record =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
if (
typeof source?.getApprovalRequestDetail !== 'function' ||
!boundedText(projectId, 128) ||
!record ||
Reflect.ownKeys(record).length !== 1 ||
!Object.hasOwn(record, 'requestId') ||
!boundedText(record.requestId, 128)
) {
return invalid('execution context or input is invalid');
}
let detail;
try {
detail = await source.getApprovalRequestDetail({
projectId,
requestId: record.requestId,
});
} catch {
throw new BuiltInApprovalGetToolUnavailableError();
}
if (!detail) return Object.freeze({ found: false });
try {
if (
typeof detail !== 'object' ||
Array.isArray(detail) ||
Reflect.ownKeys(detail).length !== 2 ||
!Object.hasOwn(detail, 'request') ||
!Object.hasOwn(detail, 'preview')
) {
throw new BuiltInApprovalGetToolUnavailableError();
}
const request = normalizeApprovalRequestRecord(detail.request);
const preview = detail.preview
? normalizeApprovalDetailPreview(detail.preview)
: null;
const permission = normalizeProjectPermission(request.action.permission);
if (
request.projectId !== projectId ||
request.id !== record.requestId ||
(preview !== null && request.action.actionType !== 'tool.invoke')
) {
throw new BuiltInApprovalGetToolUnavailableError();
}
return Object.freeze({
found: true,
approval: Object.freeze({
requestId: request.id,
version: request.version,
state: request.state,
risk: request.risk,
decisionMode: request.decisionMode,
permission,
actionType: request.action.actionType,
requestedByType: request.requestedBy.type,
requestedAtMs: request.requestedAtMs,
expiresAtMs: request.expiresAtMs,
previewAvailable: preview !== null,
...(preview === null
? {}
: {
preview: Object.freeze({
title: preview.title,
summary: preview.summary,
fields: Object.freeze(
preview.fields.map((field) =>
Object.freeze({
kind: field.kind,
label: field.label,
...(field.value === null ? {} : { value: field.value }),
}),
),
),
warnings: Object.freeze([...preview.warnings]),
}),
}),
}),
});
} catch (error) {
if (error instanceof BuiltInApprovalGetToolUnavailableError) throw error;
throw new BuiltInApprovalGetToolUnavailableError();
}
}
@@ -0,0 +1,351 @@
import {
APPROVAL_DECISION_MODES,
APPROVAL_REQUEST_STATES,
APPROVAL_RISKS,
normalizeApprovalRequestRecord,
type ApprovalRequestRecord,
} from '@qinglong/runtime-core/approved-action';
import {
approvalRequestUpdatedAtMs,
type ApprovalRequestCursor,
type ApprovalRequestSource,
} from '@qinglong/runtime-core/approval-discovery';
import { SECURITY_SUBJECT_TYPES } from '@qinglong/runtime-core/security';
import { normalizeProjectPermission } from '@qinglong/runtime-core/project-policy';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_APPROVAL_LIST_TOOL = Object.freeze({
name: 'qinglong.approval.list',
version: '1.0.0',
});
export const BUILTIN_APPROVAL_LIST_TIMEOUT_SECONDS = 5;
export const BUILTIN_APPROVAL_LIST_DEFAULT_LIMIT = 32;
export const BUILTIN_APPROVAL_LIST_MAX_LIMIT = 64;
const MAX_INT = 2_147_483_647;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
const APPROVAL_LIST_CURSOR_SCHEMA = Object.freeze({
type: 'object',
properties: {
updatedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
requestId: { type: 'string', minLength: 1, maxLength: 128 },
},
required: ['updatedAtMs', 'requestId'],
additionalProperties: false,
});
export const BUILTIN_APPROVAL_LIST_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_APPROVAL_LIST_TOOL.name,
version: BUILTIN_APPROVAL_LIST_TOOL.version,
description:
'List recent low-sensitive Approval requests in the authenticated Project',
inputSchema: {
type: 'object',
properties: {
after: APPROVAL_LIST_CURSOR_SCHEMA,
limit: {
type: 'integer',
minimum: 1,
maximum: BUILTIN_APPROVAL_LIST_MAX_LIMIT,
},
},
required: [],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
approvals: {
type: 'array',
maxItems: BUILTIN_APPROVAL_LIST_MAX_LIMIT,
items: {
type: 'object',
properties: {
requestId: { type: 'string', minLength: 1, maxLength: 128 },
version: { type: 'integer', minimum: 1, maximum: MAX_INT },
state: {
type: 'string',
maxLength: 32,
enum: [...APPROVAL_REQUEST_STATES],
},
risk: {
type: 'string',
maxLength: 32,
enum: [...APPROVAL_RISKS],
},
decisionMode: {
type: 'string',
maxLength: 32,
enum: [...APPROVAL_DECISION_MODES],
},
permission: { type: 'string', minLength: 1, maxLength: 255 },
actionType: { type: 'string', minLength: 1, maxLength: 128 },
requestedByType: {
type: 'string',
maxLength: 32,
enum: [...SECURITY_SUBJECT_TYPES],
},
requestedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
expiresAtMs: {
type: 'integer',
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
},
decidedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
consumedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
updatedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
},
required: [
'requestId',
'version',
'state',
'risk',
'decisionMode',
'permission',
'actionType',
'requestedByType',
'requestedAtMs',
'expiresAtMs',
'updatedAtMs',
],
additionalProperties: false,
},
},
hasMore: { type: 'boolean' },
next: APPROVAL_LIST_CURSOR_SCHEMA,
},
required: ['approvals', 'hasMore'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['approval.read'],
timeoutSeconds: BUILTIN_APPROVAL_LIST_TIMEOUT_SECONDS,
});
export class InvalidBuiltInApprovalListToolError extends TypeError {
readonly code = 'BUILTIN_APPROVAL_LIST_TOOL_INVALID';
constructor(message: string) {
super(`Built-in Approval list Tool is invalid: ${message}`);
this.name = 'InvalidBuiltInApprovalListToolError';
}
}
export class BuiltInApprovalListToolUnavailableError extends Error {
readonly code = 'BUILTIN_APPROVAL_LIST_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Approval list Tool is unavailable');
this.name = 'BuiltInApprovalListToolUnavailableError';
}
}
function invalid(message: string): never {
throw new InvalidBuiltInApprovalListToolError(message);
}
function boundedText(value: unknown, maximumBytes: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
!CONTROL_PATTERN.test(value)
);
}
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
function cursor(value: ToolJsonValue | undefined): ApprovalRequestCursor | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return invalid('cursor is invalid');
}
const record = value as Readonly<Record<string, ToolJsonValue>>;
if (
Reflect.ownKeys(record).length !== 2 ||
!Object.hasOwn(record, 'updatedAtMs') ||
!Object.hasOwn(record, 'requestId') ||
!integer(record.updatedAtMs, 0, Number.MAX_SAFE_INTEGER) ||
!boundedText(record.requestId, 128)
) {
return invalid('cursor is invalid');
}
return Object.freeze({
updatedAtMs: record.updatedAtMs,
requestId: record.requestId,
});
}
function before(
updatedAtMs: number,
requestId: string,
boundary?: Readonly<ApprovalRequestCursor>,
): boolean {
return (
!boundary ||
updatedAtMs < boundary.updatedAtMs ||
(updatedAtMs === boundary.updatedAtMs && requestId < boundary.requestId)
);
}
function projectApproval(
value: Readonly<ApprovalRequestRecord>,
projectId: string,
boundary?: Readonly<ApprovalRequestCursor>,
): Readonly<Record<string, ToolJsonValue>> | null {
let request: Readonly<ApprovalRequestRecord>;
let permission: string;
let updatedAtMs: number;
try {
request = normalizeApprovalRequestRecord(value);
permission = normalizeProjectPermission(request.action.permission);
updatedAtMs = approvalRequestUpdatedAtMs(request);
} catch {
return null;
}
if (
request.projectId !== projectId ||
!boundedText(request.id, 128) ||
!before(updatedAtMs, request.id, boundary) ||
!integer(request.version, 1, MAX_INT) ||
!APPROVAL_REQUEST_STATES.includes(request.state) ||
!APPROVAL_RISKS.includes(request.risk) ||
!APPROVAL_DECISION_MODES.includes(request.decisionMode) ||
!boundedText(permission, 255) ||
!boundedText(request.action.actionType, 128) ||
!SECURITY_SUBJECT_TYPES.includes(request.requestedBy.type) ||
!integer(request.requestedAtMs, 0, Number.MAX_SAFE_INTEGER) ||
!integer(request.expiresAtMs, 1, Number.MAX_SAFE_INTEGER) ||
(request.decidedAtMs !== null &&
!integer(request.decidedAtMs, 0, Number.MAX_SAFE_INTEGER)) ||
(request.consumedAtMs !== null &&
!integer(request.consumedAtMs, 0, Number.MAX_SAFE_INTEGER))
) {
return null;
}
return Object.freeze({
requestId: request.id,
version: request.version,
state: request.state,
risk: request.risk,
decisionMode: request.decisionMode,
permission,
actionType: request.action.actionType,
requestedByType: request.requestedBy.type,
requestedAtMs: request.requestedAtMs,
expiresAtMs: request.expiresAtMs,
...(request.decidedAtMs === null
? {}
: { decidedAtMs: request.decidedAtMs }),
...(request.consumedAtMs === null
? {}
: { consumedAtMs: request.consumedAtMs }),
updatedAtMs,
});
}
export async function executeBuiltInApprovalListTool(
source: Pick<ApprovalRequestSource, 'listApprovalRequests'>,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const record =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
const keys = record ? Reflect.ownKeys(record) : [];
if (
typeof source?.listApprovalRequests !== 'function' ||
!boundedText(projectId, 128) ||
!record ||
keys.length > 2 ||
keys.some((key) => key !== 'after' && key !== 'limit') ||
(record.limit !== undefined &&
!integer(record.limit, 1, BUILTIN_APPROVAL_LIST_MAX_LIMIT))
) {
return invalid('execution context or input is invalid');
}
const after = cursor(record.after);
const limit = record.limit ?? BUILTIN_APPROVAL_LIST_DEFAULT_LIMIT;
let page;
try {
page = await source.listApprovalRequests({
projectId,
limit,
...(after ? { after } : {}),
});
} catch {
throw new BuiltInApprovalListToolUnavailableError();
}
if (
!page ||
!Array.isArray(page.requests) ||
page.requests.length > limit ||
typeof page.truncated !== 'boolean' ||
page.truncated !== Boolean(page.next)
) {
throw new BuiltInApprovalListToolUnavailableError();
}
const approvals: Readonly<Record<string, ToolJsonValue>>[] = [];
let boundary = after;
for (const request of page.requests) {
const projected = projectApproval(request, projectId, boundary);
if (!projected) throw new BuiltInApprovalListToolUnavailableError();
approvals.push(projected);
boundary = Object.freeze({
updatedAtMs: projected.updatedAtMs as number,
requestId: projected.requestId as string,
});
}
if (
page.truncated &&
(!page.next ||
Reflect.ownKeys(page.next).length !== 2 ||
!boundary ||
page.next.updatedAtMs !== boundary.updatedAtMs ||
page.next.requestId !== boundary.requestId ||
approvals.length === 0)
) {
throw new BuiltInApprovalListToolUnavailableError();
}
return Object.freeze({
approvals: Object.freeze(approvals),
hasMore: page.truncated,
...(page.truncated ? { next: Object.freeze({ ...boundary! }) } : {}),
});
}
@@ -0,0 +1,197 @@
import { RUN_EVENT_ACTOR_TYPES } from '@qinglong/runtime-core/run';
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
import {
BoundedRunEventListProjectionUnavailableError,
DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT,
InvalidBoundedRunEventListProjectionError,
MAX_BOUNDED_RUN_EVENT_LIST_LIMIT,
executeBoundedRunEventListProjection,
} from '@qinglong/runtime-core/bounded-run-event-list-projection';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_RUN_EVENT_LIST_TOOL = Object.freeze({
name: 'qinglong.run.events.list',
version: '1.0.0',
});
export const BUILTIN_RUN_EVENT_LIST_TIMEOUT_SECONDS = 5;
export const BUILTIN_RUN_EVENT_LIST_DEFAULT_LIMIT =
DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT;
export const BUILTIN_RUN_EVENT_LIST_MAX_LIMIT =
MAX_BOUNDED_RUN_EVENT_LIST_LIMIT;
const MAX_INT = 2_147_483_647;
export const BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_RUN_EVENT_LIST_TOOL.name,
version: BUILTIN_RUN_EVENT_LIST_TOOL.version,
description: 'List bounded low-sensitive events for one Project-scoped Run',
inputSchema: {
type: 'object',
properties: {
runId: { type: 'string', minLength: 1, maxLength: 128 },
afterSequence: { type: 'integer', minimum: 0, maximum: MAX_INT },
limit: {
type: 'integer',
minimum: 1,
maximum: BUILTIN_RUN_EVENT_LIST_MAX_LIMIT,
},
},
required: ['runId'],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
found: { type: 'boolean' },
events: {
type: 'array',
maxItems: BUILTIN_RUN_EVENT_LIST_MAX_LIMIT,
items: {
type: 'object',
properties: {
sequence: { type: 'integer', minimum: 0, maximum: MAX_INT },
type: { type: 'string', minLength: 1, maxLength: 128 },
actorType: {
type: 'string',
maxLength: 32,
enum: RUN_EVENT_ACTOR_TYPES,
},
createdAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
},
required: ['sequence', 'type', 'actorType', 'createdAtMs'],
additionalProperties: false,
},
},
hasMore: { type: 'boolean' },
nextAfterSequence: {
type: 'integer',
minimum: 0,
maximum: MAX_INT,
},
},
required: ['found', 'events', 'hasMore', 'nextAfterSequence'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['run.read'],
timeoutSeconds: BUILTIN_RUN_EVENT_LIST_TIMEOUT_SECONDS,
});
export class InvalidBuiltInRunEventListToolError extends TypeError {
readonly code = 'BUILTIN_RUN_EVENT_LIST_TOOL_INVALID';
constructor(message: string) {
super(`Built-in Run event list Tool is invalid: ${message}`);
this.name = 'InvalidBuiltInRunEventListToolError';
}
}
export class BuiltInRunEventListToolUnavailableError extends Error {
readonly code = 'BUILTIN_RUN_EVENT_LIST_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Run event list Tool is unavailable');
this.name = 'BuiltInRunEventListToolUnavailableError';
}
}
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
function invalid(message: string): never {
throw new InvalidBuiltInRunEventListToolError(message);
}
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
export async function executeBuiltInRunEventListTool(
runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'>,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const inputRecord =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
const inputKeys = inputRecord ? Reflect.ownKeys(inputRecord) : [];
if (
!runs ||
typeof runs.findRunById !== 'function' ||
typeof runs.listEvents !== 'function' ||
!boundedText(projectId, 128) ||
!inputRecord ||
inputKeys.length < 1 ||
inputKeys.length > 3 ||
inputKeys.some(
(key) => key !== 'runId' && key !== 'afterSequence' && key !== 'limit',
) ||
!Object.hasOwn(inputRecord, 'runId') ||
!boundedText(inputRecord.runId, 128) ||
(inputRecord.afterSequence !== undefined &&
!integer(inputRecord.afterSequence, 0, MAX_INT)) ||
(inputRecord.limit !== undefined &&
!integer(inputRecord.limit, 1, BUILTIN_RUN_EVENT_LIST_MAX_LIMIT))
) {
return invalid('execution context or input is invalid');
}
const runId = inputRecord.runId;
const afterSequence = inputRecord.afterSequence ?? 0;
const limit = inputRecord.limit ?? BUILTIN_RUN_EVENT_LIST_DEFAULT_LIMIT;
try {
const projection = await executeBoundedRunEventListProjection(
runs,
projectId,
runId,
{ afterSequence, limit },
);
return Object.freeze({
found: projection.found,
events: Object.freeze(
projection.events.map((event) =>
Object.freeze({
sequence: event.sequence,
type: event.type,
actorType: event.actorType,
createdAtMs: event.createdAtMs,
}),
),
),
hasMore: projection.hasMore,
nextAfterSequence: projection.nextAfterSequence,
});
} catch (error) {
if (error instanceof InvalidBoundedRunEventListProjectionError) {
return invalid('execution context or input is invalid');
}
if (error instanceof BoundedRunEventListProjectionUnavailableError) {
throw new BuiltInRunEventListToolUnavailableError();
}
throw new BuiltInRunEventListToolUnavailableError();
}
}
@@ -0,0 +1,260 @@
import {
EXECUTION_ORIGINS,
RUN_STATUSES,
} from '@qinglong/runtime-core/run';
import {
type ProjectRunListCursor,
type ProjectRunListReader,
} from '@qinglong/runtime-core/project-run-list';
import {
BoundedRunListProjectionUnavailableError,
DEFAULT_BOUNDED_RUN_LIST_LIMIT,
InvalidBoundedRunListProjectionError,
MAX_BOUNDED_RUN_LIST_LIMIT,
executeBoundedRunListProjection,
} from '@qinglong/runtime-core/bounded-run-list-projection';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_RUN_LIST_TOOL = Object.freeze({
name: 'qinglong.run.list',
version: '1.0.0',
});
export const BUILTIN_RUN_LIST_TIMEOUT_SECONDS = 5;
export const BUILTIN_RUN_LIST_DEFAULT_LIMIT = DEFAULT_BOUNDED_RUN_LIST_LIMIT;
export const BUILTIN_RUN_LIST_MAX_LIMIT = MAX_BOUNDED_RUN_LIST_LIMIT;
const MAX_INT = 2_147_483_647;
const MIN_INT = -2_147_483_648;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
const RUN_LIST_ITEM_SCHEMA = Object.freeze({
type: 'object',
properties: {
id: { type: 'string', minLength: 1, maxLength: 128 },
taskId: { type: 'string', minLength: 1, maxLength: 255 },
taskRevision: { type: 'string', minLength: 1, maxLength: 255 },
status: { type: 'string', maxLength: 32, enum: RUN_STATUSES },
version: { type: 'integer', minimum: 0, maximum: MAX_INT },
eventSequence: { type: 'integer', minimum: 0, maximum: MAX_INT },
priority: { type: 'integer', minimum: MIN_INT, maximum: MAX_INT },
executionOrigin: {
type: 'string',
maxLength: 32,
enum: EXECUTION_ORIGINS,
},
executionOwner: {
type: 'string',
maxLength: 16,
enum: ['legacy', 'runtime'],
},
createdAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
queuedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
startedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
finishedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
},
required: [
'id',
'taskId',
'taskRevision',
'status',
'version',
'eventSequence',
'priority',
'executionOrigin',
'executionOwner',
'createdAtMs',
],
additionalProperties: false,
});
const RUN_LIST_CURSOR_SCHEMA = Object.freeze({
type: 'object',
properties: {
createdAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
runId: { type: 'string', minLength: 1, maxLength: 128 },
},
required: ['createdAtMs', 'runId'],
additionalProperties: false,
});
export const BUILTIN_RUN_LIST_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_RUN_LIST_TOOL.name,
version: BUILTIN_RUN_LIST_TOOL.version,
description: 'List recent low-sensitive Runs in the authenticated Project',
inputSchema: {
type: 'object',
properties: {
after: RUN_LIST_CURSOR_SCHEMA,
limit: {
type: 'integer',
minimum: 1,
maximum: BUILTIN_RUN_LIST_MAX_LIMIT,
},
},
required: [],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
runs: {
type: 'array',
maxItems: BUILTIN_RUN_LIST_MAX_LIMIT,
items: RUN_LIST_ITEM_SCHEMA,
},
hasMore: { type: 'boolean' },
next: RUN_LIST_CURSOR_SCHEMA,
},
required: ['runs', 'hasMore'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['run.read'],
timeoutSeconds: BUILTIN_RUN_LIST_TIMEOUT_SECONDS,
});
export class InvalidBuiltInRunListToolError extends TypeError {
readonly code = 'BUILTIN_RUN_LIST_TOOL_INVALID';
constructor(message: string) {
super(`Built-in Run list Tool is invalid: ${message}`);
this.name = 'InvalidBuiltInRunListToolError';
}
}
export class BuiltInRunListToolUnavailableError extends Error {
readonly code = 'BUILTIN_RUN_LIST_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Run list Tool is unavailable');
this.name = 'BuiltInRunListToolUnavailableError';
}
}
function invalid(message: string): never {
throw new InvalidBuiltInRunListToolError(message);
}
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
function normalizeCursor(
value: ToolJsonValue | undefined,
): ProjectRunListCursor | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return invalid('cursor is invalid');
}
const cursor = value as Readonly<Record<string, ToolJsonValue>>;
if (
Reflect.ownKeys(cursor).length !== 2 ||
!Object.hasOwn(cursor, 'createdAtMs') ||
!Object.hasOwn(cursor, 'runId') ||
!integer(cursor.createdAtMs, 0, Number.MAX_SAFE_INTEGER) ||
!boundedText(cursor.runId, 128)
) {
return invalid('cursor is invalid');
}
return Object.freeze({
createdAtMs: cursor.createdAtMs,
runId: cursor.runId,
});
}
export async function executeBuiltInRunListTool(
runs: ProjectRunListReader,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const inputRecord =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
const inputKeys = inputRecord ? Reflect.ownKeys(inputRecord) : [];
if (
!runs ||
typeof runs.listRunsByProject !== 'function' ||
!boundedText(projectId, 128) ||
!inputRecord ||
inputKeys.length > 2 ||
inputKeys.some((key) => key !== 'after' && key !== 'limit') ||
(inputRecord.limit !== undefined &&
!integer(inputRecord.limit, 1, BUILTIN_RUN_LIST_MAX_LIMIT))
) {
return invalid('execution context or input is invalid');
}
const after = normalizeCursor(inputRecord.after);
const limit = inputRecord.limit ?? BUILTIN_RUN_LIST_DEFAULT_LIMIT;
let result;
try {
result = await executeBoundedRunListProjection(runs, projectId, {
limit,
...(after === undefined ? {} : { after }),
});
} catch (error) {
if (error instanceof InvalidBoundedRunListProjectionError) {
return invalid('execution context or input is invalid');
}
if (!(error instanceof BoundedRunListProjectionUnavailableError)) {
throw error;
}
throw new BuiltInRunListToolUnavailableError();
}
const projected: readonly ToolJsonValue[] = result.runs.map((run) =>
Object.freeze({ ...run }),
);
return Object.freeze({
runs: Object.freeze(projected),
hasMore: result.hasMore,
...(result.next === undefined
? {}
: {
next: Object.freeze({
createdAtMs: result.next.createdAtMs,
runId: result.next.runId,
}),
}),
});
}
@@ -0,0 +1,275 @@
import {
BoundedRunStepListProjectionUnavailableError,
DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT,
InvalidBoundedRunStepListProjectionError,
MAX_BOUNDED_RUN_STEP_LIST_LIMIT,
executeBoundedRunStepListProjection,
} from '@qinglong/runtime-core/bounded-run-step-list-projection';
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
import {
MAX_STEP_RUN_ATTEMPTS,
STEP_RUN_KINDS,
STEP_RUN_STATUSES,
type StepRunRepository,
} from '@qinglong/runtime-core/step-run';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_RUN_STEP_LIST_TOOL = Object.freeze({
name: 'qinglong.run.steps.list',
version: '1.0.0',
});
export const BUILTIN_RUN_STEP_LIST_TIMEOUT_SECONDS = 5;
export const BUILTIN_RUN_STEP_LIST_DEFAULT_LIMIT =
DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT;
export const BUILTIN_RUN_STEP_LIST_MAX_LIMIT = MAX_BOUNDED_RUN_STEP_LIST_LIMIT;
const MAX_INT = 2_147_483_647;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
export const BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_RUN_STEP_LIST_TOOL.name,
version: BUILTIN_RUN_STEP_LIST_TOOL.version,
description: 'List bounded low-sensitive Steps for one Project-scoped Run',
inputSchema: {
type: 'object',
properties: {
runId: { type: 'string', minLength: 1, maxLength: 128 },
afterStepKey: { type: 'string', minLength: 1, maxLength: 128 },
afterStepRunId: { type: 'string', minLength: 1, maxLength: 128 },
limit: {
type: 'integer',
minimum: 1,
maximum: BUILTIN_RUN_STEP_LIST_MAX_LIMIT,
},
},
required: ['runId'],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
found: { type: 'boolean' },
steps: {
type: 'array',
maxItems: BUILTIN_RUN_STEP_LIST_MAX_LIMIT,
items: {
type: 'object',
properties: {
id: { type: 'string', minLength: 1, maxLength: 128 },
parentStepRunId: {
type: 'string',
minLength: 1,
maxLength: 128,
},
stepKey: { type: 'string', minLength: 1, maxLength: 128 },
kind: {
type: 'string',
minLength: 1,
maxLength: 32,
enum: STEP_RUN_KINDS,
},
required: { type: 'boolean' },
status: {
type: 'string',
minLength: 1,
maxLength: 32,
enum: STEP_RUN_STATUSES,
},
version: { type: 'integer', minimum: 1, maximum: MAX_INT },
attemptCount: {
type: 'integer',
minimum: 0,
maximum: MAX_STEP_RUN_ATTEMPTS,
},
readyAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
startedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
finishedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
resultCode: { type: 'string', minLength: 1, maxLength: 64 },
createdAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
updatedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
},
required: [
'id',
'stepKey',
'kind',
'required',
'status',
'version',
'attemptCount',
'createdAtMs',
'updatedAtMs',
],
additionalProperties: false,
},
},
hasMore: { type: 'boolean' },
next: {
type: 'object',
properties: {
stepKey: { type: 'string', minLength: 1, maxLength: 128 },
stepRunId: { type: 'string', minLength: 1, maxLength: 128 },
},
required: ['stepKey', 'stepRunId'],
additionalProperties: false,
},
},
required: ['found', 'steps', 'hasMore'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['run.read'],
timeoutSeconds: BUILTIN_RUN_STEP_LIST_TIMEOUT_SECONDS,
});
export class InvalidBuiltInRunStepListToolError extends TypeError {
readonly code = 'BUILTIN_RUN_STEP_LIST_TOOL_INVALID';
constructor(message: string) {
super(`Built-in Run Step list Tool is invalid: ${message}`);
this.name = 'InvalidBuiltInRunStepListToolError';
}
}
export class BuiltInRunStepListToolUnavailableError extends Error {
readonly code = 'BUILTIN_RUN_STEP_LIST_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Run Step list Tool is unavailable');
this.name = 'BuiltInRunStepListToolUnavailableError';
}
}
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
function invalid(message: string): never {
throw new InvalidBuiltInRunStepListToolError(message);
}
export async function executeBuiltInRunStepListTool(
runs: Pick<RunRepositoryReader, 'findRunById'>,
stepRuns: Pick<StepRunRepository, 'listByRun'>,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const value =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
const keys = value ? Reflect.ownKeys(value) : [];
const afterStepKey = value?.afterStepKey;
const afterStepRunId = value?.afterStepRunId;
if (
!runs ||
typeof runs.findRunById !== 'function' ||
!stepRuns ||
typeof stepRuns.listByRun !== 'function' ||
!boundedText(projectId, 128) ||
!value ||
keys.length < 1 ||
keys.length > 4 ||
keys.some(
(key) =>
key !== 'runId' &&
key !== 'afterStepKey' &&
key !== 'afterStepRunId' &&
key !== 'limit',
) ||
!Object.hasOwn(value, 'runId') ||
!boundedText(value.runId, 128) ||
(afterStepKey === undefined) !== (afterStepRunId === undefined) ||
(afterStepKey !== undefined && !boundedText(afterStepKey, 128)) ||
(afterStepRunId !== undefined && !boundedText(afterStepRunId, 128)) ||
(value.limit !== undefined &&
(!Number.isSafeInteger(value.limit) ||
Number(value.limit) < 1 ||
Number(value.limit) > BUILTIN_RUN_STEP_LIST_MAX_LIMIT))
) {
return invalid('execution context or input is invalid');
}
try {
const projection = await executeBoundedRunStepListProjection(
runs,
stepRuns,
projectId,
value.runId,
{
limit: Number(value.limit ?? BUILTIN_RUN_STEP_LIST_DEFAULT_LIMIT),
...(afterStepKey === undefined || afterStepRunId === undefined
? {}
: { after: { stepKey: afterStepKey, stepRunId: afterStepRunId } }),
},
);
return Object.freeze({
found: projection.found,
steps: Object.freeze(
projection.steps.map((step) =>
Object.freeze({
id: step.id,
...(step.parentStepRunId === null
? {}
: { parentStepRunId: step.parentStepRunId }),
stepKey: step.stepKey,
kind: step.kind,
required: step.required,
status: step.status,
version: step.version,
attemptCount: step.attemptCount,
...(step.readyAtMs === null ? {} : { readyAtMs: step.readyAtMs }),
...(step.startedAtMs === null
? {}
: { startedAtMs: step.startedAtMs }),
...(step.finishedAtMs === null
? {}
: { finishedAtMs: step.finishedAtMs }),
...(step.resultCode === null
? {}
: { resultCode: step.resultCode }),
createdAtMs: step.createdAtMs,
updatedAtMs: step.updatedAtMs,
}),
),
),
hasMore: projection.hasMore,
...(projection.next === null ? {} : { next: projection.next }),
});
} catch (error) {
if (error instanceof InvalidBoundedRunStepListProjectionError) {
return invalid('execution context or input is invalid');
}
if (error instanceof BoundedRunStepListProjectionUnavailableError) {
throw new BuiltInRunStepListToolUnavailableError();
}
throw new BuiltInRunStepListToolUnavailableError();
}
}
@@ -0,0 +1,114 @@
import {
TASK_DEFINITION_KINDS,
type TaskDefinitionSource,
} from '@qinglong/runtime-core/task-definition';
import {
BoundedTaskReadProjectionUnavailableError,
InvalidBoundedTaskReadProjectionError,
executeBoundedTaskReadProjection,
} from '@qinglong/runtime-core/bounded-task-read-projection';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_TASK_GET_TOOL = Object.freeze({
name: 'qinglong.task.get',
version: '1.0.0',
});
export const BUILTIN_TASK_GET_TIMEOUT_SECONDS = 5;
export const BUILTIN_TASK_GET_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_TASK_GET_TOOL.name,
version: BUILTIN_TASK_GET_TOOL.version,
description: 'Read one current low-sensitive Task and its immutable fence',
inputSchema: {
type: 'object',
properties: {
taskId: { type: 'string', minLength: 1, maxLength: 128 },
},
required: ['taskId'],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
found: { type: 'boolean' },
taskId: { type: 'string', minLength: 1, maxLength: 128 },
revision: { type: 'integer', minimum: 1, maximum: 2_147_483_647 },
name: { type: 'string', minLength: 1, maxLength: 255 },
kind: { type: 'string', maxLength: 16, enum: TASK_DEFINITION_KINDS },
specSchema: { type: 'string', minLength: 1, maxLength: 137 },
enabled: { type: 'boolean' },
contentDigest: { type: 'string', minLength: 64, maxLength: 64 },
createdAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
updatedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
},
required: ['found'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['task.read'],
timeoutSeconds: BUILTIN_TASK_GET_TIMEOUT_SECONDS,
});
export class InvalidBuiltInTaskGetToolError extends TypeError {
readonly code = 'BUILTIN_TASK_GET_TOOL_INVALID';
constructor() {
super('Built-in Task get Tool input is invalid');
this.name = 'InvalidBuiltInTaskGetToolError';
}
}
export class BuiltInTaskGetToolUnavailableError extends Error {
readonly code = 'BUILTIN_TASK_GET_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Task get Tool is unavailable');
this.name = 'BuiltInTaskGetToolUnavailableError';
}
}
export async function executeBuiltInTaskGetTool(
source: Pick<TaskDefinitionSource, 'findCurrentTaskDefinition'>,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const record =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
if (
!record ||
Reflect.ownKeys(record).length !== 1 ||
!Object.hasOwn(record, 'taskId') ||
typeof record.taskId !== 'string'
) {
throw new InvalidBuiltInTaskGetToolError();
}
try {
return await executeBoundedTaskReadProjection(
source,
projectId,
record.taskId,
);
} catch (error) {
if (error instanceof InvalidBoundedTaskReadProjectionError) {
throw new InvalidBuiltInTaskGetToolError();
}
if (error instanceof BoundedTaskReadProjectionUnavailableError) {
throw new BuiltInTaskGetToolUnavailableError();
}
throw error;
}
}
@@ -0,0 +1,156 @@
import {
TASK_DEFINITION_KINDS,
type TaskDefinitionSource,
} from '@qinglong/runtime-core/task-definition';
import {
BoundedTaskListProjectionUnavailableError,
DEFAULT_BOUNDED_TASK_LIST_LIMIT,
InvalidBoundedTaskListProjectionError,
MAX_BOUNDED_TASK_LIST_LIMIT,
executeBoundedTaskListProjection,
} from '@qinglong/runtime-core/bounded-task-list-projection';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_TASK_LIST_TOOL = Object.freeze({
name: 'qinglong.task.list',
version: '1.0.0',
});
export const BUILTIN_TASK_LIST_TIMEOUT_SECONDS = 5;
export const BUILTIN_TASK_LIST_DEFAULT_LIMIT = DEFAULT_BOUNDED_TASK_LIST_LIMIT;
export const BUILTIN_TASK_LIST_MAX_LIMIT = MAX_BOUNDED_TASK_LIST_LIMIT;
const TASK_LIST_CURSOR_SCHEMA = Object.freeze({
type: 'object',
properties: {
taskId: { type: 'string', minLength: 1, maxLength: 128 },
},
required: ['taskId'],
additionalProperties: false,
});
export const BUILTIN_TASK_LIST_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_TASK_LIST_TOOL.name,
version: BUILTIN_TASK_LIST_TOOL.version,
description: 'List current low-sensitive Tasks in the authenticated Project',
inputSchema: {
type: 'object',
properties: {
after: TASK_LIST_CURSOR_SCHEMA,
limit: {
type: 'integer',
minimum: 1,
maximum: BUILTIN_TASK_LIST_MAX_LIMIT,
},
},
required: [],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
tasks: {
type: 'array',
maxItems: BUILTIN_TASK_LIST_MAX_LIMIT,
items: {
type: 'object',
properties: {
taskId: { type: 'string', minLength: 1, maxLength: 128 },
revision: { type: 'integer', minimum: 1, maximum: 2_147_483_647 },
name: { type: 'string', minLength: 1, maxLength: 255 },
kind: {
type: 'string',
maxLength: 16,
enum: TASK_DEFINITION_KINDS,
},
specSchema: { type: 'string', minLength: 1, maxLength: 137 },
enabled: { type: 'boolean' },
updatedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
},
required: [
'taskId',
'revision',
'name',
'kind',
'specSchema',
'enabled',
'updatedAtMs',
],
additionalProperties: false,
},
},
hasMore: { type: 'boolean' },
next: TASK_LIST_CURSOR_SCHEMA,
},
required: ['tasks', 'hasMore'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['task.read'],
timeoutSeconds: BUILTIN_TASK_LIST_TIMEOUT_SECONDS,
});
export class InvalidBuiltInTaskListToolError extends TypeError {
readonly code = 'BUILTIN_TASK_LIST_TOOL_INVALID';
constructor(message: string) {
super(`Built-in Task list Tool is invalid: ${message}`);
this.name = 'InvalidBuiltInTaskListToolError';
}
}
export class BuiltInTaskListToolUnavailableError extends Error {
readonly code = 'BUILTIN_TASK_LIST_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Task list Tool is unavailable');
this.name = 'BuiltInTaskListToolUnavailableError';
}
}
function invalid(message: string): never {
throw new InvalidBuiltInTaskListToolError(message);
}
export async function executeBuiltInTaskListTool(
source: Pick<TaskDefinitionSource, 'listTaskDefinitions'>,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const record =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
if (!record) return invalid('execution context or input is invalid');
try {
const result = await executeBoundedTaskListProjection(
source,
projectId,
record,
);
return Object.freeze({
tasks: Object.freeze(
result.tasks.map((task) => Object.freeze({ ...task })),
),
hasMore: result.hasMore,
...(result.next === undefined
? {}
: { next: Object.freeze({ ...result.next }) }),
});
} catch (error) {
if (error instanceof InvalidBoundedTaskListProjectionError) {
return invalid('execution context or input is invalid');
}
if (error instanceof BoundedTaskListProjectionUnavailableError) {
throw new BuiltInTaskListToolUnavailableError();
}
throw error;
}
}
@@ -0,0 +1,257 @@
import type {
TriggerCursor,
TriggerRecord,
TriggerSource,
} from '@qinglong/runtime-core/trigger';
import {
normalizeToolDefinition,
type ToolJsonValue,
} from '@qinglong/runtime-core/tool-registry';
export const BUILTIN_TRIGGER_LIST_TOOL = Object.freeze({
name: 'qinglong.trigger.list',
version: '1.0.0',
});
export const BUILTIN_TRIGGER_LIST_TIMEOUT_SECONDS = 5;
export const BUILTIN_TRIGGER_LIST_DEFAULT_LIMIT = 32;
export const BUILTIN_TRIGGER_LIST_MAX_LIMIT = 64;
const MAX_INT = 2_147_483_647;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
const TRIGGER_SPEC_SCHEMA_PATTERN =
/^[a-z][a-z0-9.-]{0,63}\/[a-z][a-z0-9.-]{0,63}@v[1-9][0-9]{0,5}$/;
const TRIGGER_LIST_CURSOR_SCHEMA = Object.freeze({
type: 'object',
properties: {
triggerId: { type: 'string', minLength: 1, maxLength: 128 },
},
required: ['triggerId'],
additionalProperties: false,
});
export const BUILTIN_TRIGGER_LIST_TOOL_DEFINITION = normalizeToolDefinition({
name: BUILTIN_TRIGGER_LIST_TOOL.name,
version: BUILTIN_TRIGGER_LIST_TOOL.version,
description:
'List current low-sensitive Triggers in the authenticated Project',
inputSchema: {
type: 'object',
properties: {
after: TRIGGER_LIST_CURSOR_SCHEMA,
limit: {
type: 'integer',
minimum: 1,
maximum: BUILTIN_TRIGGER_LIST_MAX_LIMIT,
},
},
required: [],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
triggers: {
type: 'array',
maxItems: BUILTIN_TRIGGER_LIST_MAX_LIMIT,
items: {
type: 'object',
properties: {
triggerId: { type: 'string', minLength: 1, maxLength: 128 },
revision: { type: 'integer', minimum: 1, maximum: MAX_INT },
taskId: { type: 'string', minLength: 1, maxLength: 128 },
taskRevision: {
type: 'integer',
minimum: 1,
maximum: MAX_INT,
},
specSchema: { type: 'string', minLength: 1, maxLength: 137 },
enabled: { type: 'boolean' },
updatedAtMs: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
},
},
required: [
'triggerId',
'revision',
'taskId',
'taskRevision',
'specSchema',
'enabled',
'updatedAtMs',
],
additionalProperties: false,
},
},
hasMore: { type: 'boolean' },
next: TRIGGER_LIST_CURSOR_SCHEMA,
},
required: ['triggers', 'hasMore'],
additionalProperties: false,
},
effect: 'read',
risk: 'low',
requiredPermissions: ['trigger.read'],
timeoutSeconds: BUILTIN_TRIGGER_LIST_TIMEOUT_SECONDS,
});
export class InvalidBuiltInTriggerListToolError extends TypeError {
readonly code = 'BUILTIN_TRIGGER_LIST_TOOL_INVALID';
constructor(message: string) {
super(`Built-in Trigger list Tool is invalid: ${message}`);
this.name = 'InvalidBuiltInTriggerListToolError';
}
}
export class BuiltInTriggerListToolUnavailableError extends Error {
readonly code = 'BUILTIN_TRIGGER_LIST_TOOL_UNAVAILABLE';
constructor() {
super('Built-in Trigger list Tool is unavailable');
this.name = 'BuiltInTriggerListToolUnavailableError';
}
}
function invalid(message: string): never {
throw new InvalidBuiltInTriggerListToolError(message);
}
function boundedText(value: unknown, maximumBytes: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
!CONTROL_PATTERN.test(value)
);
}
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
function cursor(value: ToolJsonValue | undefined): TriggerCursor | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return invalid('cursor is invalid');
}
const record = value as Readonly<Record<string, ToolJsonValue>>;
if (
Reflect.ownKeys(record).length !== 1 ||
!Object.hasOwn(record, 'triggerId') ||
!boundedText(record.triggerId, 128)
) {
return invalid('cursor is invalid');
}
return Object.freeze({ triggerId: record.triggerId });
}
function projectTrigger(
value: TriggerRecord,
projectId: string,
after?: string,
): Readonly<Record<string, ToolJsonValue>> | null {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.projectId !== projectId ||
(after !== undefined && value.triggerId <= after) ||
!boundedText(value.triggerId, 128) ||
!integer(value.revision, 1, MAX_INT) ||
!boundedText(value.taskId, 128) ||
!integer(value.taskRevision, 1, MAX_INT) ||
!boundedText(value.spec?.schema, 137) ||
!TRIGGER_SPEC_SCHEMA_PATTERN.test(value.spec.schema) ||
typeof value.enabled !== 'boolean' ||
!integer(value.updatedAtMs, 0, Number.MAX_SAFE_INTEGER)
) {
return null;
}
return Object.freeze({
triggerId: value.triggerId,
revision: value.revision,
taskId: value.taskId,
taskRevision: value.taskRevision,
specSchema: value.spec.schema,
enabled: value.enabled,
updatedAtMs: value.updatedAtMs,
});
}
export async function executeBuiltInTriggerListTool(
source: Pick<TriggerSource, 'listTriggers'>,
projectId: string,
input: ToolJsonValue,
): Promise<Readonly<Record<string, ToolJsonValue>>> {
const record =
input && typeof input === 'object' && !Array.isArray(input)
? (input as Readonly<Record<string, ToolJsonValue>>)
: null;
const keys = record ? Reflect.ownKeys(record) : [];
if (
typeof source?.listTriggers !== 'function' ||
!boundedText(projectId, 128) ||
!record ||
keys.length > 2 ||
keys.some((key) => key !== 'after' && key !== 'limit') ||
(record.limit !== undefined &&
!integer(record.limit, 1, BUILTIN_TRIGGER_LIST_MAX_LIMIT))
) {
return invalid('execution context or input is invalid');
}
const after = cursor(record.after);
const limit = record.limit ?? BUILTIN_TRIGGER_LIST_DEFAULT_LIMIT;
let page;
try {
page = await source.listTriggers({
projectId,
limit,
...(after ? { after } : {}),
});
} catch {
throw new BuiltInTriggerListToolUnavailableError();
}
if (
!page ||
!Array.isArray(page.triggers) ||
page.triggers.length > limit ||
typeof page.truncated !== 'boolean' ||
page.truncated !== Boolean(page.next)
) {
throw new BuiltInTriggerListToolUnavailableError();
}
const triggers: Readonly<Record<string, ToolJsonValue>>[] = [];
let boundary = after?.triggerId;
for (const trigger of page.triggers) {
const projected = projectTrigger(trigger, projectId, boundary);
if (!projected) throw new BuiltInTriggerListToolUnavailableError();
triggers.push(projected);
boundary = trigger.triggerId;
}
if (
page.truncated &&
(!page.next ||
Reflect.ownKeys(page.next).length !== 1 ||
!boundedText(page.next.triggerId, 128) ||
page.next.triggerId !== boundary ||
triggers.length === 0)
) {
throw new BuiltInTriggerListToolUnavailableError();
}
return Object.freeze({
triggers: Object.freeze(triggers),
hasMore: page.truncated,
...(page.truncated ? { next: Object.freeze({ triggerId: boundary! }) } : {}),
});
}