@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.
- package/dist/api/context-ref.d.ts +0 -12
- package/dist/api/context-ref.js +0 -40
- package/dist/api/facts.d.ts +0 -6
- package/dist/api/facts.js +0 -8
- package/dist/api/long-task.d.ts +33 -4
- package/dist/api/long-task.js +34 -2
- package/dist/components/ConfirmBar.css +26 -0
- package/dist/components/ConfirmBar.d.ts +36 -0
- package/dist/components/ConfirmBar.js +26 -0
- package/dist/components/Form.d.ts +7 -5
- package/dist/components/Form.js +5 -3
- package/dist/components/FormNodes.d.ts +17 -13
- package/dist/components/FormNodes.js +13 -5
- package/dist/components/FormRows.css +31 -0
- package/dist/components/FormTypes.d.ts +39 -14
- package/dist/components/LongTaskRow.css +52 -0
- package/dist/components/LongTaskRow.d.ts +18 -2
- package/dist/components/LongTaskRow.js +33 -5
- package/dist/components/MockStrip.css +11 -1
- package/dist/components/StateDot.d.ts +5 -0
- package/dist/components/StateDot.js +10 -0
- package/dist/config/constants.d.ts +2 -0
- package/dist/config/constants.js +2 -0
- package/dist/host/__tests__/fakes.d.ts +41 -0
- package/dist/host/__tests__/fakes.js +76 -0
- package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
- package/dist/host/__tests__/preflight-fixtures.js +47 -0
- package/dist/host/board-fact.d.ts +8 -0
- package/dist/host/board-fact.js +16 -0
- package/dist/host/browser-read.js +2 -1
- package/dist/host/context-inject.d.ts +24 -0
- package/dist/host/context-inject.js +74 -0
- package/dist/host/context-ref.d.ts +13 -0
- package/dist/host/context-ref.js +41 -0
- package/dist/host/diy-debug.d.ts +80 -0
- package/dist/host/diy-debug.js +88 -0
- package/dist/host/diy-record.d.ts +135 -0
- package/dist/host/diy-record.js +162 -0
- package/dist/host/events.d.ts +9 -0
- package/dist/host/events.js +70 -0
- package/dist/host/facts.d.ts +6 -0
- package/dist/host/facts.js +9 -0
- package/dist/host/git.js +1 -1
- package/dist/host/http.d.ts +2 -2
- package/dist/host/http.js +3 -3
- package/dist/host/long-task.d.ts +12 -0
- package/dist/host/long-task.js +34 -5
- package/dist/host/mount-units.d.ts +52 -0
- package/dist/host/mount-units.js +186 -0
- package/dist/host/opencli-runtime.js +1 -1
- package/dist/host/opencli-window.js +1 -1
- package/dist/host/opencli.js +2 -1
- package/dist/host/panel-state.d.ts +120 -0
- package/dist/host/panel-state.js +176 -0
- package/dist/host/plugin-source.js +1 -1
- package/dist/host/preflight.d.ts +58 -0
- package/dist/host/preflight.js +233 -0
- package/dist/host/probe-cache.d.ts +6 -0
- package/dist/host/probe-cache.js +19 -0
- package/dist/host/probe-libs.d.ts +45 -0
- package/dist/host/probe-libs.js +61 -0
- package/dist/host/probe-units.d.ts +17 -0
- package/dist/host/probe-units.js +102 -0
- package/dist/host/probe-verdict.d.ts +7 -0
- package/dist/host/probe-verdict.js +143 -0
- package/dist/host/shell-text.d.ts +13 -0
- package/dist/host/shell-text.js +20 -0
- package/dist/host/tool-approval.d.ts +1 -0
- package/dist/host/tool-scope.d.ts +32 -0
- package/dist/host/tool-scope.js +84 -0
- package/dist/host/unit-declaration.d.ts +65 -0
- package/dist/host/unit-declaration.js +213 -0
- package/dist/host/unit-face.d.ts +1 -0
- package/dist/host/unit-readiness.d.ts +7 -0
- package/dist/host/unit-readiness.js +5 -0
- package/dist/host/unit-registry.d.ts +9 -0
- package/dist/host/unit-registry.js +33 -0
- package/dist/host/version-range.d.ts +2 -0
- package/dist/host/version-range.js +73 -0
- package/dist/host.d.ts +7 -4
- package/dist/host.js +7 -4
- package/dist/i18n/en.js +6 -0
- package/dist/i18n/index.d.ts +7 -1
- package/dist/i18n/zh.d.ts +6 -0
- package/dist/i18n/zh.js +6 -0
- package/dist/index.d.ts +1 -4
- package/dist/index.js +1 -4
- package/dist/lib/board-fact.d.ts +0 -7
- package/dist/lib/board-fact.js +0 -15
- package/dist/lib/diy-record.d.ts +38 -1
- package/dist/lib/diy-record.js +66 -0
- package/dist/lib/text.d.ts +0 -13
- package/dist/lib/text.js +0 -20
- package/dist/testing/declaration.js +1 -1
- package/package.json +1 -1
- package/src/api/__tests__/facts.test.ts +0 -13
- package/src/api/__tests__/long-task.test.ts +41 -1
- package/src/api/context-ref.ts +0 -39
- package/src/api/facts.ts +0 -9
- package/src/api/long-task.ts +46 -4
- package/src/components/ConfirmBar.css +26 -0
- package/src/components/ConfirmBar.test.tsx +130 -0
- package/src/components/ConfirmBar.tsx +63 -0
- package/src/components/Form.test.tsx +15 -0
- package/src/components/Form.tsx +7 -5
- package/src/components/FormNodes.tsx +50 -26
- package/src/components/FormRows.css +31 -0
- package/src/components/FormRows.test.tsx +71 -1
- package/src/components/FormTypes.test.ts +31 -0
- package/src/components/FormTypes.ts +46 -15
- package/src/components/FormWiring.test.tsx +1 -0
- 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/config/constants.ts +3 -0
- package/src/host/__tests__/board-fact.test.ts +47 -0
- package/src/{lib → host/__tests__}/diy-debug.test.ts +1 -1
- package/src/{lib → host/__tests__}/diy-record.test.ts +137 -1
- package/src/host/__tests__/facts.test.ts +13 -0
- package/src/host/__tests__/long-task.test.ts +306 -0
- package/src/{lib → host/__tests__}/panel-state.test.ts +1 -1
- package/src/host/__tests__/preflight-route.test.ts +33 -0
- package/src/host/__tests__/shell-text.test.ts +44 -0
- package/src/host/board-fact.ts +14 -0
- package/src/host/browser-read.ts +2 -1
- package/src/host/context-ref.ts +40 -0
- package/src/{lib → host}/diy-record.ts +95 -1
- package/src/host/facts.ts +10 -0
- package/src/host/git.ts +1 -1
- package/src/host/http.ts +4 -4
- package/src/host/long-task.ts +52 -7
- package/src/host/opencli-runtime.ts +1 -1
- package/src/host/opencli-window.ts +1 -1
- package/src/host/opencli.ts +2 -1
- package/src/host/plugin-source.ts +1 -1
- package/src/host/shell-text.ts +21 -0
- package/src/host/tool-approval.ts +1 -0
- package/src/{api → host}/unit-declaration.ts +1 -1
- package/src/host/unit-face.ts +1 -0
- package/src/host.ts +7 -4
- package/src/i18n/en.ts +7 -0
- package/src/i18n/zh.ts +6 -0
- package/src/index.ts +1 -4
- package/src/lib/board-fact.test.ts +1 -33
- package/src/lib/board-fact.ts +0 -13
- package/src/lib/text.test.ts +1 -43
- package/src/lib/text.ts +0 -22
- package/src/testing/declaration.ts +1 -1
- package/dist/lib/lint-probe.d.ts +0 -1
- package/dist/lib/lint-probe.js +0 -6
- /package/src/{api → host}/__tests__/unit-declaration.test.ts +0 -0
- /package/src/{lib → host}/diy-debug.ts +0 -0
- /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
|
+
}
|
package/src/host/browser-read.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DshShell } from '../api/dsh.js';
|
|
2
|
-
import { parseJsonLoose
|
|
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
|
-
*
|
|
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
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 '
|
|
3
|
-
import { parseCardsReport, parseCrashReport, type PanelStateStore } from '
|
|
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?: {
|
|
231
|
+
run(options?: { hard?: boolean }): Promise<unknown>;
|
|
232
232
|
snapshot(): unknown;
|
|
233
233
|
}): RouteHandler {
|
|
234
234
|
return (search) => {
|
|
235
|
-
void preflight.run({
|
|
235
|
+
void preflight.run({ hard: flag(search, 'hard') });
|
|
236
236
|
return { ok: true, ...(preflight.snapshot() as Record<string, unknown>) };
|
|
237
237
|
};
|
|
238
238
|
}
|
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;
|
|
@@ -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 =
|
|
122
|
-
if (current ===
|
|
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
|
|
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 =
|
|
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
|
|
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;
|
package/src/host/opencli.ts
CHANGED
|
@@ -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
|
|
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 '
|
|
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
|
+
}
|
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/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
|
};
|