goal-task-model.js
22.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
/**
* 目标·任务两级栏的纯逻辑(27-P3C §七)。
*
* 为什么单独一个 .js:.vue 里的逻辑没人跑得到。
* 状态映射 / 排序 / 默认选中这三件事一旦错了都是**静默错**(画错颜色、选错任务、切 task 不清栈),
* 所以全部抽到这里,由 `server/tests/unit/goal-task-model.test.js` 跑真测试
* (**不要**再写一个 .check.js —— 那种文件根 vitest.config.js 的 include 覆盖不到、
* package.json 也没有脚本引它,写了等于没人跑;测试直接 import 本文件即可,
* 手法同 `graph-console-node-signals.test.js` 之于 node-signals.js)。
*
* 色板**沿用 WorkGraphCanvas 的 .dot 与 GraphRunPane 的 .run-status**,一个新颜色都不造:
* 左栏那个点和画布上那个点说的是同一件事的两级,颜色对不上会让人以为是两套状态。
* running #0069d9 / success #1a9c4b / fail #d33 / blocked #e08a00 / ready #7aa7dd / pending #bbb
*/
/** task 状态 → 圆点样式 + 中文标签。hollow = 空心圈(草稿:还没有任何东西发生) */
export const TASK_STYLE = {
draft: { color: '#bbb', hollow: true, label: '草稿' },
// queued 是批 3(调度器)的状态,这里先给好样式:批 3 落地时列表不用再改一遍颜色。
queued: { color: '#7aa7dd', hollow: false, label: '排队中' },
running: { color: '#0069d9', hollow: false, label: '执行中', pulse: true },
completed: { color: '#1a9c4b', hollow: false, label: '已完成' },
failed: { color: '#d33', hollow: false, label: '失败' },
blocked: { color: '#e08a00', hollow: false, label: '挂起' },
cancelled: { color: '#bbb', hollow: false, label: '已取消' },
};
/** goal 状态 → 圆点样式 + 中文标签。planning 借 running 的蓝并且脉冲:它是个真会持续几十秒的状态 */
export const GOAL_STYLE = {
draft: { color: '#bbb', hollow: true, label: '草稿' },
planning: { color: '#0069d9', hollow: false, label: '拆分中', pulse: true },
active: { color: '#0069d9', hollow: false, label: '进行中' },
done: { color: '#1a9c4b', hollow: false, label: '已完成' },
failed: { color: '#d33', hollow: false, label: '失败' },
cancelled: { color: '#bbb', hollow: false, label: '已取消' },
archived: { color: '#bbb', hollow: true, label: '已归档' },
};
const FALLBACK = { color: '#bbb', hollow: true, label: '—' };
/** 认不出的状态一律退到灰空心 + 原文,绝不 undefined —— 后端加了新状态时列表照样画得出来 */
export function taskStyle(status) {
return TASK_STYLE[status] || { ...FALLBACK, label: String(status || '—') };
}
export function goalStyle(status) {
return GOAL_STYLE[status] || { ...FALLBACK, label: String(status || '—') };
}
/** 终态:不再变化,墙钟停在 finishedAt */
export const TASK_TERMINAL = ['completed', 'failed', 'cancelled'];
// ────────────────────────────────────────────────────────────────────────────
// 队列(27-P3C 批 3)
// ────────────────────────────────────────────────────────────────────────────
/**
* 排队位次徽标。
*
* **位次不在这里算**:口径(priority → seq → created_at)全仓库只有
* `server/services/task-scheduler.js#listQueue` 一个持有者,前端再排一遍就是第二个持有者
* —— `canDispatch` 那次事故就是这么来的。所以这里只读服务端随 taskView 吐回来的
* `queuePosition`(1-based),不读兄弟节点、不数数组下标。
*
* 读不到时显示 `排队 #?` 而不是悄悄退成「排队中」:那说明服务端这次没给位次(端点没吐 /
* 这条已经不在队里了),是个要有人看见的缺口,藏起来正是要消灭的那类静默。
*
* @returns {{text:string,title:string}|null} 非 queued 返回 null
*/
export function queueBadge(task) {
if (!task || task.status !== 'queued') return null;
const n = Number(task.queuePosition);
if (Number.isInteger(n) && n > 0) {
return { text: `排队 #${n}`, title: `队列第 ${n} 位(位次由服务端按 priority → seq → created_at 算)` };
}
return {
text: '排队 #?',
title: '服务端这次没有返回 queuePosition。位次口径只归调度器一处,前端不自己排,所以只能显示未知。',
};
}
/**
* 「上游已经不可能满足了」这个 blocked 的原因前缀 ——
* **逐字复述** `server/services/work-graph-db.js` 的 `UPSTREAM_BLOCK_PREFIX`。
*
* 为什么必须复述:`blocked_reason` 是一条编码过的字符串,要从里面取出「死掉的是哪个上游」
* 才能给出「强制放行」(删掉那条依赖)这个操作,也才能把 id 翻成人看得懂的任务名。
* 复述就必须有人钉住一致 —— `server/tests/unit/goal-task-model.test.js` 直接 diff 后端那个常量。
* 这条红了说明后端改了前缀:症状是界面上 blocked 的原因退成一串原文、「强制放行」永久置灰,
* 线上不会报任何错。
*/
export const UPSTREAM_BLOCK_PREFIX = 'upstream_failed:';
/**
* 拆 `blocked_reason`。认不出的原因**原样带走**(raw),绝不吞:
* 预算耗尽、入队做了一半(enqueue_incomplete:…)都会走到这里,把它们显示成空白等于
* 让一条卡住的任务看起来毫无理由地卡着。
*/
export function parseBlockedReason(reason) {
const raw = String(reason || '');
if (!raw) return null;
if (raw.startsWith(UPSTREAM_BLOCK_PREFIX)) {
return { kind: 'upstream', upstreamId: raw.slice(UPSTREAM_BLOCK_PREFIX.length), raw };
}
return { kind: 'other', upstreamId: '', raw };
}
/**
* blocked 行第二排的原因文案:`上游 #1 搭骨架 未完成`。
* 上游不在同 goal 的兄弟里(跨 goal / 已归档)就显示原始 id —— 同 dependsLabel 的理由。
* 没写原因的 blocked 返回 '',调用方退回状态标签(「挂起」)。
*/
export function blockedLabel(task, siblings = []) {
const p = parseBlockedReason(task && task.blockedReason);
if (!p) return '';
if (p.kind !== 'upstream') return p.raw;
const dep = siblings.find((t) => t.taskId === p.upstreamId);
return dep ? `上游 #${dep.seq} ${dep.title || dep.taskId} 未完成` : `上游 ${p.upstreamId} 未完成`;
}
/**
* 任务级重跑走 `POST /:cid/tasks/:taskId/rerun`(27-P3C §六.3,批 3 落地)。
*
* **刻意不叫 retry**:`POST /api/graph-runs/:id/nodes/:nodeId/retry` 是**节点级**重试
* (同一条 run 内把某个节点打回 ready)。任务级是新起一条 run、整张图从头来。
* 界面文案也按这条分:任务行的「重跑这个任务」vs 节点详情里的「重试这一步」。
*/
export const RERUN_ENDPOINT = (cid, taskId) => `/api/company/${cid}/tasks/${taskId}/rerun`;
/**
* task 行 `⋯` 菜单:**按状态只给做得成的操作**。
*
* 判据全部来自后端已经吐回来的字段(status / runId / blockedReason),不猜、不复述取值域。
* 做不成的那两个(重跑 / 重跑上游)以置灰 + title 形式留下,理由见 RERUN_UNAVAILABLE ——
* 「不给会报错的入口」指的是不给能点却必然 4xx 的按钮,不是把缺口藏起来。
*
* @returns {{key:string,label:string,title:string,danger?:boolean,disabled?:boolean}[]}
*/
export function taskActions(task) {
const t = task || {};
const acts = [];
if (t.status === 'running') {
acts.push({
key: 'stop',
label: '停止',
danger: true,
disabled: !t.runId, // 没有 run 就没有 /stop 可打,点了必 404
title: t.runId
? 'POST /api/graph-runs/:id/stop —— 软信号:不打断手头那个节点,到节点边界才落 stopped'
: '这条任务标着执行中却没有 runId,停不了;刷新后仍如此就是数据不一致,去看服务端日志',
});
}
if (t.status === 'queued') {
// manual 的任务**只有人点得动**(§五.2bis:调度器故意不碰它们,否则它会占着坑停在
// 第一个节点上等 /next,把后面所有任务无限期堵死)。auto 的也留着这个入口 ——
// 「我现在就要它跑」是合理诉求,抢不到坑服务端会回 409 + 当前位次,不会假装成功。
acts.push({
key: 'start',
label: '立即开始',
// **不在这里置灰**:能不能开始取决于「上游做完没」和「坑空没空」,两者都不在这一行的
// 数据里(兄弟任务的状态、别的 goal 占没占坑)。照前端手上这点信息去猜,就是又一个
// 复述服务端取值域的持有者。服务端会回 409 + 挡我的是谁 + 当前位次,比猜准得多。
title: 'POST /:cid/tasks/:id/start —— 抢一个执行坑。上游没做完 / 坑被占着会如实回 409,不会假装已开始',
});
acts.push({
key: 'cancel',
label: '取消排队',
danger: true,
title: 'POST /:cid/tasks/:id/cancel —— run 从 queued 撤成 stopped,任务收成「已取消」。'
+ '它还没开跑,所以不会有半截产物;行还在,归档才会从列表里消失',
});
}
if (t.status === 'blocked') {
const p = parseBlockedReason(t.blockedReason);
const upstream = p && p.kind === 'upstream' ? p.upstreamId : '';
acts.push({
key: 'rerun-upstream',
label: '重跑上游',
// 上游 id 从 blocked_reason 里解出来;解不出来(预算耗尽那种 blocked)就没有重跑对象。
// 置灰而不是藏起来:藏起来会让下一个人以为这里本来就没有这个操作
upstreamId: upstream,
disabled: !upstream,
title: upstream
? `重跑上游任务 ${upstream}。它一 completed,这条就自己回队列(blocked 是每轮 tick 重算的,不是不可逆状态)`
: `卡住的原因不是上游失败(${(p && p.raw) || '服务端没写原因'}),没有上游可重跑`,
});
acts.push({
key: 'release',
label: '强制放行',
// 判据 = **后端此刻真做得到的事**:`PATCH /:cid/tasks/:id {depends_on}` 的闸门在
// `work-graph-db.updateTask`,它对 draft / queued / blocked 都放行 —— 而这个动作只在
// blocked 时才出现,所以「状态」这一维恒成立,剩下的唯一条件就是「有一条依赖可删」。
// (批 3 复核抓到的原状:那道闸门只认 draft/queued,于是这个按钮在真正需要它的那一格
// 一定是亮的、请求一定 409 —— 「不给能点却必然 4xx 的入口」当时只是一句注释。)
disabled: !upstream || !(t.dependsOn || []).includes(upstream),
title: !upstream
? `卡住的原因不是上游失败(${(p && p.raw) || '服务端没写原因'}),没有可删的依赖 —— 放行不适用`
: (t.dependsOn || []).includes(upstream)
? `删掉「依赖 ${upstream}」这条边。服务端改完依赖会立刻重算一次队列,这条随即回到排队中`
: `原因指向 ${upstream},但这条任务的依赖里已经没有它了 —— 没什么可删的。`
+ '这种「原因过期」的 blocked 由服务端每轮调度自动解开(最迟 60s 的兜底扫描),等一下刷新即可',
});
// **保底出路**:上游被归档 / 被删号时,「重跑上游」打过去是 404、「强制放行」也可能无依赖可删,
// 于是这一行会一个能点的按钮都没有。取消对 blocked 一定走得通(服务端按 run 的真实状态
// 分三条路收尾),所以它必须在这里,否则界面上就是一条谁也动不了的死任务。
acts.push({
key: 'cancel',
label: '取消任务',
danger: true,
title: 'POST /:cid/tasks/:id/cancel —— 放弃这条挂起的任务,收成「已取消」。'
+ '行还留在列表里(归档才会消失),之后随时可以「重跑」',
});
}
if (t.status === 'draft' || TASK_TERMINAL.includes(t.status)) {
if (TASK_TERMINAL.includes(t.status)) {
acts.push({
key: 'rerun',
label: '重跑',
title: '新起一条 run,整张图从头来。旧 run 连同它的节点行留在历史里,随时点得开',
});
}
acts.push({ key: 'archive', label: '归档', danger: true, title: '从列表里移走(只盖 archived_at,不删数据)' });
}
return acts;
}
/**
* 全公司在途统计,喂「新目标」表单那句提示。
* 只数后端给的 status,不推断位次 —— 一条**还没建出来**的任务排第几,谁都算不准
* (它的 priority 会决定它插在队伍的哪一段),说「排在第 M 位」就是编一个数字。
*/
export function queueStat(goals = []) {
const stat = { running: 0, queued: 0, blocked: 0 };
for (const g of goals) {
for (const t of g.tasks || []) {
if (t.status in stat) stat[t.status] += 1;
}
}
return stat;
}
/**
* 「新目标 · 全部下达」按钮上方那句话。
*
* 这是本批要消解的那个问题在界面上的落点:批 3 之前公司里只要有一条任务在跑,下达就是一个
* **死路**(按钮灰 / 409),用户唯一能做的是盯着等。现在下达=入队,所以这里要说清两件事:
* 队伍里现在有谁,以及「点下去之后它会怎么样」。
*
* **不编位次**:一条还没建出来的任务排第几谁都算不准(它的 priority 决定它插在队伍哪一段,
* 而 priority 只有服务端的 listQueue 说了算)。所以这里只报 running / queued / blocked 三个
* **服务端给的**数,位次留给下达之后每一行自己显示(queueBadge 读 queuePosition)。
* 写死一个「将排在第 M 位」就是前端自造取值域 —— canDispatch 那次事故正是这么开始的。
*
* blocked 单独报一句:它是「有东西卡住了、要人动手」,混在「在排队」里会让人以为它自己会好。
*
* @returns {string} 队里空空如也时返回 ''(没有队伍就没什么好说的)
*/
export function queueNoteText(stat) {
const s = stat || {};
const running = Number(s.running) || 0;
const queued = Number(s.queued) || 0;
const blocked = Number(s.blocked) || 0;
if (!running && !queued && !blocked) return '';
const parts = [];
if (running) parts.push(`${running} 个任务在跑`);
if (queued) parts.push(`${queued} 个在排队`);
if (blocked) parts.push(`${blocked} 个卡在上游`);
return `当前${parts.join('、')}。「全部下达」照常可点:本次的任务会进同一个队列,`
+ '排在它们后面,坑一空就自动开跑 —— 确切位次由服务端算,下达后显示在左栏每一行上。';
}
/**
* 选中的这条 task 有没有一张**画得出来**的 run。
*
* `queued` 的 task 在库里是有 `current_run_id` 的(出队认领要靠 `WHERE current_run_id=?` 决胜),
* 但那条 run 还是 `run_status='queued'`:它一个节点都没点亮,控制按钮一个都不可用,
* 面板也不在轮询名单里。把它交给 GraphRunPane 画出来的是一块「看着活着、其实全灰、还不刷新」
* 的死面板,没有任何一处能告诉人「它在排队」。所以排队中一律退回 org graph **定义**视图,
* 由 pendingRunHint 说清它排在第几 —— 这才是用户此刻唯一想知道的事。
*/
export function hasStartedRun(task) {
const t = task || {};
return Boolean(t.runId) && t.status !== 'queued';
}
/**
* 从 dispatch 响应里认出「把选中态挪到哪条 task」。
*
* 批 3 之后一次下达会把多条 task 一起入队,响应形状由服务端定;这里**只认它明确给出的
* taskId**,三种都认不出就返回 '',调用方保持选中态不动。绝不从别处猜一条出来:
* 猜错就是用户点完「下达」看到的是另一条任务的图,而且没有任何报错。
*/
export function dispatchedTaskId(d) {
const r = d || {};
return (r.started && r.started.taskId)
|| (Array.isArray(r.queued) && r.queued.length && (r.queued[0].taskId || r.queued[0]))
|| r.taskId
|| '';
}
/**
* 选中一条还没起过 run 的 task 时,org 栏顶上那句话。
* 排队中的那条要说清「前面还有几个」—— 这是「为什么点了下达却什么都没发生」的唯一答案。
*/
export function pendingRunHint(task) {
const t = task || {};
const tail = '下面是 org graph 定义本身,节点状态一律 pending。';
if (t.status !== 'queued') return `还没有起过 run(${taskStyle(t.status).label}),${tail}`;
const n = Number(t.queuePosition);
if (!Number.isInteger(n) || n <= 0) return `排队中(服务端没给位次),${tail}`;
if (n === 1) return `排在队首,前面的任务一收敛就自动开跑,${tail}`;
return `排队中,前面还有 ${n - 1} 个任务,${tail}`;
}
/**
* 墙钟:startedAt → (finishedAt | now)。没开跑返回 ''(第二排就少一段,不显示 0min)。
* 负数 / 解析不出来也返回 '' —— 宁可少一个字段,不要显示「-3min」。
*/
export function elapsedLabel(task, nowMs = Date.now()) {
if (!task || !task.startedAt) return '';
const t0 = Date.parse(task.startedAt);
if (!Number.isFinite(t0)) return '';
const t1 = task.finishedAt ? Date.parse(task.finishedAt) : nowMs;
const ms = (Number.isFinite(t1) ? t1 : nowMs) - t0;
if (!Number.isFinite(ms) || ms < 0) return '';
const m = Math.floor(ms / 60000);
return m < 60 ? `${m}min` : `${Math.floor(m / 60)}h${m % 60}min`;
}
/**
* task 行第二排:`auto · 3/7 · 12min`。
* progress 为 null = 还没起 run 或 run 还没有节点行 → 整段不出现(后端就是这么约定的:
* 「据此画未开始,而不是 0/0」),不要自己补一个 0/0 出来。
*/
export function taskSubtitle(task, nowMs = Date.now()) {
if (!task) return '';
const parts = [];
if (task.mode) parts.push(task.mode);
if (task.progress && task.progress.total) parts.push(`${task.progress.done || 0}/${task.progress.total}`);
const el = elapsedLabel(task, nowMs);
if (el) parts.push(el);
return parts.join(' · ');
}
/** goal 行右侧:`3/5 任务完成`。零 task 的 goal(拆分中 / 空草稿)不显示这段 */
export function goalProgressLabel(goal) {
const tasks = (goal && goal.tasks) || [];
if (!tasks.length) return '';
const done = tasks.filter((t) => t.status === 'completed').length;
return `${done}/${tasks.length} 任务完成`;
}
/**
* `⇢` 的 hover 文案:「等 T1 完成」。
* 只认同 goal 内的兄弟 task;引用到已归档 / 已删的 id 时原样显示 id,不静默吞掉 ——
* 「依赖一个不存在的 task」正是 dispatch 会 409 的原因,藏起来只会让人查不到。
*/
export function dependsLabel(task, siblings = []) {
const deps = (task && task.dependsOn) || [];
if (!deps.length) return '';
const byId = new Map(siblings.map((t) => [t.taskId, t]));
const names = deps.map((id) => {
const t = byId.get(id);
if (!t) return id;
return `#${t.seq} ${t.title || id}`;
});
return `等 ${names.join('、')} 完成`;
}
/**
* 默认选中:最近一条 running 的 task,没有就最近创建的 task。
* 「最近」按 createdAt 倒序;goals 列表本身已经是 created_at DESC,但不能依赖调用方的顺序 ——
* 轮询回来的数组顺序变了就会让选中态莫名其妙跳一次。
*/
export function pickDefaultSelection(goals = []) {
const all = [];
for (const g of goals) for (const t of g.tasks || []) all.push({ goal: g, task: t });
if (!all.length) return null;
const byNewest = (a, b) => String(b.task.createdAt || '').localeCompare(String(a.task.createdAt || ''));
const running = all.filter((x) => x.task.status === 'running').sort(byNewest);
const pick = running[0] || all.slice().sort(byNewest)[0];
return { goalId: pick.goal.goalId, taskId: pick.task.taskId };
}
/** 在整棵树里按 taskId 找一条 task(连它的 goal 一起给回来,选中态两个都要) */
export function findTask(goals = [], taskId) {
if (!taskId) return null;
for (const g of goals) {
for (const t of g.tasks || []) if (t.taskId === taskId) return { goal: g, task: t };
}
return null;
}
/**
* run 收敛态 → task 状态:**逐字复述** `server/services/task-settle.js` 的 `RUN_TO_TASK_STATUS`。
*
* 为什么这里必须是「逐字」而不是「差不多」:前端复述一份后端的取值域、还自作主张多写一条,
* 正是 `canDispatch` 那次事故的形态(前端多认一个状态 → 公司失败一次后再也下不了任务)。
* 后端表改了而这里没跟,浏览器就会把一个已经收敛的 task 画成认不出的状态,而且没有任何报错。
* 所以两张表的一致性由 `server/tests/unit/goal-task-model.test.js` 直接 diff 钉住 ——
* 后端改表这条测试会红,并报出差在哪一条。
*/
export const RUN_TERMINAL_TO_TASK_STATUS = {
completed: 'completed',
failed: 'failed',
stopped: 'cancelled', // 人工停止不是执行失败,照抄 'stopped' 会画成认不出的状态
blocked: 'blocked',
};
/**
* 前端实际用的表 = 后端那 4 条 + `running`。
*
* 多出来的这条**不是复述后端**,而是前端独有的需求,所以显式拼在这里而不是混进上面那张表:
* 后端的 settleRootRun 只在 run 收敛时被调用,它没有理由认识 `running`;而 SSE 是全程推的,
* 一条正在跑的 run 也要能把行画成「执行中」(库里那行确实在 launchTaskRun 时就写成 running 了,
* 这条目前只是兜底,但推流早于轮询到达时它就是唯一的信息来源)。
*/
const RUN_TO_TASK_STATUS = { ...RUN_TERMINAL_TO_TASK_STATUS, running: 'running' };
/**
* SSE 快照 → 选中 task 行的增量补丁(§七.3「有 SSE 就只更新那一行」)。
*
* 只回**看得出来的那几个字段**:状态走上面那张表,进度用节点行数算。
* run 还在 ready/planning 时不给 status —— 那时 task 行在库里是 running,回一个 'ready' 会把
* 一个正在跑的任务画成认不出的状态。
*/
export function runStateToTaskPatch(state) {
if (!state || !state.run) return null;
const patch = {};
const mapped = RUN_TO_TASK_STATUS[state.run.run_status];
if (mapped) patch.status = mapped;
const nodes = state.nodes || [];
if (nodes.length) {
patch.progress = {
total: nodes.length,
done: nodes.filter((n) => n.status === 'success' || n.status === 'skipped').length,
};
}
return Object.keys(patch).length ? patch : null;
}