Archived
仿真测试台(后端):节点指派 + 全链 Trace —— 起单定向到指定账号,真实任务落工作台
用户定的形态:测试台是**后端检测活动与流程**的,真实任务照旧到工作台
(不然指定账号干嘛)—— 所以测试台只管「起单 + 看穿」,人那一环还是真的。
**节点指派**(迁移 0016 workflow.assignments):
- StartWorkflowInput 增 `assignments {节点id → 员工id}`;分派改三级取人:
①节点指派(点名的人在岗就是他)②员工组轮值 ③全员轮值 —— 指派是加法不是替换,
点名的人不在岗退回轮值(单不能没人接);事件里如实记 `(节点指派)`
- **顺带修一个真 bug**:`node_instance.assignee_id` 写了但 `listInstances` 从没读回来
(审批箱收件、员工面链条、治理列表全按它认人 —— 写了不读等于没写)
**全链 Trace**(新模块 trace,L2;迁移 0017 sys.trace_span):
- span 由 **Mastra Observability 组件**产出(执行不经 Mastra 图,所以手动打点),
一开就落库、一收就补 —— 观测面不该比被观测的事晚(不走 exporter 的批量异步路)
- 打点覆盖:工作流 root → 节点(记着派给了谁)→ 活动内每次工具调用 / 模型调用 / 人卡
- **root span 挂在 `runtime.begin`**:那是「单已建好、还没进第一个节点」那一刻 ——
挂在 start 返回之后就晚了,起单同步走到第一个节点,节点 span 会先出生、另起孤树(实测)
- 观测尽力而为:写 span 失败吞掉,不掀翻业务
- 三样东西各管各的:trace(机器读时间轴)/ 任务记忆(人读留痕)/
**工作区报告(活动自持的 HTML)** —— 谁也不替代谁
路由:`POST /api/gov/testbed/run`(带指派起单)、`GET /api/gov/workflows/:id/trace`。
端到端六例(真库):指派生效 / 任务真落到被指派人手上并由他答单 / 下游节点同样生效 /
全链 span 逐层挂得上且耗时齐备 / 一条流一个 traceId / 不指派照旧轮值。五闸全过。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# module: trace
|
||||
|
||||
职责: 全链观测 —— 一条工作流一条 trace;节点实例、活动内每一步、
|
||||
每次工具调用 / 模型调用 / 人卡各一个 span(嵌套、耗时、入出参、错误)
|
||||
不负责: 人读的留痕(那是 memory 的任务记忆)、事件流(那是 event 的信封)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`TraceService` `TraceSpan` `SpanHandle` `SpanKind` `TraceStore` `SpanFactory`
|
||||
- 用例工厂:`createTraceService(deps)`
|
||||
- adapter:`createTraceStore`;Drizzle 表:`traceSpanTable`
|
||||
|
||||
层级: L2(编码规范 §4;只依赖 shared)
|
||||
依赖: 无模块依赖
|
||||
被依赖: app(装配层把 span 打在执行链路上)
|
||||
|
||||
不变量:
|
||||
- **span 由 Mastra Observability 组件产出**:我们的执行不经 Mastra 图,
|
||||
所以是**手动打点** —— app 层用 `Observability` 实例做 `SpanFactory`,
|
||||
换掉它只需换这一个工厂
|
||||
- **一开就落、一收就补**:不走 Mastra exporter 的批量异步路(缓冲 + 定时冲刷),
|
||||
测试台要的是「点一下就看得见」—— 观测面不该比被观测的事晚
|
||||
- **观测尽力而为**:写 span 失败不掀翻被观测的事(try/catch 吞掉)——
|
||||
观测坏了不该让流程停摆
|
||||
- **三样东西各管各的,谁也不替代谁**:
|
||||
· **trace**(此模块)= 机器读的时间轴:嵌套、耗时、入出参、错误;
|
||||
· **任务记忆**(memory)= 人读的留痕:谁在何时做了什么、依据什么;
|
||||
· **工作区报告**(工作台)= **活动自持的 HTML 报告** —— 活动自己产出的业务成果,
|
||||
系统不代它生成、不从 trace 里凑。观测面看得见「怎么跑的」,报告说的是「干出了什么」
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* module: trace —— **全链留痕的观测面**(设计台「每个活动完全 Trace 监控」)。
|
||||
*
|
||||
* 一条工作流 = 一条 trace;节点实例 = 一个 span;活动内的每一步、每次工具调用、
|
||||
* 每次模型调用、每张人卡 = 子 span。span 由 **Mastra Observability 组件**产出
|
||||
* (我们的执行不经 Mastra 图,所以是**手动打点**),再由自定义 exporter 落库。
|
||||
*
|
||||
* 与任务记忆的分工:任务记忆是**人读的留痕**(谁在何时做了什么、依据什么),
|
||||
* 一件事一行;trace 是**机器读的时间轴**(嵌套、耗时、入出参、错误),
|
||||
* 一次调用一个 span。两者并行,不互相替代。
|
||||
*/
|
||||
export type SpanKind = 'workflow' | 'node' | 'step' | 'tool' | 'model' | 'human';
|
||||
|
||||
export type TraceSpan = {
|
||||
readonly id: string;
|
||||
readonly traceId: string;
|
||||
readonly parentId?: string;
|
||||
readonly name: string;
|
||||
readonly kind: SpanKind;
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
readonly taskId?: string;
|
||||
readonly input?: Record<string, unknown>;
|
||||
readonly output?: Record<string, unknown>;
|
||||
readonly error?: { message: string };
|
||||
readonly attributes?: Record<string, unknown>;
|
||||
readonly startedAt: string;
|
||||
readonly endedAt?: string;
|
||||
};
|
||||
|
||||
/** 一次可观测的执行:开一个 span,干活,收尾 —— 收尾必做,不然时间轴上永远挂着 */
|
||||
export type SpanHandle = {
|
||||
readonly id: string;
|
||||
readonly traceId: string;
|
||||
child(input: {
|
||||
name: string;
|
||||
kind: SpanKind;
|
||||
nodeId?: string;
|
||||
taskId?: string;
|
||||
input?: Record<string, unknown>;
|
||||
attributes?: Record<string, unknown>;
|
||||
}): SpanHandle;
|
||||
end(out?: { output?: Record<string, unknown>; attributes?: Record<string, unknown> }): Promise<void>;
|
||||
fail(error: unknown): Promise<void>;
|
||||
};
|
||||
|
||||
export type TraceService = {
|
||||
/*
|
||||
* 作用域索引 —— 调用方只报 id,父子关系由服务自己拼。
|
||||
* 打点散在装配层的十几处,让每处自己拎 handle 传来传去,一处漏传整棵树就断了。
|
||||
*/
|
||||
/** 开一条工作流的 trace(root span) */
|
||||
beginWorkflow(input: { workflowId: string; subject: string; input?: Record<string, unknown> }): void;
|
||||
/** 开一个节点 span(自动挂在工作流 span 下) */
|
||||
beginNode(input: {
|
||||
workflowId: string;
|
||||
nodeId: string;
|
||||
label: string;
|
||||
taskId?: string;
|
||||
approvalId?: string;
|
||||
assigneeId?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}): void;
|
||||
/** 活动内的一次动作:步骤 / 工具 / 模型 / 人卡 —— 自动挂在该任务的节点 span 下 */
|
||||
beginAction(input: {
|
||||
taskId: string;
|
||||
name: string;
|
||||
kind: SpanKind;
|
||||
input?: Record<string, unknown>;
|
||||
}): SpanHandle | undefined;
|
||||
/** 收尾:节点终态 / 工作流终态 */
|
||||
endNode(input: { taskId?: string; workflowId?: string; nodeId?: string; output?: Record<string, unknown> }): Promise<void>;
|
||||
endWorkflow(input: { workflowId: string; state: string }): Promise<void>;
|
||||
|
||||
/** 起一条 trace(工作流级)或挂在既有 trace 下 —— 低层口,便利层用它 */
|
||||
start(input: {
|
||||
name: string;
|
||||
kind: SpanKind;
|
||||
workflowId?: string;
|
||||
nodeId?: string;
|
||||
taskId?: string;
|
||||
parent?: { traceId: string; spanId: string };
|
||||
input?: Record<string, unknown>;
|
||||
attributes?: Record<string, unknown>;
|
||||
}): SpanHandle;
|
||||
/** 这条工作流的完整 span 树(按开始时间序;树形由 parentId 拼) */
|
||||
ofWorkflow(workflowId: string): Promise<TraceSpan[]>;
|
||||
/** 这个任务(活动实例)的 span —— 活动内每一步都在这儿 */
|
||||
ofTask(taskId: string): Promise<TraceSpan[]>;
|
||||
flush(): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* module `trace` 唯一出口(编码规范 §9.1)。
|
||||
* 全链观测:一条工作流一条 trace,节点/步骤/工具/模型/人卡逐层 span。
|
||||
*/
|
||||
export type { SpanHandle, SpanKind, TraceService, TraceSpan } from './contract.ts';
|
||||
export type { Clock, TraceStore } from './port.ts';
|
||||
export type { SpanFactory, TraceServiceDeps } from './internal/usecase/trace-service.ts';
|
||||
export { createTraceService } from './internal/usecase/trace-service.ts';
|
||||
export { createTraceStore } from './internal/adapter/trace-store.ts';
|
||||
export { traceSpanTable } from './internal/adapter/trace-schema.ts';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* trace 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||
* span 只增不改:开 span 一行,收尾补 output/ended_at —— 与留痕同一条规矩。
|
||||
*/
|
||||
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const traceSpanTable = sys.table(
|
||||
'trace_span',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
traceId: text('trace_id').notNull(),
|
||||
parentId: text('parent_id'),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind').notNull(),
|
||||
workflowId: text('workflow_id'),
|
||||
nodeId: text('node_id'),
|
||||
taskId: text('task_id'),
|
||||
input: jsonb('input'),
|
||||
output: jsonb('output'),
|
||||
error: jsonb('error'),
|
||||
attributes: jsonb('attributes'),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
|
||||
endedAt: timestamp('ended_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [index('trace_span_trace_idx').on(t.traceId, t.startedAt), index('trace_span_workflow_idx').on(t.workflowId, t.startedAt)],
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
/** trace 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3) */
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { TraceSpan } from '../../contract.ts';
|
||||
import type { TraceStore } from '../../port.ts';
|
||||
import { traceSpanTable } from './trace-schema.ts';
|
||||
|
||||
type Row = typeof traceSpanTable.$inferSelect;
|
||||
|
||||
const toSpan = (r: Row): TraceSpan => ({
|
||||
id: r.id,
|
||||
traceId: r.traceId,
|
||||
...(r.parentId === null ? {} : { parentId: r.parentId }),
|
||||
name: r.name,
|
||||
kind: r.kind as TraceSpan['kind'],
|
||||
...(r.workflowId === null ? {} : { workflowId: r.workflowId }),
|
||||
...(r.nodeId === null ? {} : { nodeId: r.nodeId }),
|
||||
...(r.taskId === null ? {} : { taskId: r.taskId }),
|
||||
...(r.input === null ? {} : { input: r.input as Record<string, unknown> }),
|
||||
...(r.output === null ? {} : { output: r.output as Record<string, unknown> }),
|
||||
...(r.error === null ? {} : { error: r.error as { message: string } }),
|
||||
...(r.attributes === null ? {} : { attributes: r.attributes as Record<string, unknown> }),
|
||||
startedAt: r.startedAt.toISOString(),
|
||||
...(r.endedAt === null ? {} : { endedAt: r.endedAt.toISOString() }),
|
||||
});
|
||||
|
||||
export function createTraceStore(db: Db): TraceStore {
|
||||
return {
|
||||
async insert(span) {
|
||||
await db.insert(traceSpanTable).values({
|
||||
id: span.id,
|
||||
traceId: span.traceId,
|
||||
parentId: span.parentId ?? null,
|
||||
name: span.name,
|
||||
kind: span.kind,
|
||||
workflowId: span.workflowId ?? null,
|
||||
nodeId: span.nodeId ?? null,
|
||||
taskId: span.taskId ?? null,
|
||||
input: span.input ?? null,
|
||||
output: span.output ?? null,
|
||||
error: span.error ?? null,
|
||||
attributes: span.attributes ?? null,
|
||||
startedAt: new Date(span.startedAt),
|
||||
endedAt: span.endedAt === undefined ? null : new Date(span.endedAt),
|
||||
});
|
||||
},
|
||||
|
||||
async finish(id, patch) {
|
||||
await db
|
||||
.update(traceSpanTable)
|
||||
.set({
|
||||
...(patch.output === undefined ? {} : { output: patch.output }),
|
||||
...(patch.error === undefined ? {} : { error: patch.error }),
|
||||
...(patch.attributes === undefined ? {} : { attributes: patch.attributes }),
|
||||
endedAt: new Date(patch.endedAt),
|
||||
})
|
||||
.where(eq(traceSpanTable.id, id));
|
||||
},
|
||||
|
||||
async listByWorkflow(workflowId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(traceSpanTable)
|
||||
.where(eq(traceSpanTable.workflowId, workflowId))
|
||||
.orderBy(asc(traceSpanTable.startedAt));
|
||||
return rows.map(toSpan);
|
||||
},
|
||||
|
||||
async listByTask(taskId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(traceSpanTable)
|
||||
.where(eq(traceSpanTable.taskId, taskId))
|
||||
.orderBy(asc(traceSpanTable.startedAt));
|
||||
return rows.map(toSpan);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* trace 用例 —— **手动打点的全链观测**(编码规范 §5.3;`@mastra` 只准 adapter/app §6.2)。
|
||||
*
|
||||
* 我们的执行不经 Mastra 图(自持流程运行时 + 自持范式引擎),所以 Mastra 自带的
|
||||
* 自动 tracing 不会产出任何 span。这里把它的**观测组件**当作产 span 的工具用:
|
||||
* `Observability` 实例出 span(标准形状:traceId / parentSpanId / input / output /
|
||||
* errorInfo / 起止时刻),我们在执行链路的关键点手动开合。
|
||||
*
|
||||
* 落库不用 Mastra 的 storage —— exporter 那条路是**批量异步**的(缓冲 + 定时冲刷),
|
||||
* 而测试台要的是「点一下就看得见」。所以 span 一开就落一行、一收就补一行,
|
||||
* 两笔写都在 usecase 里同步做:**观测面不该比被观测的事晚**。
|
||||
*
|
||||
* Mastra 侧只用来生成 id 与保证 span 形状 —— 换掉它只需改 `spanFactory`。
|
||||
*/
|
||||
import type { SpanHandle, SpanKind, TraceService, TraceSpan } from '../../contract.ts';
|
||||
import type { Clock, TraceStore } from '../../port.ts';
|
||||
|
||||
/** span 工厂:出 id 与父子关系 —— 由 app 层用 Mastra 的 Observability 供给 */
|
||||
export type SpanFactory = {
|
||||
root(name: string): { spanId: string; traceId: string };
|
||||
child(parent: { traceId: string; spanId: string }, name: string): { spanId: string; traceId: string };
|
||||
};
|
||||
|
||||
export type TraceServiceDeps = {
|
||||
readonly store: TraceStore;
|
||||
readonly clock: Clock;
|
||||
readonly spans: SpanFactory;
|
||||
};
|
||||
|
||||
export function createTraceService(deps: TraceServiceDeps): TraceService {
|
||||
/** 落一行「开」;失败不掀翻被观测的事 —— 观测坏了不该让业务停摆 */
|
||||
const open = async (span: TraceSpan): Promise<void> => {
|
||||
try {
|
||||
await deps.store.insert(span);
|
||||
} catch {
|
||||
/* 观测尽力而为:trace 写不进去也不能让流程跑不动 */
|
||||
}
|
||||
};
|
||||
|
||||
const close = async (
|
||||
id: string,
|
||||
patch: { output?: Record<string, unknown>; error?: { message: string }; attributes?: Record<string, unknown> },
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await deps.store.finish(id, { ...patch, endedAt: deps.clock.now().toISOString() });
|
||||
} catch {
|
||||
/* 同上 */
|
||||
}
|
||||
};
|
||||
|
||||
const handleOf = (
|
||||
ids: { spanId: string; traceId: string },
|
||||
scope: { workflowId?: string; nodeId?: string; taskId?: string },
|
||||
): SpanHandle => ({
|
||||
id: ids.spanId,
|
||||
traceId: ids.traceId,
|
||||
|
||||
child(input) {
|
||||
const born = deps.spans.child({ traceId: ids.traceId, spanId: ids.spanId }, input.name);
|
||||
// 归属向下继承:子 span 没说自己属于谁,就跟父亲同一条流、同一个节点
|
||||
const childScope = {
|
||||
...scope,
|
||||
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
|
||||
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
||||
};
|
||||
void open({
|
||||
id: born.spanId,
|
||||
traceId: born.traceId,
|
||||
parentId: ids.spanId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
...childScope,
|
||||
...(input.input === undefined ? {} : { input: input.input }),
|
||||
...(input.attributes === undefined ? {} : { attributes: input.attributes }),
|
||||
startedAt: deps.clock.now().toISOString(),
|
||||
});
|
||||
return handleOf(born, childScope);
|
||||
},
|
||||
|
||||
async end(out) {
|
||||
await close(ids.spanId, { ...(out?.output === undefined ? {} : { output: out.output }), ...(out?.attributes === undefined ? {} : { attributes: out.attributes }) });
|
||||
},
|
||||
|
||||
async fail(error) {
|
||||
await close(ids.spanId, { error: { message: error instanceof Error ? error.message : String(error) } });
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
* 作用域索引:workflowId → 工作流 span,taskId → 节点 span。
|
||||
* 打点散在装配层十几处,让每处自己拎 handle 传来传去,一处漏传整棵树就断了 ——
|
||||
* 所以由服务记着「这一单现在挂在哪儿」,调用方只报 id。
|
||||
* 进程内存活即可:trace 已经落库了,这张表只是为了拼父子。
|
||||
*/
|
||||
const workflowSpans = new Map<string, SpanHandle>();
|
||||
const nodeSpans = new Map<string, SpanHandle>();
|
||||
|
||||
const service: TraceService = {
|
||||
beginWorkflow({ workflowId, subject, input }) {
|
||||
const span = service.start({
|
||||
name: `流程:${subject}`,
|
||||
kind: 'workflow',
|
||||
workflowId,
|
||||
...(input === undefined ? {} : { input }),
|
||||
});
|
||||
workflowSpans.set(workflowId, span);
|
||||
},
|
||||
|
||||
beginNode({ workflowId, nodeId, label, taskId, approvalId, assigneeId, input }) {
|
||||
// 派给谁、卡了哪张审批单 —— 开 span 那一刻就写清「这一步在谁手上」
|
||||
const shape = {
|
||||
name: `${nodeId} ${label}`,
|
||||
kind: 'node' as const,
|
||||
nodeId,
|
||||
...(taskId === undefined ? {} : { taskId }),
|
||||
...(input === undefined ? {} : { input }),
|
||||
attributes: {
|
||||
...(assigneeId === undefined ? {} : { assigneeId }),
|
||||
...(approvalId === undefined ? {} : { approvalId }),
|
||||
},
|
||||
};
|
||||
// 工作流 span 不在手上(进程重启过)就另起一棵 —— 观测尽力而为,不让业务停摆
|
||||
const parent = workflowSpans.get(workflowId);
|
||||
const span = parent === undefined ? service.start({ ...shape, workflowId }) : parent.child(shape);
|
||||
nodeSpans.set(taskId ?? `${workflowId}:${nodeId}`, span);
|
||||
},
|
||||
|
||||
beginAction({ taskId, name, kind, input }) {
|
||||
const parent = nodeSpans.get(taskId);
|
||||
if (parent === undefined) return undefined;
|
||||
return parent.child({ name, kind, taskId, ...(input === undefined ? {} : { input }) });
|
||||
},
|
||||
|
||||
async endNode({ taskId, workflowId, nodeId, output }) {
|
||||
const key = taskId ?? `${workflowId}:${nodeId}`;
|
||||
const span = nodeSpans.get(key);
|
||||
if (span === undefined) return;
|
||||
nodeSpans.delete(key);
|
||||
await span.end(output === undefined ? undefined : { output });
|
||||
},
|
||||
|
||||
async endWorkflow({ workflowId, state }) {
|
||||
const span = workflowSpans.get(workflowId);
|
||||
if (span === undefined) return;
|
||||
workflowSpans.delete(workflowId);
|
||||
await span.end({ output: { state } });
|
||||
},
|
||||
|
||||
start(input) {
|
||||
const born =
|
||||
input.parent === undefined ? deps.spans.root(input.name) : deps.spans.child(input.parent, input.name);
|
||||
const scope = {
|
||||
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
|
||||
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
|
||||
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
||||
};
|
||||
void open({
|
||||
id: born.spanId,
|
||||
traceId: born.traceId,
|
||||
...(input.parent === undefined ? {} : { parentId: input.parent.spanId }),
|
||||
name: input.name,
|
||||
kind: input.kind as SpanKind,
|
||||
...scope,
|
||||
...(input.input === undefined ? {} : { input: input.input }),
|
||||
...(input.attributes === undefined ? {} : { attributes: input.attributes }),
|
||||
startedAt: deps.clock.now().toISOString(),
|
||||
});
|
||||
return handleOf(born, scope);
|
||||
},
|
||||
|
||||
async ofWorkflow(workflowId) {
|
||||
return deps.store.listByWorkflow(workflowId);
|
||||
},
|
||||
|
||||
async ofTask(taskId) {
|
||||
return deps.store.listByTask(taskId);
|
||||
},
|
||||
|
||||
async flush() {
|
||||
/* 同步落库,没有缓冲要冲 —— 留这个口子是给将来换成批量 exporter 用的 */
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** trace 的存储端口 —— 落库形状由 adapter 决定,usecase 不认识 Drizzle */
|
||||
import type { TraceSpan } from './contract.ts';
|
||||
|
||||
export type TraceStore = {
|
||||
/** 只增不改的两笔:开 span 写一行,收尾补 output/ended_at */
|
||||
insert(span: TraceSpan): Promise<void>;
|
||||
finish(id: string, patch: { output?: Record<string, unknown>; error?: { message: string }; attributes?: Record<string, unknown>; endedAt: string }): Promise<void>;
|
||||
listByWorkflow(workflowId: string): Promise<TraceSpan[]>;
|
||||
listByTask(taskId: string): Promise<TraceSpan[]>;
|
||||
};
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
Reference in New Issue
Block a user