@dsh-workbench/shared 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/long-task.d.ts +33 -4
- package/dist/api/long-task.js +34 -2
- package/dist/components/FormRows.css +31 -0
- package/dist/components/LongTaskRow.css +52 -0
- package/dist/components/LongTaskRow.d.ts +18 -2
- package/dist/components/LongTaskRow.js +33 -5
- package/dist/host/long-task.d.ts +7 -0
- package/dist/host/long-task.js +31 -5
- package/dist/host/tool-approval.d.ts +1 -0
- package/dist/host/unit-face.d.ts +1 -0
- package/dist/i18n/en.js +2 -0
- package/dist/i18n/index.d.ts +3 -1
- package/dist/i18n/zh.d.ts +2 -0
- package/dist/i18n/zh.js +2 -0
- package/package.json +1 -1
- package/src/api/__tests__/long-task.test.ts +41 -1
- package/src/api/long-task.ts +46 -4
- package/src/components/FormRows.css +31 -0
- package/src/components/FormRows.test.tsx +71 -1
- package/src/components/LongTaskRow.css +52 -0
- package/src/components/LongTaskRow.tsx +59 -4
- package/src/components/__tests__/long-task-row.test.ts +115 -0
- package/src/host/__tests__/long-task.test.ts +264 -0
- package/src/host/long-task.ts +43 -7
- package/src/host/tool-approval.ts +1 -0
- package/src/host/unit-face.ts +1 -0
- package/src/i18n/en.ts +3 -0
- package/src/i18n/zh.ts +2 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { LongTaskStep } from '../../api/long-task.js';
|
|
3
|
+
import {
|
|
4
|
+
createLongTaskHost,
|
|
5
|
+
createLongTaskRegistry,
|
|
6
|
+
sharedLongTaskRegistry,
|
|
7
|
+
type LongTaskBase,
|
|
8
|
+
type LongTaskDeps,
|
|
9
|
+
} from '../long-task.js';
|
|
10
|
+
|
|
11
|
+
interface Probe extends LongTaskBase {
|
|
12
|
+
readonly note?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function harness(over: Partial<LongTaskDeps<Probe>> = {}) {
|
|
16
|
+
let signal: AbortSignal | undefined;
|
|
17
|
+
let step: ((step: LongTaskStep) => void) | undefined;
|
|
18
|
+
let settle: ((result: unknown) => void) | undefined;
|
|
19
|
+
let fail: ((error: unknown) => void) | undefined;
|
|
20
|
+
const base: LongTaskDeps<Probe> = {
|
|
21
|
+
now: () => '2026-10-21T02:00:00.000Z',
|
|
22
|
+
run: (options) =>
|
|
23
|
+
new Promise((resolve, reject) => {
|
|
24
|
+
signal = options.signal;
|
|
25
|
+
step = options.onStep;
|
|
26
|
+
settle = resolve as (result: unknown) => void;
|
|
27
|
+
fail = reject as (error: unknown) => void;
|
|
28
|
+
}),
|
|
29
|
+
view: (snapshot) => ({ target: snapshot.target, status: snapshot.status, note: snapshot.note }),
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
deps: { ...base, ...over },
|
|
33
|
+
signal: () => signal,
|
|
34
|
+
step: (one: LongTaskStep) => step?.(one),
|
|
35
|
+
done: (result: unknown) => settle?.(result),
|
|
36
|
+
boom: (error: unknown) => fail?.(error),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const flush = async (): Promise<void> => {
|
|
41
|
+
await Promise.resolve();
|
|
42
|
+
await Promise.resolve();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('createLongTaskRegistry.stop(停止只是转发给生产方,认不认 signal 是模块的事)', () => {
|
|
46
|
+
it('★★ stop:run 拿到的 signal 被 abort,这一趟变 stopped,回 true', () => {
|
|
47
|
+
const h = harness();
|
|
48
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
49
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
50
|
+
|
|
51
|
+
expect(h.signal()?.aborted).toBe(false);
|
|
52
|
+
expect(runs.stop(started.id)).toBe(true);
|
|
53
|
+
expect(h.signal()?.aborted).toBe(true);
|
|
54
|
+
expect(runs.snapshot(started.id)?.status).toBe('stopped');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('★★ 结算首胜:停过之后 run 再 resolve 也不许翻案(还是 stopped,不是 ok)', async () => {
|
|
58
|
+
const h = harness();
|
|
59
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
60
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
61
|
+
|
|
62
|
+
runs.stop(started.id);
|
|
63
|
+
h.done({ anything: true });
|
|
64
|
+
await flush();
|
|
65
|
+
|
|
66
|
+
expect(runs.snapshot(started.id)?.status).toBe('stopped');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('★★ 被 abort 的 run 常常 reject(AbortError):那一趟不许变成 failed', async () => {
|
|
70
|
+
const h = harness();
|
|
71
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
72
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
73
|
+
|
|
74
|
+
runs.stop(started.id);
|
|
75
|
+
h.boom(new Error('This operation was aborted'));
|
|
76
|
+
await flush();
|
|
77
|
+
|
|
78
|
+
expect(runs.snapshot(started.id)?.status).toBe('stopped');
|
|
79
|
+
expect(runs.snapshot(started.id)?.why).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('★★ 不是活的:已收尾 / 不认识的 id ⇒ 回 false,什么都没变', async () => {
|
|
83
|
+
const h = harness();
|
|
84
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
85
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
86
|
+
|
|
87
|
+
h.done({});
|
|
88
|
+
await flush();
|
|
89
|
+
|
|
90
|
+
expect(runs.stop(started.id)).toBe(false);
|
|
91
|
+
expect(runs.snapshot(started.id)?.status).toBe('ok');
|
|
92
|
+
expect(runs.stop('run-nope')).toBe(false);
|
|
93
|
+
expect(runs.current('/w/a')).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('★★ 停这一下走 deps.stopped(与 outcome 之于 ok 同形:停在哪、文案怎么写是模块的知识)', () => {
|
|
97
|
+
const h = harness({ stopped: () => ({ note: '用户按停的' }) });
|
|
98
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
99
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
100
|
+
|
|
101
|
+
runs.stop(started.id);
|
|
102
|
+
|
|
103
|
+
expect(runs.snapshot(started.id)?.note).toBe('用户按停的');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('★ 等你登录时也能停:登录地址跟着清掉', () => {
|
|
107
|
+
const h = harness();
|
|
108
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
109
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
110
|
+
|
|
111
|
+
h.step({ kind: 'start', text: '未登录', loginUrl: 'https://x/login' });
|
|
112
|
+
expect(runs.snapshot(started.id)?.status).toBe('needs-login');
|
|
113
|
+
|
|
114
|
+
expect(runs.stop(started.id)).toBe(true);
|
|
115
|
+
expect(runs.snapshot(started.id)?.status).toBe('stopped');
|
|
116
|
+
expect(runs.snapshot(started.id)?.loginUrl).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('★ 停过之后迟到的步骤也不进这一趟(结算首胜)', () => {
|
|
120
|
+
const h = harness();
|
|
121
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
122
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
123
|
+
|
|
124
|
+
runs.stop(started.id);
|
|
125
|
+
h.step({ kind: 'info', text: '迟到一步' });
|
|
126
|
+
|
|
127
|
+
expect(runs.snapshot(started.id)?.steps).toEqual([]);
|
|
128
|
+
expect(runs.snapshot(started.id)?.status).toBe('stopped');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('★★ current() 每条都带 id(浏览器按 id 点那颗钮),view 覆盖不了', () => {
|
|
132
|
+
const h = harness({ view: (snapshot) => ({ id: 'spoofed', status: snapshot.status }) });
|
|
133
|
+
const runs = createLongTaskRegistry({ ...h.deps, nextId: () => 'run-1' });
|
|
134
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
135
|
+
|
|
136
|
+
const [row] = runs.current('/w/a');
|
|
137
|
+
|
|
138
|
+
expect(started.id).toBe('run-1');
|
|
139
|
+
expect(row?.['id']).toBe('run-1');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('sharedLongTaskRegistry(那一份跨热重载存活)', () => {
|
|
144
|
+
it('★★ 槽里那份**上一版的注册表**(四个方法、没有 stop)仍被认回:升级那次重载不重建空表', () => {
|
|
145
|
+
const scope = `test-${String(Date.now())}-${Math.random()}`;
|
|
146
|
+
const slot = Symbol.for(`dsh-workbench:long-task:${scope}:demo`);
|
|
147
|
+
const stale = {
|
|
148
|
+
start: () => undefined,
|
|
149
|
+
snapshot: () => null,
|
|
150
|
+
active: () => null,
|
|
151
|
+
current: () => [],
|
|
152
|
+
onChange: () => () => {},
|
|
153
|
+
};
|
|
154
|
+
(globalThis as unknown as Record<symbol, unknown>)[slot] = stale;
|
|
155
|
+
|
|
156
|
+
const again = sharedLongTaskRegistry(scope, 'demo', harness().deps);
|
|
157
|
+
|
|
158
|
+
expect(again).toBe(stale);
|
|
159
|
+
expect(Object.hasOwn(again, 'stop')).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('★ 认不出(热重载换了接口)⇒ 重造,槽里换成新表', () => {
|
|
163
|
+
const scope = `test-${String(Date.now())}-${Math.random()}`;
|
|
164
|
+
const slot = Symbol.for(`dsh-workbench:long-task:${scope}:demo`);
|
|
165
|
+
const stale = {
|
|
166
|
+
start: () => undefined,
|
|
167
|
+
snapshot: () => null,
|
|
168
|
+
active: () => null,
|
|
169
|
+
};
|
|
170
|
+
(globalThis as unknown as Record<symbol, unknown>)[slot] = stale;
|
|
171
|
+
|
|
172
|
+
const fresh = sharedLongTaskRegistry(scope, 'demo', harness().deps);
|
|
173
|
+
|
|
174
|
+
expect(fresh).not.toBe(stale);
|
|
175
|
+
expect((globalThis as unknown as Record<symbol, unknown>)[slot]).toBe(fresh);
|
|
176
|
+
expect(typeof fresh.stop).toBe('function');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('来源会话(谁的这一趟,决定停它要不要审批)', () => {
|
|
181
|
+
it('★★ start 把来源会话记进快照', () => {
|
|
182
|
+
const h = harness();
|
|
183
|
+
const runs = createLongTaskRegistry({ ...h.deps, nextId: () => 'run-1' });
|
|
184
|
+
const started = runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
|
|
185
|
+
|
|
186
|
+
expect(started.session).toBe('s-1');
|
|
187
|
+
expect(runs.snapshot('run-1')?.session).toBe('s-1');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('★★ 不给来源 ⇒ 快照里连 session 这个键都没有(别把 undefined 塞进去)', () => {
|
|
191
|
+
const h = harness();
|
|
192
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
193
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
194
|
+
|
|
195
|
+
expect(started.session).toBeUndefined();
|
|
196
|
+
expect(Object.keys(runs.snapshot(started.id) ?? {})).not.toContain('session');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('★★ stop(id, by):谁按的落在那条 settle 通知上', () => {
|
|
200
|
+
const h = harness();
|
|
201
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
202
|
+
const seen: Array<{ kind: string; by: string | undefined; status: string }> = [];
|
|
203
|
+
runs.onChange((change) => {
|
|
204
|
+
seen.push({ kind: change.kind, by: change.by, status: change.snapshot.status });
|
|
205
|
+
});
|
|
206
|
+
const started = runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
|
|
207
|
+
|
|
208
|
+
expect(runs.stop(started.id, 's-2')).toBe(true);
|
|
209
|
+
|
|
210
|
+
expect(seen).toEqual([
|
|
211
|
+
{ kind: 'start', by: undefined, status: 'running' },
|
|
212
|
+
{ kind: 'settle', by: 's-2', status: 'stopped' },
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('★★ stop(id) 不给 by ⇒ 通知里的 by 是 undefined(界面上按的用户)', () => {
|
|
217
|
+
const h = harness();
|
|
218
|
+
const runs = createLongTaskRegistry(h.deps);
|
|
219
|
+
let settleBy: string | undefined = 'sentinel';
|
|
220
|
+
runs.onChange((change) => {
|
|
221
|
+
if (change.kind === 'settle') settleBy = change.by;
|
|
222
|
+
});
|
|
223
|
+
const started = runs.start({ workspace: '/w/a', target: 'release' });
|
|
224
|
+
|
|
225
|
+
expect(runs.stop(started.id)).toBe(true);
|
|
226
|
+
expect(settleBy).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('createLongTaskHost 的 notify(每次变更都叫一下,bump 那半照旧)', () => {
|
|
231
|
+
it('★★ 起 / 走一步 / 被停 三次都叫 notify;停那笔带着谁按的', () => {
|
|
232
|
+
const bumps: string[][] = [];
|
|
233
|
+
const seen: string[] = [];
|
|
234
|
+
const box = createLongTaskHost({
|
|
235
|
+
scope: `test-${String(Date.now())}-${Math.random()}`,
|
|
236
|
+
unit: 'demo',
|
|
237
|
+
bump: (facts) => bumps.push([...facts]),
|
|
238
|
+
notify: (change) => seen.push(`${change.kind}:${change.snapshot.status}:${change.by ?? '-'}`),
|
|
239
|
+
});
|
|
240
|
+
const h = harness();
|
|
241
|
+
const runs = box.host.create({ key: 'release', ...h.deps, nextId: () => 'run-1' });
|
|
242
|
+
|
|
243
|
+
runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
|
|
244
|
+
h.step({ kind: 'start', text: '走一步' });
|
|
245
|
+
|
|
246
|
+
expect(runs.stop('run-1', 's-1')).toBe(true);
|
|
247
|
+
expect(seen).toEqual(['start:running:-', 'step:running:-', 'settle:stopped:s-1']);
|
|
248
|
+
expect(bumps).toEqual([['demo/runs'], ['demo/runs'], ['demo/runs']]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('★ 没给 notify 也照常跑(老调用点不受影响)', () => {
|
|
252
|
+
const bumps: string[][] = [];
|
|
253
|
+
const box = createLongTaskHost({
|
|
254
|
+
scope: `test-${String(Date.now())}-${Math.random()}`,
|
|
255
|
+
unit: 'demo',
|
|
256
|
+
bump: (facts) => bumps.push([...facts]),
|
|
257
|
+
});
|
|
258
|
+
const h = harness();
|
|
259
|
+
|
|
260
|
+
box.host.create({ key: 'release', ...h.deps }).start({ workspace: '/w/a', target: 'release' });
|
|
261
|
+
|
|
262
|
+
expect(bumps).toEqual([['demo/runs']]);
|
|
263
|
+
});
|
|
264
|
+
});
|
package/src/host/long-task.ts
CHANGED
|
@@ -15,27 +15,32 @@ export interface LongTaskBase {
|
|
|
15
15
|
readonly why?: string | undefined;
|
|
16
16
|
readonly startedAt: string;
|
|
17
17
|
readonly at: string;
|
|
18
|
+
readonly session?: string | undefined;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface LongTaskStart {
|
|
21
22
|
readonly workspace: string;
|
|
22
23
|
readonly target: string;
|
|
23
24
|
readonly payload?: unknown;
|
|
25
|
+
readonly session?: string | undefined;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export interface LongTaskChange<S extends LongTaskBase> {
|
|
27
29
|
readonly kind: 'start' | 'step' | 'settle';
|
|
28
30
|
readonly snapshot: S;
|
|
29
31
|
readonly step?: LongTaskStep | undefined;
|
|
32
|
+
readonly by?: string | undefined;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export interface LongTaskDeps<S extends LongTaskBase> {
|
|
33
36
|
readonly run: (options: {
|
|
34
37
|
readonly payload: unknown;
|
|
35
38
|
readonly onStep: (step: LongTaskStep) => void;
|
|
39
|
+
readonly signal: AbortSignal;
|
|
36
40
|
}) => Promise<unknown>;
|
|
37
41
|
readonly step?: ((step: LongTaskStep, current: S) => Partial<S>) | undefined;
|
|
38
42
|
readonly outcome?: ((result: unknown, current: S) => Partial<S>) | undefined;
|
|
43
|
+
readonly stopped?: ((current: S) => Partial<S>) | undefined;
|
|
39
44
|
readonly view: (snapshot: S) => Readonly<Record<string, unknown>>;
|
|
40
45
|
readonly now?: (() => string) | undefined;
|
|
41
46
|
readonly nextId?: (() => string) | undefined;
|
|
@@ -48,17 +53,25 @@ export interface LongTaskRegistry<S extends LongTaskBase> {
|
|
|
48
53
|
snapshot(id: string): S | null;
|
|
49
54
|
active(workspace: string, target: string): S | null;
|
|
50
55
|
current(workspace: string): readonly Readonly<Record<string, unknown>>[];
|
|
56
|
+
stop(id: string, by?: string): boolean;
|
|
51
57
|
onChange(listener: (change: LongTaskChange<S>) => void): () => void;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDeps<S>): LongTaskRegistry<S> {
|
|
55
61
|
const runs = new Map<string, LongTaskBase>();
|
|
62
|
+
const controls = new Map<string, AbortController>();
|
|
56
63
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
57
64
|
let counter = 0;
|
|
58
65
|
const nextId = deps.nextId ?? (() => `run-${Date.now()}-${(counter += 1)}`);
|
|
59
66
|
const recentMs = deps.recentMs ?? RECENT_MS;
|
|
60
67
|
const listeners = new Set<(change: LongTaskChange<S>) => void>();
|
|
61
68
|
|
|
69
|
+
const live = (id: string): S | null => {
|
|
70
|
+
const current = runs.get(id);
|
|
71
|
+
if (current === undefined || !LIVE.has(current.status)) return null;
|
|
72
|
+
return current as S;
|
|
73
|
+
};
|
|
74
|
+
|
|
62
75
|
const notify = (change: LongTaskChange<S>): void => {
|
|
63
76
|
for (const listener of [...listeners]) {
|
|
64
77
|
try {
|
|
@@ -97,7 +110,7 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
|
|
|
97
110
|
(snapshot) => LIVE.has(snapshot.status) || Date.parse(snapshot.at) >= floor,
|
|
98
111
|
)
|
|
99
112
|
.reverse()
|
|
100
|
-
.map((snapshot) => deps.view(snapshot as S));
|
|
113
|
+
.map((snapshot) => ({ ...deps.view(snapshot as S), id: snapshot.id }));
|
|
101
114
|
},
|
|
102
115
|
|
|
103
116
|
start(options) {
|
|
@@ -114,14 +127,15 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
|
|
|
114
127
|
steps: [],
|
|
115
128
|
startedAt,
|
|
116
129
|
at: startedAt,
|
|
130
|
+
...(options.session === undefined ? {} : { session: options.session }),
|
|
117
131
|
});
|
|
118
132
|
notify({ kind: 'start', snapshot: runs.get(id) as S });
|
|
119
133
|
|
|
120
134
|
const onStep = (step: LongTaskStep): void => {
|
|
121
|
-
const current =
|
|
122
|
-
if (current ===
|
|
135
|
+
const current = live(id);
|
|
136
|
+
if (current === null) return;
|
|
123
137
|
const steps = [...current.steps, `${step.kind === 'fail' ? '✗ ' : ''}${step.text}`].slice(-20);
|
|
124
|
-
const extra = deps.step?.(step, current
|
|
138
|
+
const extra = deps.step?.(step, current) ?? {};
|
|
125
139
|
if (step.loginUrl !== undefined) {
|
|
126
140
|
patch(
|
|
127
141
|
id,
|
|
@@ -143,17 +157,24 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
|
|
|
143
157
|
);
|
|
144
158
|
};
|
|
145
159
|
|
|
160
|
+
const control = new AbortController();
|
|
161
|
+
controls.set(id, control);
|
|
162
|
+
|
|
146
163
|
void deps
|
|
147
|
-
.run({ payload: options.payload, onStep })
|
|
164
|
+
.run({ payload: options.payload, onStep, signal: control.signal })
|
|
148
165
|
.then((result) => {
|
|
149
|
-
const current =
|
|
166
|
+
const current = live(id);
|
|
167
|
+
if (current === null) return;
|
|
168
|
+
controls.delete(id);
|
|
150
169
|
patch(
|
|
151
170
|
id,
|
|
152
|
-
{ status: 'ok', ...(deps.outcome?.(result, current
|
|
171
|
+
{ status: 'ok', ...(deps.outcome?.(result, current) ?? {}), loginUrl: undefined },
|
|
153
172
|
{ kind: 'settle' },
|
|
154
173
|
);
|
|
155
174
|
})
|
|
156
175
|
.catch((error: unknown) => {
|
|
176
|
+
if (live(id) === null) return;
|
|
177
|
+
controls.delete(id);
|
|
157
178
|
patch(
|
|
158
179
|
id,
|
|
159
180
|
{
|
|
@@ -168,6 +189,19 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
|
|
|
168
189
|
return runs.get(id) as S;
|
|
169
190
|
},
|
|
170
191
|
|
|
192
|
+
stop(id, by) {
|
|
193
|
+
const current = live(id);
|
|
194
|
+
if (current === null) return false;
|
|
195
|
+
controls.get(id)?.abort();
|
|
196
|
+
controls.delete(id);
|
|
197
|
+
patch(
|
|
198
|
+
id,
|
|
199
|
+
{ ...(deps.stopped?.(current) ?? {}), status: 'stopped', loginUrl: undefined },
|
|
200
|
+
{ kind: 'settle', by },
|
|
201
|
+
);
|
|
202
|
+
return true;
|
|
203
|
+
},
|
|
204
|
+
|
|
171
205
|
snapshot(id) {
|
|
172
206
|
return (runs.get(id) as S | undefined) ?? null;
|
|
173
207
|
},
|
|
@@ -212,6 +246,7 @@ export function createLongTaskHost(options: {
|
|
|
212
246
|
readonly scope: string;
|
|
213
247
|
readonly unit: string;
|
|
214
248
|
readonly bump: (facts: readonly string[]) => void;
|
|
249
|
+
readonly notify?: ((change: LongTaskChange<LongTaskBase>) => void) | undefined;
|
|
215
250
|
}): { readonly host: LongTaskHost; dispose(): void } {
|
|
216
251
|
const offs: Array<() => void> = [];
|
|
217
252
|
return {
|
|
@@ -223,6 +258,7 @@ export function createLongTaskHost(options: {
|
|
|
223
258
|
registry.onChange((change) => {
|
|
224
259
|
const named = [runsRouteOf(options.unit), ...(facts?.(change) ?? [])];
|
|
225
260
|
options.bump([...new Set(named)]);
|
|
261
|
+
options.notify?.(change);
|
|
226
262
|
}),
|
|
227
263
|
);
|
|
228
264
|
return registry;
|
package/src/host/unit-face.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface UnitMountDeps {
|
|
|
28
28
|
options?: { readonly force?: boolean },
|
|
29
29
|
) => Promise<UnitCapability | undefined>;
|
|
30
30
|
readonly longTasks?: LongTaskHost | undefined;
|
|
31
|
+
readonly longTaskRuns?: (() => ReadonlyMap<string, LongTaskRegistry<LongTaskBase>>) | undefined;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export type ContextRenderer = (ref: {
|
package/src/i18n/en.ts
CHANGED
|
@@ -451,4 +451,7 @@ export const en: Record<keyof WorkbenchMessages, string> = {
|
|
|
451
451
|
'approval.record.noUrl': 'no site given (this call gets refused)',
|
|
452
452
|
'approval.record.oneAction': 'one action',
|
|
453
453
|
'approval.record.noSwitch': 'it will not switch you back (the leftbar footer shows the state)',
|
|
454
|
+
'approval.longTask.what': 'Stops',
|
|
455
|
+
'approval.longTask.signal':
|
|
456
|
+
'stopping only sends an interrupt signal — how the platform-side run finishes is up to the module',
|
|
454
457
|
};
|
package/src/i18n/zh.ts
CHANGED
|
@@ -446,6 +446,8 @@ export const zh = {
|
|
|
446
446
|
'approval.record.noUrl': '没给站点地址(这次会被当场拒)',
|
|
447
447
|
'approval.record.oneAction': '一次操作',
|
|
448
448
|
'approval.record.noSwitch': '录完不会替你切回来(左栏底部会亮一条状态)',
|
|
449
|
+
'approval.longTask.what': '要停的那一趟',
|
|
450
|
+
'approval.longTask.signal': '停了只是让外壳发出中断信号 —— 平台侧那趟活儿的收尾看模块自己',
|
|
449
451
|
} as const;
|
|
450
452
|
|
|
451
453
|
/** 中文词表的键类型:`en` 与模块词表都得覆盖同一批键。 */
|