@dsh-workbench/shared 0.3.5 → 0.3.7

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.
Files changed (153) hide show
  1. package/dist/api/context-ref.d.ts +0 -12
  2. package/dist/api/context-ref.js +0 -40
  3. package/dist/api/facts.d.ts +0 -6
  4. package/dist/api/facts.js +0 -8
  5. package/dist/api/long-task.d.ts +33 -4
  6. package/dist/api/long-task.js +34 -2
  7. package/dist/components/ConfirmBar.css +26 -0
  8. package/dist/components/ConfirmBar.d.ts +36 -0
  9. package/dist/components/ConfirmBar.js +26 -0
  10. package/dist/components/Form.d.ts +7 -5
  11. package/dist/components/Form.js +5 -3
  12. package/dist/components/FormNodes.d.ts +17 -13
  13. package/dist/components/FormNodes.js +13 -5
  14. package/dist/components/FormRows.css +31 -0
  15. package/dist/components/FormTypes.d.ts +39 -14
  16. package/dist/components/LongTaskRow.css +52 -0
  17. package/dist/components/LongTaskRow.d.ts +18 -2
  18. package/dist/components/LongTaskRow.js +33 -5
  19. package/dist/components/MockStrip.css +11 -1
  20. package/dist/components/StateDot.d.ts +5 -0
  21. package/dist/components/StateDot.js +10 -0
  22. package/dist/config/constants.d.ts +2 -0
  23. package/dist/config/constants.js +2 -0
  24. package/dist/host/__tests__/fakes.d.ts +41 -0
  25. package/dist/host/__tests__/fakes.js +76 -0
  26. package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
  27. package/dist/host/__tests__/preflight-fixtures.js +47 -0
  28. package/dist/host/board-fact.d.ts +8 -0
  29. package/dist/host/board-fact.js +16 -0
  30. package/dist/host/browser-read.js +2 -1
  31. package/dist/host/context-inject.d.ts +24 -0
  32. package/dist/host/context-inject.js +74 -0
  33. package/dist/host/context-ref.d.ts +13 -0
  34. package/dist/host/context-ref.js +41 -0
  35. package/dist/host/diy-debug.d.ts +80 -0
  36. package/dist/host/diy-debug.js +88 -0
  37. package/dist/host/diy-record.d.ts +135 -0
  38. package/dist/host/diy-record.js +162 -0
  39. package/dist/host/events.d.ts +9 -0
  40. package/dist/host/events.js +70 -0
  41. package/dist/host/facts.d.ts +6 -0
  42. package/dist/host/facts.js +9 -0
  43. package/dist/host/git.js +1 -1
  44. package/dist/host/http.d.ts +2 -2
  45. package/dist/host/http.js +3 -3
  46. package/dist/host/long-task.d.ts +12 -0
  47. package/dist/host/long-task.js +34 -5
  48. package/dist/host/mount-units.d.ts +52 -0
  49. package/dist/host/mount-units.js +186 -0
  50. package/dist/host/opencli-runtime.js +1 -1
  51. package/dist/host/opencli-window.js +1 -1
  52. package/dist/host/opencli.js +2 -1
  53. package/dist/host/panel-state.d.ts +120 -0
  54. package/dist/host/panel-state.js +176 -0
  55. package/dist/host/plugin-source.js +1 -1
  56. package/dist/host/preflight.d.ts +58 -0
  57. package/dist/host/preflight.js +233 -0
  58. package/dist/host/probe-cache.d.ts +6 -0
  59. package/dist/host/probe-cache.js +19 -0
  60. package/dist/host/probe-libs.d.ts +45 -0
  61. package/dist/host/probe-libs.js +61 -0
  62. package/dist/host/probe-units.d.ts +17 -0
  63. package/dist/host/probe-units.js +102 -0
  64. package/dist/host/probe-verdict.d.ts +7 -0
  65. package/dist/host/probe-verdict.js +143 -0
  66. package/dist/host/shell-text.d.ts +13 -0
  67. package/dist/host/shell-text.js +20 -0
  68. package/dist/host/tool-approval.d.ts +1 -0
  69. package/dist/host/tool-scope.d.ts +32 -0
  70. package/dist/host/tool-scope.js +84 -0
  71. package/dist/host/unit-declaration.d.ts +65 -0
  72. package/dist/host/unit-declaration.js +213 -0
  73. package/dist/host/unit-face.d.ts +1 -0
  74. package/dist/host/unit-readiness.d.ts +7 -0
  75. package/dist/host/unit-readiness.js +5 -0
  76. package/dist/host/unit-registry.d.ts +9 -0
  77. package/dist/host/unit-registry.js +33 -0
  78. package/dist/host/version-range.d.ts +2 -0
  79. package/dist/host/version-range.js +73 -0
  80. package/dist/host.d.ts +7 -4
  81. package/dist/host.js +7 -4
  82. package/dist/i18n/en.js +6 -0
  83. package/dist/i18n/index.d.ts +7 -1
  84. package/dist/i18n/zh.d.ts +6 -0
  85. package/dist/i18n/zh.js +6 -0
  86. package/dist/index.d.ts +1 -4
  87. package/dist/index.js +1 -4
  88. package/dist/lib/board-fact.d.ts +0 -7
  89. package/dist/lib/board-fact.js +0 -15
  90. package/dist/lib/diy-record.d.ts +38 -1
  91. package/dist/lib/diy-record.js +66 -0
  92. package/dist/lib/text.d.ts +0 -13
  93. package/dist/lib/text.js +0 -20
  94. package/dist/testing/declaration.js +1 -1
  95. package/package.json +1 -1
  96. package/src/api/__tests__/facts.test.ts +0 -13
  97. package/src/api/__tests__/long-task.test.ts +41 -1
  98. package/src/api/context-ref.ts +0 -39
  99. package/src/api/facts.ts +0 -9
  100. package/src/api/long-task.ts +46 -4
  101. package/src/components/ConfirmBar.css +26 -0
  102. package/src/components/ConfirmBar.test.tsx +130 -0
  103. package/src/components/ConfirmBar.tsx +63 -0
  104. package/src/components/Form.test.tsx +15 -0
  105. package/src/components/Form.tsx +7 -5
  106. package/src/components/FormNodes.tsx +50 -26
  107. package/src/components/FormRows.css +31 -0
  108. package/src/components/FormRows.test.tsx +71 -1
  109. package/src/components/FormTypes.test.ts +31 -0
  110. package/src/components/FormTypes.ts +46 -15
  111. package/src/components/FormWiring.test.tsx +1 -0
  112. package/src/components/LongTaskRow.css +52 -0
  113. package/src/components/LongTaskRow.tsx +59 -4
  114. package/src/components/__tests__/long-task-row.test.ts +115 -0
  115. package/src/config/constants.ts +3 -0
  116. package/src/host/__tests__/board-fact.test.ts +47 -0
  117. package/src/{lib → host/__tests__}/diy-debug.test.ts +1 -1
  118. package/src/{lib → host/__tests__}/diy-record.test.ts +137 -1
  119. package/src/host/__tests__/facts.test.ts +13 -0
  120. package/src/host/__tests__/long-task.test.ts +306 -0
  121. package/src/{lib → host/__tests__}/panel-state.test.ts +1 -1
  122. package/src/host/__tests__/preflight-route.test.ts +33 -0
  123. package/src/host/__tests__/shell-text.test.ts +44 -0
  124. package/src/host/board-fact.ts +14 -0
  125. package/src/host/browser-read.ts +2 -1
  126. package/src/host/context-ref.ts +40 -0
  127. package/src/{lib → host}/diy-record.ts +95 -1
  128. package/src/host/facts.ts +10 -0
  129. package/src/host/git.ts +1 -1
  130. package/src/host/http.ts +4 -4
  131. package/src/host/long-task.ts +52 -7
  132. package/src/host/opencli-runtime.ts +1 -1
  133. package/src/host/opencli-window.ts +1 -1
  134. package/src/host/opencli.ts +2 -1
  135. package/src/host/plugin-source.ts +1 -1
  136. package/src/host/shell-text.ts +21 -0
  137. package/src/host/tool-approval.ts +1 -0
  138. package/src/{api → host}/unit-declaration.ts +1 -1
  139. package/src/host/unit-face.ts +1 -0
  140. package/src/host.ts +7 -4
  141. package/src/i18n/en.ts +7 -0
  142. package/src/i18n/zh.ts +6 -0
  143. package/src/index.ts +1 -4
  144. package/src/lib/board-fact.test.ts +1 -33
  145. package/src/lib/board-fact.ts +0 -13
  146. package/src/lib/text.test.ts +1 -43
  147. package/src/lib/text.ts +0 -22
  148. package/src/testing/declaration.ts +1 -1
  149. package/dist/lib/lint-probe.d.ts +0 -1
  150. package/dist/lib/lint-probe.js +0 -6
  151. /package/src/{api → host}/__tests__/unit-declaration.test.ts +0 -0
  152. /package/src/{lib → host}/diy-debug.ts +0 -0
  153. /package/src/{lib → host}/panel-state.ts +0 -0
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { preflightHandler } from '../http.js';
3
+
4
+ const SNAPSHOT = { phase: 'ready', steps: [], at: 1 };
5
+
6
+ function handlerWith() {
7
+ const run = vi.fn(async () => SNAPSHOT);
8
+ return { run, handler: preflightHandler({ run, snapshot: () => SNAPSHOT }) };
9
+ }
10
+
11
+ describe('preflightHandler(体检那一拍带什么参数)', () => {
12
+ it('平时(不带 hard)交给宿主自己判新鲜度', async () => {
13
+ const { run, handler } = handlerWith();
14
+
15
+ await handler(new URLSearchParams(''));
16
+
17
+ expect(run).toHaveBeenCalledWith({ hard: false });
18
+ });
19
+
20
+ it('?hard=1 一路传到 run(用户按的那颗「重试」)', async () => {
21
+ const { run, handler } = handlerWith();
22
+
23
+ await handler(new URLSearchParams('hard=1'));
24
+
25
+ expect(run).toHaveBeenCalledWith({ hard: true });
26
+ });
27
+
28
+ it('不管 run 跑没跑完,回的都是当前快照(页面靠 400ms 轮询看进度)', () => {
29
+ const { handler } = handlerWith();
30
+
31
+ expect(handler(new URLSearchParams(''))).toEqual({ ok: true, ...SNAPSHOT });
32
+ });
33
+ });
@@ -0,0 +1,44 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { quoteShellArg, tailLines } from '../shell-text.js';
4
+
5
+ function shellSees(value: string): string {
6
+ return execFileSync('/bin/sh', ['-c', `printf '%s' ${quoteShellArg(value)}`], {
7
+ encoding: 'utf8',
8
+ });
9
+ }
10
+
11
+ describe('quoteShellArg', () => {
12
+ it('普通值原样往返', () => {
13
+ expect(shellSees('example-monorepo')).toBe('example-monorepo');
14
+ });
15
+
16
+ it('含空格的路径不被拆成两个参数', () => {
17
+ expect(shellSees('/Volumes/My Projects/app')).toBe('/Volumes/My Projects/app');
18
+ });
19
+
20
+ it('单引号被正确转义(否则就是注入面)', () => {
21
+ expect(shellSees("it's")).toBe("it's");
22
+ });
23
+
24
+ it('shell 元字符按字面量传递', () => {
25
+ for (const raw of ['$(whoami)', '`id`', 'a; rm -rf /', 'a && b', '*', '"double"', '\\n']) {
26
+ expect(shellSees(raw), raw).toBe(raw);
27
+ }
28
+ });
29
+ });
30
+
31
+ describe('tailLines', () => {
32
+ it('取末尾若干行并去掉首尾空行', () => {
33
+ expect(tailLines('\n a \n b \n', 2)).toBe('a \n b');
34
+ });
35
+
36
+ it('行数不够时全给', () => {
37
+ expect(tailLines('only', 6)).toBe('only');
38
+ });
39
+
40
+ it('空输入给空串', () => {
41
+ expect(tailLines(undefined, 3)).toBe('');
42
+ expect(tailLines(' ', 3)).toBe('');
43
+ });
44
+ });
@@ -0,0 +1,14 @@
1
+ import { insideWorkshop, type BoardFactState, type BoardProbe } from '../lib/board-fact.js';
2
+
3
+ /**
4
+ * 把一次探针读数归成椅面归属。
5
+ * @param probe - 探针读数。
6
+ * @param workshop - 当前工作区根路径;`null` 表示还不知道当前是哪个工作区。
7
+ * @returns 外壳不在、或板路径与工作区缺一 → `unknown`;右栏没开或没画出来 → `hidden`;板在本工作区 → `workshop`,否则 `other`。
8
+ */
9
+ export function boardFactOf(probe: BoardProbe, workshop: string | null): BoardFactState {
10
+ if (!probe.workbench) return 'unknown';
11
+ if (!probe.bar || !probe.open || !probe.drawn) return 'hidden';
12
+ if (probe.board === '' || workshop === null) return 'unknown';
13
+ return insideWorkshop(workshop, probe.board) ? 'workshop' : 'other';
14
+ }
@@ -1,5 +1,6 @@
1
1
  import type { DshShell } from '../api/dsh.js';
