MVP底座⑤:自定义工具导入路由 —— 与第一方同门两审
顺手补两处底座:试运行审此前传空连接,靠 endpoint 干活的工具一律过不了门(这道门 只对不需要连接的工具有效);现传本次申报的连接值。未注册第一方处理器的工具走通用 HTTP 实现 —— 导入的工具由此可真调、可真审:端点不通就是不通,不入目录。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -1531,6 +1531,55 @@ export declare const saveWorkGroupRoute: {
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 导入自定义工具 —— 与第一方工具走**同一条门**:静态审 + 试运行审。
|
||||||
|
*
|
||||||
|
* 工具承载权力,约束严于活动包:契约对活动、实现对系统、凭证对治理台(OS 7.3)。
|
||||||
|
* 连接配置**只声明键**;值随本次导入送到治理台加密落库,此后只读得到「是否已绑定」。
|
||||||
|
*/
|
||||||
|
export declare const registerToolRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/gov/tools";
|
||||||
|
readonly body: z.ZodObject<{
|
||||||
|
contract: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
|
description: z.ZodString;
|
||||||
|
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
output: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
effect: z.ZodEnum<{
|
||||||
|
read: "read";
|
||||||
|
write: "write";
|
||||||
|
}>;
|
||||||
|
errors: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
code: z.ZodString;
|
||||||
|
retryable: z.ZodBoolean;
|
||||||
|
message: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
idempotent: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
||||||
|
retries: z.ZodDefault<z.ZodNumber>;
|
||||||
|
connectionKeys: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
connectionValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
samples: z.ZodArray<z.ZodObject<{
|
||||||
|
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
accepted: z.ZodBoolean;
|
||||||
|
findings: z.ZodArray<z.ZodObject<{
|
||||||
|
code: z.ZodString;
|
||||||
|
severity: z.ZodString;
|
||||||
|
message: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
trialRun: z.ZodObject<{
|
||||||
|
ran: z.ZodNumber;
|
||||||
|
passed: z.ZodNumber;
|
||||||
|
failures: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
export declare const listActivitiesRoute: {
|
export declare const listActivitiesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/activities";
|
readonly path: "/api/gov/activities";
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import type { TriggerService } from '@modules/trigger';
|
|||||||
import type { MinuteService } from '@modules/minute';
|
import type { MinuteService } from '@modules/minute';
|
||||||
import type { ReportService } from '@modules/report';
|
import type { ReportService } from '@modules/report';
|
||||||
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
||||||
|
import { httpToolHandler } from './tools/http-tool.ts';
|
||||||
import { wireEngines } from './wiring/engines.ts';
|
import { wireEngines } from './wiring/engines.ts';
|
||||||
import { wireDeskElements } from './wiring/desk-elements.ts';
|
import { wireDeskElements } from './wiring/desk-elements.ts';
|
||||||
import type { ChannelService } from '@modules/channel';
|
import type { ChannelService } from '@modules/channel';
|
||||||
@@ -262,7 +263,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toolRegistry = createToolRegistry(db);
|
const toolRegistry = createToolRegistry(db);
|
||||||
const toolRuntime = createToolRuntime(firstPartyToolHandlers);
|
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
||||||
|
const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);
|
||||||
const schemas = createSchemaValidator();
|
const schemas = createSchemaValidator();
|
||||||
const toolbox = createToolboxService({
|
const toolbox = createToolboxService({
|
||||||
registry: toolRegistry,
|
registry: toolRegistry,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
listToolsRoute,
|
listToolsRoute,
|
||||||
listTriggersRoute,
|
listTriggersRoute,
|
||||||
publishProcessRoute,
|
publishProcessRoute,
|
||||||
|
registerToolRoute,
|
||||||
saveProcessRoute,
|
saveProcessRoute,
|
||||||
reassignTaskRoute,
|
reassignTaskRoute,
|
||||||
resumeTaskRoute,
|
resumeTaskRoute,
|
||||||
@@ -63,6 +64,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
const governTrigger = guard('trigger', 'govern');
|
const governTrigger = guard('trigger', 'govern');
|
||||||
const readActivity = guard('activity', 'read');
|
const readActivity = guard('activity', 'read');
|
||||||
const readTool = guard('tool', 'read');
|
const readTool = guard('tool', 'read');
|
||||||
|
const governTool = guard('tool', 'govern');
|
||||||
const readData = guard('data', 'read');
|
const readData = guard('data', 'read');
|
||||||
const readMemory = guard('memory', 'read');
|
const readMemory = guard('memory', 'read');
|
||||||
|
|
||||||
@@ -376,6 +378,32 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/* ── 工具管理 ────────────────────────────────────────────────────── */
|
/* ── 工具管理 ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入自定义工具 —— 与第一方走同一条门:静态审 + 试运行审,过了才入目录。
|
||||||
|
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
||||||
|
*/
|
||||||
|
app.post(registerToolRoute.path, async (c) => {
|
||||||
|
const denied = await governTool(c.get('account'));
|
||||||
|
if (denied) return c.json(denied, denyStatus(denied));
|
||||||
|
const parsed = registerToolRoute.body.safeParse(await c.req.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
|
||||||
|
return c.json(body, 400);
|
||||||
|
}
|
||||||
|
const { contract, samples, connectionValues } = parsed.data;
|
||||||
|
const verdict = await container.toolbox.register({
|
||||||
|
contract,
|
||||||
|
// 期望形状统一按「出参是个对象」收:细的形状由契约 output schema 自己说
|
||||||
|
samples: samples.map((s) => ({ input: s.input, expectShape: { type: 'object' } })),
|
||||||
|
...(connectionValues === undefined ? {} : { connectionValues }),
|
||||||
|
});
|
||||||
|
return c.json({
|
||||||
|
accepted: verdict.accepted,
|
||||||
|
findings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })),
|
||||||
|
trialRun: { ran: verdict.trialRun.ran, passed: verdict.trialRun.passed, failures: [...verdict.trialRun.failures] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get(listToolsRoute.path, async (c) => {
|
app.get(listToolsRoute.path, async (c) => {
|
||||||
const denied = await readTool(c.get('account'));
|
const denied = await readTool(c.get('account'));
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
if (denied) return c.json(denied, denyStatus(denied));
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 导入工具的通用实现 —— 「实现对系统」的兜底一件(编码规范 §3;语义出处 OS 7.3)。
|
||||||
|
*
|
||||||
|
* 第一方工具各有各的处理器;**导入进来的自定义工具**没有处理器,
|
||||||
|
* 但它在契约里申报了 `endpoint` 连接键 —— 这个处理器就把入参 POST 到那个端点。
|
||||||
|
*
|
||||||
|
* 运行时受限照旧:只拿得到申报的连接目标,没有别的环境能力。
|
||||||
|
* 端点不通 = 试运行审不过 = 进不了目录 —— 这是**门**,不是障碍:
|
||||||
|
* 一件调不通的工具入了目录,活动调它时才失败,那才是真的坏。
|
||||||
|
*/
|
||||||
|
import type { ToolHandler } from '@modules/toolbox';
|
||||||
|
|
||||||
|
/** 未在第一方注册的工具:申报了 endpoint 就按 HTTP 调,没申报就如实说没有实现 */
|
||||||
|
export const httpToolHandler: ToolHandler = async ({ input, connection, operation, idempotencyKey }) => {
|
||||||
|
const endpoint = connection['endpoint'];
|
||||||
|
if (endpoint === undefined || endpoint.trim() === '') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'tool-unimplemented',
|
||||||
|
retryable: false,
|
||||||
|
message: '工具无实现:未申报 endpoint 连接键,系统侧也没有第一方处理器',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//.test(endpoint)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'endpoint-invalid', retryable: false, message: `端点须是 http(s) 地址:${endpoint}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
// 幂等键透传给对端:写类动作重投时由对端去重(契约声明 idempotent 才有意义)
|
||||||
|
...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ input, ...(operation === undefined ? {} : { operation }) }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: `http-${response.status}`,
|
||||||
|
// 5xx 可重试,4xx 是我们自己请求得不对,重试没用
|
||||||
|
retryable: response.status >= 500,
|
||||||
|
message: `端点返回 ${response.status}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body: unknown = await response.json();
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return { ok: false, error: { code: 'output-invalid', retryable: false, message: '端点返回的不是 JSON 对象' } };
|
||||||
|
}
|
||||||
|
return { ok: true, output: body as Record<string, unknown> };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'endpoint-unreachable',
|
||||||
|
retryable: true,
|
||||||
|
message: `端点调用失败:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -33,4 +33,11 @@
|
|||||||
- **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值
|
- **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值
|
||||||
- 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功
|
- 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功
|
||||||
|
|
||||||
|
近变(2026-07-28):模块面未变,**试运行审的行为修正** ——
|
||||||
|
此前 `register` 给试运行传空连接,靠 `endpoint` 干活的工具永远「无实现」过不了门,
|
||||||
|
这道门于是只对不需要连接的工具有效;现改为传入本次注册申报的 `connectionValues`。
|
||||||
|
运行时仍只把**契约申报过的键**交给处理器,凭证隔离不变。
|
||||||
|
app 侧配套:未注册第一方处理器的工具走通用 HTTP 实现(`tools/http-tool.ts`),
|
||||||
|
导入的自定义工具由此可真调、可真审。
|
||||||
|
|
||||||
影响半径: index/contract/port 未变 → 对外零影响
|
影响半径: index/contract/port 未变 → 对外零影响
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 注册两审之**试运行审** —— 这道门要真调得动工具才算数。
|
||||||
|
*
|
||||||
|
* 守一条曾经形同虚设的规矩:靠连接干活的工具,试运行必须拿到本次申报的连接值。
|
||||||
|
* 从前这里传空对象,凡是需要 `endpoint` 的工具一律「无实现」过不了门 ——
|
||||||
|
* 于是这道门只对不需要连接的工具有效。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createToolboxService } from '../internal/usecase/toolbox-service.ts';
|
||||||
|
import type { ToolContract } from '../internal/domain/tool-contract.ts';
|
||||||
|
|
||||||
|
const CONTRACT: ToolContract = {
|
||||||
|
name: 'erp.stock-read',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: '读取 ERP 现货库存:PN → 库存量',
|
||||||
|
input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } },
|
||||||
|
output: { type: 'object', properties: { qty: { type: 'number' } } },
|
||||||
|
effect: 'read',
|
||||||
|
errors: [],
|
||||||
|
idempotent: true,
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
retries: 1,
|
||||||
|
connectionKeys: ['endpoint'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 只认端点的处理器 —— 拿不到 endpoint 就报无实现,正是导入工具的真实处境 */
|
||||||
|
const runtime = {
|
||||||
|
execute: vi.fn(async ({ connection }: { connection: Readonly<Record<string, string>> }) =>
|
||||||
|
connection['endpoint'] === undefined
|
||||||
|
? { ok: false as const, error: { code: 'tool-unimplemented', retryable: false, message: '工具无实现' } }
|
||||||
|
: { ok: true as const, output: { qty: 120 } },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function serviceWith() {
|
||||||
|
const saved: Array<{ contract: ToolContract; cipher: unknown }> = [];
|
||||||
|
const toolbox = createToolboxService({
|
||||||
|
registry: {
|
||||||
|
get: async () => undefined,
|
||||||
|
upsert: async (contract: ToolContract, cipher: unknown) => {
|
||||||
|
saved.push({ contract, cipher });
|
||||||
|
},
|
||||||
|
list: async () => [],
|
||||||
|
setAllowlist: async () => undefined,
|
||||||
|
allowlistOf: async () => [],
|
||||||
|
connectionOf: async () => ({}),
|
||||||
|
setEnabled: async () => undefined,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||||
|
secrets: { seal: (v: string) => `sealed:${v}`, open: (v: string) => v.replace('sealed:', '') },
|
||||||
|
ledger: { register: async () => undefined },
|
||||||
|
clock: { now: () => new Date('2026-07-28T00:00:00Z') },
|
||||||
|
} as never);
|
||||||
|
return { toolbox, saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('register —— 试运行审拿得到连接值', () => {
|
||||||
|
it('申报了 endpoint:试运行按真连接跑,过门入目录', async () => {
|
||||||
|
const { toolbox, saved } = serviceWith();
|
||||||
|
const verdict = await toolbox.register({
|
||||||
|
contract: CONTRACT,
|
||||||
|
samples: [{ input: { pn: 'LM358DR' }, expectShape: { type: 'object' } }],
|
||||||
|
connectionValues: { endpoint: 'http://localhost:9099/stock' },
|
||||||
|
});
|
||||||
|
expect(verdict.trialRun.passed).toBe(1);
|
||||||
|
expect(verdict.accepted).toBe(true);
|
||||||
|
// 连接值加密落库,不明文存
|
||||||
|
expect(saved[0]?.cipher).toEqual({ endpoint: 'sealed:http://localhost:9099/stock' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没给连接值:试运行如实失败,门拦住 —— 调不通的工具不入目录', async () => {
|
||||||
|
const { toolbox, saved } = serviceWith();
|
||||||
|
const verdict = await toolbox.register({
|
||||||
|
contract: CONTRACT,
|
||||||
|
samples: [{ input: { pn: 'LM358DR' }, expectShape: { type: 'object' } }],
|
||||||
|
});
|
||||||
|
expect(verdict.accepted).toBe(false);
|
||||||
|
expect(verdict.trialRun.failures[0]).toContain('工具无实现');
|
||||||
|
expect(saved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -170,10 +170,17 @@ export type ToolHandler = (input: {
|
|||||||
readonly idempotencyKey: string | undefined;
|
readonly idempotencyKey: string | undefined;
|
||||||
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
|
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
|
||||||
|
|
||||||
export function createToolRuntime(handlers: ReadonlyMap<string, ToolHandler>): ToolRuntime {
|
/**
|
||||||
|
* `fallback` 供**导入进来的工具**用:第一方各有处理器,导入的没有 ——
|
||||||
|
* 由 app 给一个通用实现(按申报的 endpoint 调),没给就如实报「无实现」。
|
||||||
|
*/
|
||||||
|
export function createToolRuntime(
|
||||||
|
handlers: ReadonlyMap<string, ToolHandler>,
|
||||||
|
fallback?: ToolHandler,
|
||||||
|
): ToolRuntime {
|
||||||
return {
|
return {
|
||||||
async execute({ contract, connection, call, idempotencyKey }) {
|
async execute({ contract, connection, call, idempotencyKey }) {
|
||||||
const handler = handlers.get(contract.name);
|
const handler = handlers.get(contract.name) ?? fallback;
|
||||||
if (handler === undefined) {
|
if (handler === undefined) {
|
||||||
return { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: `工具无实现:${contract.name}` } };
|
return { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: `工具无实现:${contract.name}` } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ export function createToolboxService(deps: ToolboxServiceDeps): ToolboxService {
|
|||||||
}
|
}
|
||||||
const run = await deps.runtime.execute({
|
const run = await deps.runtime.execute({
|
||||||
contract: input.contract,
|
contract: input.contract,
|
||||||
connection: {},
|
/*
|
||||||
|
* 试运行**要用真连接**:此前这里传空对象,凡是靠 endpoint 干活的工具
|
||||||
|
* 一律「无实现」过不了门 —— 那道门就只对不需要连接的工具有效,形同虚设。
|
||||||
|
* 运行时只把契约申报的键交给处理器,多传的环境值不会漏出去。
|
||||||
|
*/
|
||||||
|
connection: input.connectionValues ?? {},
|
||||||
call: { tool: input.contract.name, input: sample.input, activityId: '__trial__' },
|
call: { tool: input.contract.name, input: sample.input, activityId: '__trial__' },
|
||||||
});
|
});
|
||||||
if (!run.ok) {
|
if (!run.ok) {
|
||||||
|
|||||||
@@ -278,6 +278,45 @@ export const saveWorkGroupRoute = {
|
|||||||
output: z.object({ group: workGroupSchema }),
|
output: z.object({ group: workGroupSchema }),
|
||||||
} as const satisfies RouteContract;
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
/* ── 工具管理 · 导入自定义工具 ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入自定义工具 —— 与第一方工具走**同一条门**:静态审 + 试运行审。
|
||||||
|
*
|
||||||
|
* 工具承载权力,约束严于活动包:契约对活动、实现对系统、凭证对治理台(OS 7.3)。
|
||||||
|
* 连接配置**只声明键**;值随本次导入送到治理台加密落库,此后只读得到「是否已绑定」。
|
||||||
|
*/
|
||||||
|
export const registerToolRoute = {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/gov/tools',
|
||||||
|
body: z.object({
|
||||||
|
contract: z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
version: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
input: z.record(z.string(), z.unknown()),
|
||||||
|
output: z.record(z.string(), z.unknown()),
|
||||||
|
effect: z.enum(['read', 'write']),
|
||||||
|
errors: z
|
||||||
|
.array(z.object({ code: z.string(), retryable: z.boolean(), message: z.string() }))
|
||||||
|
.default([]),
|
||||||
|
idempotent: z.boolean().default(true),
|
||||||
|
timeoutMs: z.number().int().positive().default(10_000),
|
||||||
|
retries: z.number().int().min(0).default(1),
|
||||||
|
connectionKeys: z.array(z.string()).default([]),
|
||||||
|
}),
|
||||||
|
/** 连接值只写不读 —— 键在契约里,值密封在治理台 */
|
||||||
|
connectionValues: z.record(z.string(), z.string()).optional(),
|
||||||
|
/** 契约测试集:没有样例就没有试运行审,门形同虚设 */
|
||||||
|
samples: z.array(z.object({ input: z.record(z.string(), z.unknown()) })).min(1),
|
||||||
|
}),
|
||||||
|
output: z.object({
|
||||||
|
accepted: z.boolean(),
|
||||||
|
findings: z.array(z.object({ code: z.string(), severity: z.string(), message: z.string() })),
|
||||||
|
trialRun: z.object({ ran: z.number().int(), passed: z.number().int(), failures: z.array(z.string()) }),
|
||||||
|
}),
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
/* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */
|
/* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */
|
||||||
|
|
||||||
export const listActivitiesRoute = {
|
export const listActivitiesRoute = {
|
||||||
|
|||||||
Reference in New Issue
Block a user