2
- import { parseJsonLoose, tailLines } from '../lib/text.js';
2
+ import { parseJsonLoose } from '../lib/text.js';
3
+ import { tailLines } from './shell-text.js';
3
4
  import { backgroundLine } from './opencli-window.js';
4
5
  import { runShell } from './shell.js';
5
6
 
@@ -0,0 +1,40 @@
1
+ import { CONTEXT_MARKER_NAME, encodeRef, type ContextRef } from '../api/context-ref.js';
2
+
3
+ const MARKER = new RegExp(`@${CONTEXT_MARKER_NAME}:([a-z][a-z0-9-]*:[A-Za-z0-9._~%#/-]+)`, 'g');
4
+
5
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
6
+
7
+ /**
8
+ * 把 kind:part/part 文本读回引用。
9
+ * @param text 待解析文本。
10
+ * @returns 没冒号、冒号在首尾、种类不是小写字母开头、或冒号后为空时给 undefined;不抛异常。
11
+ */
12
+ export function decodeRef(text: string): ContextRef | undefined {
13
+ const at = text.indexOf(':');
14
+ if (at <= 0 || at === text.length - 1) return undefined;
15
+ const kind = text.slice(0, at);
16
+ if (!/^[a-z][a-z0-9-]*$/.test(kind)) return undefined;
17
+ const parts = text.slice(at + 1).split('/').filter((part) => part !== '');
18
+ if (parts.length === 0) return undefined;
19
+ return { kind, parts };
20
+ }
21
+
22
+ /**
23
+ * 从一段文本里按出现顺序捞出所有标记引用,同一个引用只留第一次。
24
+ * @param text 任意文本;null / undefined 当空串。
25
+ * @returns 解析不出来的标记会被丢掉,所以结果只含合法引用,可能为空数组。
26
+ */
27
+ export function parseMarkers(text: string): ContextRef[] {
28
+ const seen = new Set<string>();
29
+ const refs: ContextRef[] = [];
30
+ for (const match of String(text ?? '').matchAll(MARKER)) {
31
+ const raw = match[1].replace(TRAILING_PUNCTUATION, '');
32
+ const ref = decodeRef(raw);
33
+ if (ref === undefined) continue;
34
+ const key = encodeRef(ref);
35
+ if (seen.has(key)) continue;
36
+ seen.add(key);
37
+ refs.push(ref);
38
+ }
39
+ return refs;
40
+ }
@@ -17,7 +17,32 @@ export interface RecordExpect {
17
17
  }
18
18
 
19
19
  /**
20
- * 一份活着的录制约定:相位 + 谁的会话 + 哪个工作区 / 单元 + 页面 URL 与标题 + 要找的那一次写 + 落盘文件 + 三个时间戳。
20
+ * 一次「截图」在宿主台账里的那一行:页面上的计数、落图那一刻、落盘路径;没落下来时带 `error` 原文(落成的那条没有它)。
21
+ */
22
+ export interface RecordShot {
23
+ readonly n: number;
24
+ readonly at: number;
25
+ readonly path: string;
26
+ readonly error?: string;
27
+ }
28
+
29
+ /**
30
+ * 某个页面计数已经试过几次:初拍算一次,失败再试一次;计数一换就从头数。
31
+ */
32
+ export interface RecordShotTries {
33
+ readonly n: number;
34
+ readonly tries: number;
35
+ }
36
+
37
+ /**
38
+ * 一张图最多试几次(初拍 + 一次重试):失败如实报过就停,不每拍重试到底。
39
+ */
40
+ export const RECORD_SHOT_TRIES = 2;
41
+
42
+ /**
43
+ * 一份活着的录制约定:相位 + 谁的会话 + 哪个工作区 / 单元 + 页面 URL 与标题 + 要找的那一次写 +
44
+ * 落盘文件 + 三个时间戳,以及两份台账:按计数落图的 `shots`(`shotsSeen` 只记落成的那个计数)
45
+ * 与门铃的 `beats` / `beatMs`(命中几次、最近一次等了多久)。
21
46
  */
22
47
  export interface RecordState {
23
48
  readonly phase: RecordPhase;
@@ -33,6 +58,11 @@ export interface RecordState {
33
58
  readonly startedAt: number;
34
59
  readonly t0: number | null;
35
60
  readonly t1: number | null;
61
+ readonly shots: readonly RecordShot[];
62
+ readonly shotsSeen: number;
63
+ readonly shotTries: RecordShotTries | null;
64
+ readonly beats: number;
65
+ readonly beatMs: number;
36
66
  readonly at: number;
37
67
  }
38
68
 
@@ -95,9 +125,26 @@ export interface RecordStore {
95
125
  begin(pact: RecordPact): RecordMove;
96
126
  started(sessionId: string, at: number, now: number): RecordMove;
97
127
  finished(sessionId: string, at: number, now: number): RecordMove;
128
+ shotTaken(sessionId: string, shot: RecordShot, now: number): RecordMove;
129
+ shotFailed(sessionId: string, shot: RecordShot, now: number): RecordMove;
130
+ beat(sessionId: string, ms: number, now: number): RecordMove;
98
131
  stop(sessionId: string, now: number): RecordMove;
99
132
  }
100
133
 
134
+ /**
135
+ * 这一拍该不该落图:页面计数涨过 `shotsSeen` 才算;同一个计数已经试满就先放下,等他自己再按。
136
+ * @param state - 当前有效的那份约定。
137
+ * @param pageShots - 页面浮条上那个「已拍」计数。
138
+ * @returns 该落的那个计数;不该落给 `null`(没涨 / 试满了 / 不是个正经数)。
139
+ */
140
+ export function shotDueOf(state: RecordState, pageShots: number): number | null {
141
+ if (!Number.isFinite(pageShots)) return null;
142
+ const n = Math.floor(pageShots);
143
+ if (n <= state.shotsSeen) return null;
144
+ if (state.shotTries !== null && state.shotTries.n === n && state.shotTries.tries >= RECORD_SHOT_TRIES) return null;
145
+ return n;
146
+ }
147
+
101
148
  function alive(state: RecordState | null, now: number): RecordState | null {
102
149
  if (state === null) return null;
103
150
  return now - state.at <= RECORD_STALE_MS ? state : null;
@@ -118,6 +165,19 @@ export function createRecordStore(): RecordStore {
118
165
 
119
166
  const live = (now: number): RecordState | null => alive(held, now);
120
167
 
168
+ const withShot = (found: RecordState, shot: RecordShot, now: number, error: string | undefined): RecordState => {
169
+ const row: RecordShot = error === undefined ? shot : { n: shot.n, at: shot.at, path: shot.path, error };
170
+ const kept = found.shots.filter((one) => one.n !== shot.n);
171
+ const tries = found.shotTries !== null && found.shotTries.n === shot.n ? found.shotTries.tries : 0;
172
+ return {
173
+ ...found,
174
+ shots: [...kept, row].sort((one, two) => one.n - two.n),
175
+ shotsSeen: error === undefined ? Math.max(found.shotsSeen, shot.n) : found.shotsSeen,
176
+ shotTries: error === undefined ? null : { n: shot.n, tries: tries + 1 },
177
+ at: now,
178
+ };
179
+ };
180
+
121
181
  return {
122
182
  live,
123
183
 
@@ -140,6 +200,11 @@ export function createRecordStore(): RecordStore {
140
200
  startedAt: pact.now,
141
201
  t0: null,
142
202
  t1: null,
203
+ shots: [],
204
+ shotsSeen: 0,
205
+ shotTries: null,
206
+ beats: 0,
207
+ beatMs: 0,
143
208
  at: pact.now,
144
209
  };
145
210
  held = state;
@@ -165,6 +230,35 @@ export function createRecordStore(): RecordStore {
165
230
  return { ok: true, state };
166
231
  },
167
232
 
233
+ shotTaken(sessionId, shot, now) {
234
+ const found = ownerOf(held, sessionId, now);
235
+ if (typeof found === 'string') return { ok: false, code: found };
236
+ const state = withShot(found, shot, now, undefined);
237
+ held = state;
238
+ return { ok: true, state };
239
+ },
240
+
241
+ shotFailed(sessionId, shot, now) {
242
+ const found = ownerOf(held, sessionId, now);
243
+ if (typeof found === 'string') return { ok: false, code: found };
244
+ const state = withShot(found, shot, now, shot.error === undefined ? '落图失败' : shot.error);
245
+ held = state;
246
+ return { ok: true, state };
247
+ },
248
+
249
+ beat(sessionId, ms, now) {
250
+ const found = ownerOf(held, sessionId, now);
251
+ if (typeof found === 'string') return { ok: false, code: found };
252
+ const state: RecordState = {
253
+ ...found,
254
+ beats: found.beats + 1,
255
+ beatMs: Math.max(0, Math.floor(ms)),
256
+ at: now,
257
+ };
258
+ held = state;
259
+ return { ok: true, state };
260
+ },
261
+
168
262
  stop(sessionId, now) {
169
263
  const found = ownerOf(held, sessionId, now);
170
264
  if (typeof found === 'string') return { ok: false, code: found };
package/src/host/facts.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { UnitManifest } from '../api/unit.js';
2
+ import { FACT_SCOPE_SUFFIX } from '../api/facts.js';
2
3
 
3
4
  export interface FactChange {
4
5
  readonly rev: number;
@@ -67,3 +68,12 @@ export function wroteNow(result: unknown): boolean | undefined {
67
68
  const wrote = (value as { wrote?: unknown } | null)?.wrote;
68
69
  return typeof wrote === 'boolean' ? wrote : undefined;
69
70
  }
71
+
72
+ /**
73
+ * 由单元 id 拼出它那一族事实的范围名。
74
+ * @param unit 单元 id,不带后缀。
75
+ * @returns <unit>* —— 永远是范围名,不是某一条事实。
76
+ */
77
+ export function unitFactScope(unit: string): string {
78
+ return `${unit}${FACT_SCOPE_SUFFIX}`;
79
+ }
package/src/host/git.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DshShell } from '../api/dsh.js';
2
- import { quoteShellArg, tailLines } from '../lib/text.js';
2
+ import { quoteShellArg, tailLines } from './shell-text.js';
3
3
  import { runShell } from './shell.js';
4
4
 
5
5
  export type OriginRead =
package/src/host/http.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { DshHostContext, DshRequest, DshResponse } from '../api/dsh.js';
2
- import { unitFactScope } from '../api/facts.js';
3
- import { parseCardsReport, parseCrashReport, type PanelStateStore } from '../lib/panel-state.js';
2
+ import { unitFactScope } from './facts.js';
3
+ import { parseCardsReport, parseCrashReport, type PanelStateStore } from './panel-state.js';
4
4
  import { ACTIONS_PREFIX, API_PREFIX, DIY_DEBUG_PATH, DIY_RECORD_PATH, PANEL_STATE_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
5
5
  import {
6
6
  parseProvisionBody,
@@ -228,11 +228,11 @@ export function changesHandler(log: {
228
228
  }
229
229
 
230
230
  export function preflightHandler(preflight: {
231
- run(options?: { force?: boolean }): Promise<unknown>;
231
+ run(options?: { hard?: boolean }): Promise<unknown>;
232
232
  snapshot(): unknown;
233
233
  }): RouteHandler {
234
234
  return (search) => {
235
- void preflight.run({ force: flag(search, 'force') });
235
+ void preflight.run({ hard: flag(search, 'hard') });
236
236
  return { ok: true, ...(preflight.snapshot() as Record<string, unknown>) };
237
237
  };
238
238
  }
@@ -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;
@@ -43,22 +48,35 @@ export interface LongTaskDeps<S extends LongTaskBase> {
43
48
  readonly recentMs?: number | undefined;
44
49
  }
45
50
 
51
+ /**
52
+ * 一个单元的长任务注册表:`current(workspace)` 是面板那一行要的投影,`live()` 是外壳侧栏那颗
53
+ * loading 点要的(**不分工作区、不分目标**,把现在活着的每一趟交出来),`stop` 只把中断信号转给生产方。
54
+ */
46
55
  export interface LongTaskRegistry<S extends LongTaskBase> {
47
56
  start(options: LongTaskStart): S;
48
57
  snapshot(id: string): S | null;
49
58
  active(workspace: string, target: string): S | null;
50
59
  current(workspace: string): readonly Readonly<Record<string, unknown>>[];
60
+ live(): readonly S[];
61
+ stop(id: string, by?: string): boolean;
51
62
  onChange(listener: (change: LongTaskChange<S>) => void): () => void;
52
63
  }
53
64
 
54
65
  export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDeps<S>): LongTaskRegistry<S> {
55
66
  const runs = new Map<string, LongTaskBase>();
67
+ const controls = new Map<string, AbortController>();
56
68
  const now = deps.now ?? (() => new Date().toISOString());
57
69
  let counter = 0;
58
70
  const nextId = deps.nextId ?? (() => `run-${Date.now()}-${(counter += 1)}`);
59
71
  const recentMs = deps.recentMs ?? RECENT_MS;
60
72
  const listeners = new Set<(change: LongTaskChange<S>) => void>();
61
73
 
74
+ const live = (id: string): S | null => {
75
+ const current = runs.get(id);
76
+ if (current === undefined || !LIVE.has(current.status)) return null;
77
+ return current as S;
78
+ };
79
+
62
80
  const notify = (change: LongTaskChange<S>): void => {
63
81
  for (const listener of [...listeners]) {
64
82
  try {
@@ -97,7 +115,11 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
97
115
  (snapshot) => LIVE.has(snapshot.status) || Date.parse(snapshot.at) >= floor,
98
116
  )
99
117
  .reverse()
100
- .map((snapshot) => deps.view(snapshot as S));
118
+ .map((snapshot) => ({ ...deps.view(snapshot as S), id: snapshot.id }));
119
+ },
120
+
121
+ live() {
122
+ return [...runs.values()].filter((snapshot) => LIVE.has(snapshot.status)) as S[];
101
123
  },
102
124
 
103
125
  start(options) {
@@ -114,14 +136,15 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
114
136
  steps: [],
115
137
  startedAt,
116
138
  at: startedAt,
139
+ ...(options.session === undefined ? {} : { session: options.session }),
117
140
  });
118
141
  notify({ kind: 'start', snapshot: runs.get(id) as S });
119
142
 
120
143
  const onStep = (step: LongTaskStep): void => {
121
- const current = runs.get(id);
122
- if (current === undefined) return;
144
+ const current = live(id);
145
+ if (current === null) return;
123
146
  const steps = [...current.steps, `${step.kind === 'fail' ? '✗ ' : ''}${step.text}`].slice(-20);
124
- const extra = deps.step?.(step, current as S) ?? {};
147
+ const extra = deps.step?.(step, current) ?? {};
125
148
  if (step.loginUrl !== undefined) {
126
149
  patch(
127
150
  id,
@@ -143,17 +166,24 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
143
166
  );
144
167
  };
145
168
 
169
+ const control = new AbortController();
170
+ controls.set(id, control);
171
+
146
172
  void deps
147
- .run({ payload: options.payload, onStep })
173
+ .run({ payload: options.payload, onStep, signal: control.signal })
148
174
  .then((result) => {
149
- const current = runs.get(id);
175
+ const current = live(id);
176
+ if (current === null) return;
177
+ controls.delete(id);
150
178
  patch(
151
179
  id,
152
- { status: 'ok', ...(deps.outcome?.(result, current as S) ?? {}), loginUrl: undefined },
180
+ { status: 'ok', ...(deps.outcome?.(result, current) ?? {}), loginUrl: undefined },
153
181
  { kind: 'settle' },
154
182
  );
155
183
  })
156
184
  .catch((error: unknown) => {
185
+ if (live(id) === null) return;
186
+ controls.delete(id);
157
187
  patch(
158
188
  id,
159
189
  {
@@ -168,6 +198,19 @@ export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDep
168
198
  return runs.get(id) as S;
169
199
  },
170
200
 
201
+ stop(id, by) {
202
+ const current = live(id);
203
+ if (current === null) return false;
204
+ controls.get(id)?.abort();
205
+ controls.delete(id);
206
+ patch(
207
+ id,
208
+ { ...(deps.stopped?.(current) ?? {}), status: 'stopped', loginUrl: undefined },
209
+ { kind: 'settle', by },
210
+ );
211
+ return true;
212
+ },
213
+
171
214
  snapshot(id) {
172
215
  return (runs.get(id) as S | undefined) ?? null;
173
216
  },
@@ -212,6 +255,7 @@ export function createLongTaskHost(options: {
212
255
  readonly scope: string;
213
256
  readonly unit: string;
214
257
  readonly bump: (facts: readonly string[]) => void;
258
+ readonly notify?: ((change: LongTaskChange<LongTaskBase>) => void) | undefined;
215
259
  }): { readonly host: LongTaskHost; dispose(): void } {
216
260
  const offs: Array<() => void> = [];
217
261
  return {
@@ -223,6 +267,7 @@ export function createLongTaskHost(options: {
223
267
  registry.onChange((change) => {
224
268
  const named = [runsRouteOf(options.unit), ...(facts?.(change) ?? [])];
225
269
  options.bump([...new Set(named)]);
270
+ options.notify?.(change);
226
271
  }),
227
272
  );
228
273
  return registry;
@@ -1,6 +1,6 @@
1
1
  import { OPENCLI_PIN } from '../config/constants.js';
2
2
  import type { UnitLibNeed } from '../api/unit.js';
3
- import { quoteShellArg } from '../lib/text.js';
3
+ import { quoteShellArg } from './shell-text.js';
4
4
 
5
5
  import { dshHome } from './data-root.js';
6
6
 
@@ -1,4 +1,4 @@
1
- import { quoteShellArg } from '../lib/text.js';
1
+ import { quoteShellArg } from './shell-text.js';
2
2
 
3
3
  const OPENCLI_WINDOW_ENV = 'OPENCLI_WINDOW=background';
4
4
 
@@ -1,6 +1,7 @@
1
1
  import type { DshShell } from '../api/dsh.js';
2
2
  import { OPENCLI_MAX_CONCURRENT, OPENCLI_TIMEOUT_MS } from '../config/constants.js';
3
- import { parseJsonLoose, quoteShellArg, tailLines } from '../lib/text.js';
3
+ import { parseJsonLoose } from '../lib/text.js';
4
+ import { quoteShellArg, tailLines } from './shell-text.js';
4
5
  import { helpUrlOf, parseCliErrorEnvelope } from './cli-error.js';
5
6
  import { backgroundPrefix } from './opencli-window.js';
6
7
  import { runShell } from './shell.js';
@@ -1,5 +1,5 @@
1
1
  import type { PluginSource } from '../api/unit.js';
2
- import { quoteShellArg } from '../lib/text.js';
2
+ import { quoteShellArg } from './shell-text.js';
3
3
  import { npmCacheDir, toolchainOpencliDir, vendorPeerLink, vendorPrefix } from '../host/opencli-runtime.js';
4
4
 
5
5
  export type PluginSourceProblem =
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 把一个值裹成 shell 里的单引号字面量(值里的单引号一并转义),保证元字符按字面量传递。
3
+ * @param value - 任意值;不是字符串的先 `String()`。
4
+ * @returns 可安全拼进 `/bin/sh -c` 的一段文本 —— 含空格 / `$()` / `;` / 通配符的值都不会被当成语法。
5
+ */
6
+ export function quoteShellArg(value: unknown): string {
7
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
8
+ }
9
+
10
+ /**
11
+ * 取一段文本的末尾若干行(如命令输出),顺带去掉首尾空白。
12
+ * @param text - 原文;`null` / `undefined` 当空串。
13
+ * @param lines - 最多留几行,默认 6;0 给全篇、负数从开头砍掉那么多行(都是 `slice` 语义),调用方传正数。
14
+ * @returns 末尾若干行、去首尾空行后的文本;原文行数不够就全给,空输入给空串。
15
+ */
16
+ export function tailLines(text: string | null | undefined, lines = 6): string {
17
+ const all = String(text ?? '')
18
+ .trim()
19
+ .split('\n');
20
+ return all.slice(-lines).join('\n');
21
+ }
@@ -72,6 +72,7 @@ export interface ApprovalCall {
72
72
  readonly name?: string | undefined;
73
73
  readonly arguments?: unknown;
74
74
  readonly workspace?: string | undefined;
75
+ readonly session?: string | undefined;
75
76
  }
76
77
 
77
78
  export type ApprovalVerdict =
@@ -1,4 +1,4 @@
1
- import type { UnitEnvNeeds, UnitHostNeeds, UnitManifest } from './unit.js';
1
+ import type { UnitEnvNeeds, UnitHostNeeds, UnitManifest } from '../api/unit.js';
2
2
 
3
3
  /**
4
4
  * 声明协议当前版本;模块清单的 dsh.workbench.apiVersion 要写这个整数。
@@ -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/host.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  export * from './config/constants.js';
2
2
  export * from './api/context-ref.js';
3
3
  export * from './api/unit.js';
4
- export * from './api/unit-declaration.js';
5
4
  export * from './api/facts.js';
6
5
  export * from './api/long-task.js';
7
6
  export * from './lib/text.js';
8
7
  export * from './lib/ui.js';
9
- export * from './lib/panel-state.js';
10
8
  export * from './lib/board-fact.js';
11
- export * from './lib/diy-debug.js';
12
- export * from './lib/diy-record.js';
13
9
  export * from './api/blocker-table.js';
10
+ export * from './host/board-fact.js';
14
11
  export * from './host/browser-find.js';
15
12
  export * from './host/browser-read.js';
16
13
  export * from './host/cli-error.js';
14
+ export * from './host/context-ref.js';
17
15
  export * from './host/data-root.js';
16
+ export * from './host/diy-debug.js';
17
+ export * from './host/diy-record.js';
18
18
  export * from './host/facts.js';
19
19
  export * from './host/git.js';
20
20
  export * from './host/http.js';
@@ -22,12 +22,15 @@ export * from './host/long-task.js';
22
22
  export * from './host/opencli-runtime.js';
23
23
  export * from './host/opencli-window.js';
24
24
  export * from './host/opencli.js';
25
+ export * from './host/panel-state.js';
25
26
  export * from './host/plugin-ledger.js';
26
27
  export * from './host/plugin-source.js';
27
28
  export * from './host/provision.js';
29
+ export * from './host/shell-text.js';
28
30
  export * from './host/shell.js';
29
31
  export * from './host/tool-approval.js';
30
32
  export * from './host/tool-kit.js';
33
+ export * from './host/unit-declaration.js';
31
34
  export * from './host/unit-face.js';
32
35
  export * from './host/unit-libs.js';
33
36
  export * from './host/value-handoff.js';
package/src/i18n/en.ts CHANGED
@@ -40,6 +40,7 @@ export const en: Record<keyof WorkbenchMessages, string> = {
40
40
  'session.actions': 'Actions for {name}',
41
41
  'status.idle': 'Idle',
42
42
  'status.running': 'Running',
43
+ 'status.longTask': 'Long task running',
43
44
  'status.waitingApproval': 'Waiting for approval',
44
45
  'status.planReview': 'Plan awaiting review',
45
46
  'status.waitingAnswer': 'Waiting for your answer',
@@ -53,6 +54,8 @@ export const en: Record<keyof WorkbenchMessages, string> = {
53
54
  'project.attention.warning.other': '{n} sessions waiting for you',
54
55
  'project.attention.running.one': '1 session running',
55
56
  'project.attention.running.other': '{n} sessions running',
57
+ 'project.attention.longTask.one': '1 long task running',
58
+ 'project.attention.longTask.other': '{n} long tasks running',
56
59
  'project.attention.done.one': '1 session finished, unread',
57
60
  'project.attention.done.other': '{n} sessions finished, unread',
58
61
  'project.attention.state.warning': 'Approval',
@@ -441,6 +444,7 @@ export const en: Record<keyof WorkbenchMessages, string> = {
441
444
  'record.waiting.note': 'Press "Start recording" on that page, then "Stop recording" when done (it will not switch you back)',
442
445
  'record.recording': 'Recording',
443
446
  'record.recording.note': 'Press "Stop recording" when done; if the bar is gone press Halt so the agent grabs it first',
447
+ 'record.recording.shots': '; to keep a shot of this moment press "Screenshot" on the bar ({n} taken)',
444
448
  'record.finished': 'Recording finished',
445
449
  'record.finished.note': 'This run is done — the agent will grab it and wrap up on its own',
446
450
  'record.act.cancel': 'Cancel',
@@ -451,4 +455,7 @@ export const en: Record<keyof WorkbenchMessages, string> = {
451
455
  'approval.record.noUrl': 'no site given (this call gets refused)',
452
456
  'approval.record.oneAction': 'one action',
453
457
  'approval.record.noSwitch': 'it will not switch you back (the leftbar footer shows the state)',
458
+ 'approval.longTask.what': 'Stops',
459
+ 'approval.longTask.signal':
460
+ 'stopping only sends an interrupt signal — how the platform-side run finishes is up to the module',
454
461
  };