@dsh-workbench/module-tools 1.1.13 → 1.1.14-beta.0
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/README.md +36 -23
- package/bin/dsh-wb-module.mjs +18 -4
- package/package.json +4 -4
- package/src/arch.mjs +36 -25
- package/src/check.mjs +50 -3
- package/src/checks/arch.mjs +2 -2
- package/src/checks/contracts.mjs +88 -41
- package/src/checks/kit.mjs +3 -2
- package/src/checks/lint.mjs +2 -2
- package/src/checks/primitives.mjs +2 -2
- package/src/discover.mjs +186 -16
- package/src/init.mjs +7 -8
- package/src/lint.mjs +4 -16
- package/src/market-index.mjs +1 -1
- package/src/migrate.mjs +69 -0
- package/src/release-journal.mjs +3 -1
- package/src/release-target.mjs +1 -1
- package/src/release-util.mjs +7 -1
- package/template-diy/AGENTS.md.tpl +14 -22
- package/template-diy/README.md.tpl +61 -65
- package/template-diy/design.json.tpl +19 -21
- package/template-diy/package.json.tpl +6 -2
- package/template-diy/scripts/smoke.assert.mjs.tpl +28 -0
- package/template-diy/scripts/smoke.mjs.tpl +150 -30
- package/template-diy/src/__tests__/plugin.test.ts.tpl +453 -0
- package/template-diy/src/components/NotesSection.tsx.tpl +74 -0
- package/template-diy/src/components/OverviewBody.module.css.tpl +5 -0
- package/template-diy/src/components/OverviewBody.tsx.tpl +64 -0
- package/template-diy/src/components/OverviewCard.module.css.tpl +9 -1
- package/template-diy/src/components/OverviewCard.tsx.tpl +108 -67
- package/template-diy/src/components/PrefsSection.tsx.tpl +36 -0
- package/template-diy/src/components/RunSection.tsx.tpl +69 -0
- package/template-diy/src/components/{OverviewCard.test.ts.tpl → __tests__/OverviewCard.test.ts.tpl} +51 -1
- package/template-diy/src/declared-tools.ts.tpl +6 -0
- package/template-diy/src/host/notes.ts.tpl +92 -0
- package/template-diy/src/host/overview.ts.tpl +57 -0
- package/template-diy/src/host/sample.ts.tpl +151 -0
- package/template-diy/src/host.ts.tpl +88 -66
- package/template-diy/src/messages.ts.tpl +28 -0
- package/template-diy/src/model/__tests__/flow.test.ts.tpl +36 -0
- package/template-diy/src/model/__tests__/notes.test.ts.tpl +49 -0
- package/template-diy/src/model/{overview.test.ts.tpl → __tests__/overview.test.ts.tpl} +1 -1
- package/template-diy/src/model/__tests__/prefs.test.ts.tpl +59 -0
- package/template-diy/src/model/flow.ts.tpl +47 -0
- package/template-diy/src/model/notes.ts.tpl +52 -0
- package/template-diy/src/model/overview.ts.tpl +3 -1
- package/template-diy/src/model/prefs.ts.tpl +61 -0
- package/template/AGENTS.md.tpl +0 -33
- package/template/CHANGELOG.md.tpl +0 -7
- package/template/README.md.tpl +0 -30
- package/template/arch.config.mjs +0 -2
- package/template/cordis.patch.yml.tpl +0 -3
- package/template/eslint.config.mjs +0 -2
- package/template/package.json.tpl +0 -50
- package/template/scripts/smoke.mjs.tpl +0 -192
- package/template/src/client.tsx.tpl +0 -22
- package/template/src/components/HelloCard.tsx.tpl +0 -28
- package/template/src/host.ts.tpl +0 -21
- package/template/src/messages.ts.tpl +0 -12
- package/template/src/plugin.test.ts.tpl +0 -56
- package/template/tsconfig.json +0 -20
- package/template/types/globals.d.ts +0 -14
- package/template/vitest.config.ts +0 -14
- package/template-diy/src/plugin.test.ts.tpl +0 -159
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { DshToolDefinition, UnitMountDeps } from '@dsh-workbench/core/contract';
|
|
2
|
+
import {
|
|
3
|
+
TEXT_OUTPUT,
|
|
4
|
+
longTaskStepOf,
|
|
5
|
+
recordOf,
|
|
6
|
+
sessionIdOf,
|
|
7
|
+
sessionWorkspace,
|
|
8
|
+
textOf,
|
|
9
|
+
type LongTaskBase,
|
|
10
|
+
type LongTaskRegistry,
|
|
11
|
+
} from '@dsh-workbench/shared/host';
|
|
12
|
+
import { LIST_LIMIT } from '../model/overview';
|
|
13
|
+
import { DECLARED_TOOLS } from '../declared-tools';
|
|
14
|
+
import { FLOW_TARGET, RUN_TASK, headlineOf, sampleFlow } from '../model/flow';
|
|
15
|
+
import { readOverview } from './overview';
|
|
16
|
+
|
|
17
|
+
export interface StartResult {
|
|
18
|
+
readonly ok: boolean;
|
|
19
|
+
readonly runId?: string;
|
|
20
|
+
readonly status?: string;
|
|
21
|
+
readonly text?: string;
|
|
22
|
+
readonly code?: string;
|
|
23
|
+
readonly message?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export interface Sample {
|
|
29
|
+
readonly runs: LongTaskRegistry<LongTaskBase> | undefined;
|
|
30
|
+
readonly runTool: DshToolDefinition;
|
|
31
|
+
startFromPayload(payload: unknown): StartResult;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createSample(deps: UnitMountDeps): Sample {
|
|
35
|
+
const flowDefinition = sampleFlow();
|
|
36
|
+
const actions = deps.flow.createActions();
|
|
37
|
+
|
|
38
|
+
actions.register(
|
|
39
|
+
'read',
|
|
40
|
+
async (_input, context) => {
|
|
41
|
+
context.log('读目录');
|
|
42
|
+
const payload = readOverview(context.workspace, LIST_LIMIT);
|
|
43
|
+
return { files: payload.files, dirs: payload.dirs, bytes: payload.bytes };
|
|
44
|
+
},
|
|
45
|
+
{ title: '读目录' },
|
|
46
|
+
);
|
|
47
|
+
actions.register(
|
|
48
|
+
'summarize',
|
|
49
|
+
async (input) => {
|
|
50
|
+
const one = recordOf(input);
|
|
51
|
+
return {
|
|
52
|
+
text: `${String(one['files'])} 个文件 · ${String(one['dirs'])} 个目录 · ${String(one['bytes'])} 字节`,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
{ title: '汇总' },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const checked = deps.flow.check(flowDefinition, actions);
|
|
59
|
+
if (!checked.ok) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`流程定义有问题:${checked.problems.map((one) => `${one.path}: ${one.problem}`).join(';')}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const runs = deps.longTasks?.create<LongTaskBase>({
|
|
66
|
+
key: RUN_TASK,
|
|
67
|
+
run: async ({ payload, onStep, signal }) => {
|
|
68
|
+
const path = textOf(recordOf(payload)['path']);
|
|
69
|
+
const result = await deps.flow.run({
|
|
70
|
+
flow: flowDefinition,
|
|
71
|
+
input: { path },
|
|
72
|
+
actions,
|
|
73
|
+
signal,
|
|
74
|
+
runId: `${DECLARED_TOOLS.run.name}-${String(Date.now())}`,
|
|
75
|
+
workspace: path,
|
|
76
|
+
onStep: (event) => {
|
|
77
|
+
onStep(longTaskStepOf(event));
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
if (result.status === 'failed') {
|
|
81
|
+
throw result.error instanceof Error
|
|
82
|
+
? result.error
|
|
83
|
+
: new Error(`流程失败在 ${result.failedStep ?? '未知步骤'}`);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
},
|
|
87
|
+
step: (step) => ({ step: step.text }),
|
|
88
|
+
outcome: () => ({ step: '完成:读目录 → 汇总' }),
|
|
89
|
+
stopped: (current) => ({ step: current.step === '' ? '已停止' : `停在「${current.step}」` }),
|
|
90
|
+
view: (snapshot) => ({
|
|
91
|
+
status: snapshot.status,
|
|
92
|
+
step: snapshot.step,
|
|
93
|
+
steps: snapshot.steps,
|
|
94
|
+
...(snapshot.why === undefined ? {} : { why: snapshot.why }),
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const start = (workspace: string, session: string | null): StartResult => {
|
|
99
|
+
if (runs === undefined) return { ok: false, text: '外壳没有给长任务服务:这一版起不了流程' };
|
|
100
|
+
const run = runs.start({
|
|
101
|
+
workspace,
|
|
102
|
+
target: FLOW_TARGET,
|
|
103
|
+
payload: { path: workspace },
|
|
104
|
+
...(session === null ? {} : { session }),
|
|
105
|
+
});
|
|
106
|
+
return { ok: true, runId: run.id, status: run.status, text: headlineOf(run) };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const runTool: DshToolDefinition = {
|
|
110
|
+
name: DECLARED_TOOLS.run.name,
|
|
111
|
+
description:
|
|
112
|
+
'跑一遍样板流程(读目录 → 汇总);只读本机目录,不写平台,不用点头;再传 runId 可只看同一趟。',
|
|
113
|
+
parameters: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
runId: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description: '填上一次的 runId = 只看那条;不填 = 起一条新的。',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
additionalProperties: false,
|
|
122
|
+
},
|
|
123
|
+
output: TEXT_OUTPUT,
|
|
124
|
+
async execute(raw, exec) {
|
|
125
|
+
const args = recordOf(raw);
|
|
126
|
+
const runId = textOf(args['runId']);
|
|
127
|
+
if (runId !== '') {
|
|
128
|
+
const snapshot = runs?.snapshot(runId) ?? null;
|
|
129
|
+
if (snapshot === null) return { ok: false, text: `没有这条 run:${runId}` };
|
|
130
|
+
return { ok: true, runId, status: snapshot.status, text: headlineOf(snapshot) };
|
|
131
|
+
}
|
|
132
|
+
const workspace = sessionWorkspace(exec);
|
|
133
|
+
if (workspace === null || workspace === '') {
|
|
134
|
+
return { ok: false, text: '这个会话没有工作区:先打开一个项目再跑。' };
|
|
135
|
+
}
|
|
136
|
+
return start(workspace, sessionIdOf(exec));
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
runs,
|
|
142
|
+
runTool,
|
|
143
|
+
startFromPayload(payload: unknown): StartResult {
|
|
144
|
+
const path = textOf(recordOf(payload)['path']);
|
|
145
|
+
if (path === '') {
|
|
146
|
+
return { ok: false, code: 'no-path', message: '没告诉我要跑哪个目录', text: '先打开一个项目再跑。' };
|
|
147
|
+
}
|
|
148
|
+
return start(path, null);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -1,84 +1,106 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import type { DshToolRegistry, UnitModuleFace } from '@dsh-workbench/core/contract';
|
|
2
|
+
import {
|
|
3
|
+
defineUnitHostPlugin,
|
|
4
|
+
factOfRoute,
|
|
5
|
+
recordOf,
|
|
6
|
+
textOf,
|
|
7
|
+
writeApprovalOf,
|
|
8
|
+
} from '@dsh-workbench/shared/host';
|
|
9
|
+
import { DECLARED_TOOLS } from './declared-tools';
|
|
10
|
+
import { RUN_RESOURCE } from './model/flow';
|
|
11
|
+
import { NOTE_RESOURCE, NOTES_ROUTE, UNIT_ID } from './model/notes';
|
|
12
|
+
import { PREFS_RESOURCE, PREFS_ROUTE, applyPrefs, prefsOf } from './model/prefs';
|
|
13
|
+
import { createNotes } from './host/notes';
|
|
14
|
+
import { createSample } from './host/sample';
|
|
15
|
+
import { overviewAnswer } from './host/overview';
|
|
16
|
+
|
|
17
|
+
export { readOverview } from './host/overview';
|
|
5
18
|
|
|
6
|
-
const UNIT_ID = '__UNIT__';
|
|
7
19
|
const PLUGIN_ID = '__NAME__';
|
|
8
|
-
const DEFAULT_LIMIT = 5;
|
|
9
|
-
const MAX_LIMIT = 50;
|
|
10
20
|
|
|
11
21
|
export const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
|
|
12
22
|
|
|
13
23
|
export type RouteHandler = (search: URLSearchParams) => Promise<unknown> | unknown;
|
|
14
24
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const files: OverviewFile[] = [];
|
|
18
|
-
let dirs = 0;
|
|
19
|
-
let bytes = 0;
|
|
20
|
-
for (const entry of entries) {
|
|
21
|
-
if (entry.isDirectory()) {
|
|
22
|
-
dirs += 1;
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
if (!entry.isFile()) continue;
|
|
26
|
-
const info = statSync(join(path, entry.name));
|
|
27
|
-
bytes += info.size;
|
|
28
|
-
files.push({ name: entry.name, bytes: info.size, mtime: new Date(info.mtimeMs).toISOString() });
|
|
29
|
-
}
|
|
30
|
-
files.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name));
|
|
31
|
-
return { path, files: files.length, dirs, bytes, largest: files.slice(0, limit) };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function searchParam(search: URLSearchParams, name: string): string | undefined {
|
|
35
|
-
const raw = search.get(name);
|
|
36
|
-
return raw === null || raw === '' ? undefined : raw;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function limitOf(search: URLSearchParams): number {
|
|
40
|
-
const raw = searchParam(search, 'limit');
|
|
41
|
-
if (raw === undefined) return DEFAULT_LIMIT;
|
|
42
|
-
const parsed = Number(raw);
|
|
43
|
-
if (!Number.isFinite(parsed)) return DEFAULT_LIMIT;
|
|
44
|
-
return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(parsed)));
|
|
25
|
+
function pathOf(search: URLSearchParams): string {
|
|
26
|
+
return search.get('path') ?? '';
|
|
45
27
|
}
|
|
46
28
|
|
|
47
29
|
const face: UnitModuleFace = {
|
|
48
30
|
unit: UNIT_ID,
|
|
49
|
-
mount: () =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
31
|
+
mount: (deps) => {
|
|
32
|
+
const sample = createSample(deps);
|
|
33
|
+
const notes = createNotes(deps);
|
|
34
|
+
const storeAt = (workspace: string) => deps.store.for(UNIT_ID, workspace === '' ? null : workspace);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
routes: {
|
|
38
|
+
[OVERVIEW_ROUTE]: (search: URLSearchParams) => overviewAnswer(search),
|
|
39
|
+
[NOTES_ROUTE]: (search: URLSearchParams) => {
|
|
40
|
+
const path = pathOf(search);
|
|
41
|
+
if (path === '') {
|
|
42
|
+
return { ok: false, error: { code: 'no-path', message: '没告诉我要看哪个目录' } };
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, notes: notes.list(path) };
|
|
45
|
+
},
|
|
46
|
+
[PREFS_ROUTE]: (search: URLSearchParams) => ({
|
|
47
|
+
ok: true,
|
|
48
|
+
prefs: prefsOf(storeAt(search.get('workspace') ?? pathOf(search))),
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
prefs: {
|
|
52
|
+
resource: PREFS_RESOURCE,
|
|
53
|
+
handle: (payload: unknown) => {
|
|
54
|
+
const body = recordOf(payload);
|
|
55
|
+
const workspace = textOf(body['workspace']) || textOf(body['path']);
|
|
56
|
+
if (workspace === '') {
|
|
57
|
+
return { ok: false, error: { code: 'no-workspace', message: '没告诉我要存到哪个工作区' } };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, ...applyPrefs(storeAt(workspace), payload) };
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
registerTools: () => {
|
|
63
|
+
const tools = deps.ctx.get<DshToolRegistry>('tools');
|
|
64
|
+
if (tools === undefined) {
|
|
65
|
+
deps.logger?.warn?.(`[${UNIT_ID}] 外壳没有提供 tools 服务:这两把工具注册不进去`);
|
|
66
|
+
return [];
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
return [tools.register(sample.runTool), tools.register(notes.tool)];
|
|
69
|
+
},
|
|
70
|
+
actions: [
|
|
71
|
+
{
|
|
72
|
+
resource: RUN_RESOURCE,
|
|
73
|
+
handle: (payload: unknown) => sample.startFromPayload(payload),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
resource: NOTE_RESOURCE,
|
|
77
|
+
handle: (payload: unknown) => notes.handle(payload),
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
factsOf: (call: { readonly source: string }) => {
|
|
81
|
+
if (call.source === DECLARED_TOOLS.addNote.name || call.source === NOTE_RESOURCE) {
|
|
82
|
+
return [factOfRoute(NOTES_ROUTE)];
|
|
66
83
|
}
|
|
84
|
+
if (call.source === PREFS_RESOURCE) return [factOfRoute(PREFS_ROUTE)];
|
|
85
|
+
if (call.source === DECLARED_TOOLS.run.name) return [];
|
|
86
|
+
return undefined;
|
|
67
87
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
...writeApprovalOf({
|
|
89
|
+
tools: [DECLARED_TOOLS.addNote.name],
|
|
90
|
+
reason: '会往这个模块的笔记里加一条',
|
|
91
|
+
question: { zh: '把这条笔记写进去吗?', en: 'Write this note?' },
|
|
92
|
+
fallback: { zh: '只写本模块的笔记文件,不动平台', en: 'Writes only this module’s notes file' },
|
|
93
|
+
detail: { zh: '新增 1 条笔记', en: 'Add 1 note' },
|
|
94
|
+
}),
|
|
95
|
+
...(sample.runs === undefined ? {} : { longTasks: sample.runs }),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
71
98
|
};
|
|
72
99
|
|
|
73
|
-
|
|
100
|
+
const plugin = defineUnitHostPlugin({ name: PLUGIN_ID, unit: UNIT_ID, face });
|
|
74
101
|
|
|
75
|
-
export const
|
|
102
|
+
export const name = plugin.name;
|
|
76
103
|
|
|
77
|
-
export
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
ctx.logger?.warn?.(`[${UNIT_ID}] 外壳没有提供 workbenchUnits 服务:这个模块装不进来`);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
ctx.effect(() => units.register(face), `${PLUGIN_ID}: 注册 ${UNIT_ID}`);
|
|
84
|
-
}
|
|
104
|
+
export const inject = plugin.inject;
|
|
105
|
+
|
|
106
|
+
export const apply = plugin.apply;
|
|
@@ -10,6 +10,20 @@ export const messages = {
|
|
|
10
10
|
'__UNIT__.largest': '最大的几个文件',
|
|
11
11
|
'__UNIT__.fileUnit': ' 个文件',
|
|
12
12
|
'__UNIT__.dirUnit': ' 个目录',
|
|
13
|
+
'__UNIT__.network': '网络不通,没发出去',
|
|
14
|
+
'__UNIT__.runSample': '跑一遍样板流程',
|
|
15
|
+
'__UNIT__.runHint': '点一下上面那颗钮:读目录 → 汇总,会在这张卡里出现一条长任务行',
|
|
16
|
+
'__UNIT__.stopLabel': '停止任务',
|
|
17
|
+
'__UNIT__.stopAsk': '确认停止',
|
|
18
|
+
'__UNIT__.stepsTitle': '步骤',
|
|
19
|
+
'__UNIT__.notesLabel': '模块笔记',
|
|
20
|
+
'__UNIT__.notesHint': '写一句就存进这个项目自己的模块文件夹;agent 的写工具走同一份数据,要你点头',
|
|
21
|
+
'__UNIT__.notePlaceholder': '记一句(200 字以内)',
|
|
22
|
+
'__UNIT__.noteSave': '存一条',
|
|
23
|
+
'__UNIT__.notesEmpty': '还没有笔记:写一句存进去,刷新后它还在',
|
|
24
|
+
'__UNIT__.prefsLabel': '最大的几个文件',
|
|
25
|
+
'__UNIT__.prefsHint': '这个选择按项目存(刷新还在);只影响卡片显示,不改读到的数据',
|
|
26
|
+
'__UNIT__.prefsUnit': ' 个',
|
|
13
27
|
},
|
|
14
28
|
en: {
|
|
15
29
|
'__UNIT__.cardTitle': 'Directory overview',
|
|
@@ -22,5 +36,19 @@ export const messages = {
|
|
|
22
36
|
'__UNIT__.largest': 'Largest files',
|
|
23
37
|
'__UNIT__.fileUnit': ' files',
|
|
24
38
|
'__UNIT__.dirUnit': ' dirs',
|
|
39
|
+
'__UNIT__.network': 'Network is down: nothing was sent',
|
|
40
|
+
'__UNIT__.runSample': 'Run the sample flow',
|
|
41
|
+
'__UNIT__.runHint': 'Hit the button above: read directory → summarize; a long-task row appears in this card',
|
|
42
|
+
'__UNIT__.stopLabel': 'Stop task',
|
|
43
|
+
'__UNIT__.stopAsk': 'Confirm stop',
|
|
44
|
+
'__UNIT__.stepsTitle': 'Steps',
|
|
45
|
+
'__UNIT__.notesLabel': 'Module notes',
|
|
46
|
+
'__UNIT__.notesHint': 'Saved to this project’s own module folder; the agent’s write tool uses the same data and needs your nod',
|
|
47
|
+
'__UNIT__.notePlaceholder': 'One line (up to 200 chars)',
|
|
48
|
+
'__UNIT__.noteSave': 'Save note',
|
|
49
|
+
'__UNIT__.notesEmpty': 'No notes yet: write one and it survives a refresh',
|
|
50
|
+
'__UNIT__.prefsLabel': 'Largest files shown',
|
|
51
|
+
'__UNIT__.prefsHint': 'Stored per project (survives refresh); affects display only, not the data',
|
|
52
|
+
'__UNIT__.prefsUnit': '',
|
|
25
53
|
},
|
|
26
54
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { headlineOf, runIsLive, sampleFlow, type SampleRun } from '../flow';
|
|
4
|
+
|
|
5
|
+
const run = (over: Partial<SampleRun> = {}): SampleRun => ({
|
|
6
|
+
id: 'run-1',
|
|
7
|
+
status: 'running',
|
|
8
|
+
step: '',
|
|
9
|
+
steps: [],
|
|
10
|
+
...over,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('样板流程的模型', () => {
|
|
14
|
+
it('两步:先读目录、再汇总,步骤 id 就是动作名', () => {
|
|
15
|
+
const flow = sampleFlow();
|
|
16
|
+
expect(flow.steps.map((step) => ('action' in step ? step.action : 'sub'))).toEqual(['read', 'summarize']);
|
|
17
|
+
expect(flow.target).toEqual({ $ref: 'in.path' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('只有 running / needs-login 算还在跑', () => {
|
|
21
|
+
expect(runIsLive(run())).toBe(true);
|
|
22
|
+
expect(runIsLive(run({ status: 'needs-login' }))).toBe(true);
|
|
23
|
+
expect(runIsLive(run({ status: 'ok' }))).toBe(false);
|
|
24
|
+
expect(runIsLive(run({ status: 'failed' }))).toBe(false);
|
|
25
|
+
expect(runIsLive(run({ status: 'stopped' }))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('标题按五态与当前步骤说人话', () => {
|
|
29
|
+
expect(headlineOf(run())).toBe('样板流程正在跑');
|
|
30
|
+
expect(headlineOf(run({ step: '读目录' }))).toBe('样板流程:读目录');
|
|
31
|
+
expect(headlineOf(run({ status: 'ok' }))).toBe('样板流程跑完了');
|
|
32
|
+
expect(headlineOf(run({ status: 'failed' }))).toBe('样板流程失败');
|
|
33
|
+
expect(headlineOf(run({ status: 'stopped' }))).toBe('样板流程已停止');
|
|
34
|
+
expect(headlineOf(run({ status: 'needs-login' }))).toBe('样板流程要登录');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { addNoteOf, noteProblemOf, notesOf } from '../notes';
|
|
3
|
+
|
|
4
|
+
describe('notesOf', () => {
|
|
5
|
+
it('没存过就是空数组', () => {
|
|
6
|
+
expect(notesOf(null)).toEqual([]);
|
|
7
|
+
expect(notesOf({ nope: true })).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('只认完整的条目,坏的丢掉;最多留 20 条', () => {
|
|
11
|
+
const raw = [
|
|
12
|
+
{ id: 'a', text: '一', at: '2026-01-01T00:00:00.000Z' },
|
|
13
|
+
{ id: 'b', text: '缺 at' },
|
|
14
|
+
...Array.from({ length: 30 }, (_unused, index) => ({
|
|
15
|
+
id: `n${String(index)}`,
|
|
16
|
+
text: `第 ${String(index)} 条`,
|
|
17
|
+
at: '2026-01-01T00:00:00.000Z',
|
|
18
|
+
})),
|
|
19
|
+
];
|
|
20
|
+
const notes = notesOf(raw);
|
|
21
|
+
expect(notes).toHaveLength(20);
|
|
22
|
+
expect(notes[0]?.id).toBe('a');
|
|
23
|
+
expect(notes[19]?.id).toBe('n18');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('noteProblemOf', () => {
|
|
28
|
+
it('空 / 只有空白 / 超长都点名叫原因', () => {
|
|
29
|
+
expect(noteProblemOf(' ')).toContain('写点什么');
|
|
30
|
+
expect(noteProblemOf(undefined)).toContain('一句话');
|
|
31
|
+
expect(noteProblemOf('x'.repeat(201))).toContain('200');
|
|
32
|
+
expect(noteProblemOf('x'.repeat(200))).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('addNoteOf', () => {
|
|
37
|
+
it('新条目排最前,超过 20 条丢最旧', () => {
|
|
38
|
+
const old = Array.from({ length: 20 }, (_unused, index) => ({
|
|
39
|
+
id: `old-${String(index)}`,
|
|
40
|
+
text: '旧',
|
|
41
|
+
at: '2026-01-01T00:00:00.000Z',
|
|
42
|
+
}));
|
|
43
|
+
const { note, notes } = addNoteOf(old, ' 新的一条 ', '2026-02-02T00:00:00.000Z', 'new');
|
|
44
|
+
expect(note.text).toBe('新的一条');
|
|
45
|
+
expect(notes[0]).toEqual(note);
|
|
46
|
+
expect(notes).toHaveLength(20);
|
|
47
|
+
expect(notes.some((one) => one.id === 'old-19')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PREFS,
|
|
4
|
+
LIMIT_MAX,
|
|
5
|
+
LIMIT_MIN,
|
|
6
|
+
PREF_FILE,
|
|
7
|
+
applyPrefs,
|
|
8
|
+
prefsOf,
|
|
9
|
+
type PrefStore,
|
|
10
|
+
} from '../prefs';
|
|
11
|
+
|
|
12
|
+
function memoryStore(initial: Record<string, unknown> = {}): PrefStore & { readonly files: Map<string, unknown> } {
|
|
13
|
+
const files = new Map<string, unknown>(Object.entries(initial));
|
|
14
|
+
return {
|
|
15
|
+
files,
|
|
16
|
+
read: (name) => files.get(name) ?? null,
|
|
17
|
+
write: (name, value) => {
|
|
18
|
+
files.set(name, value);
|
|
19
|
+
},
|
|
20
|
+
remove: (name) => {
|
|
21
|
+
files.delete(name);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('prefsOf', () => {
|
|
27
|
+
it('没存过 / 存坏了都回默认', () => {
|
|
28
|
+
expect(prefsOf(memoryStore())).toEqual(DEFAULT_PREFS);
|
|
29
|
+
expect(prefsOf(memoryStore({ [PREF_FILE]: { limit: 999 } }))).toEqual(DEFAULT_PREFS);
|
|
30
|
+
expect(prefsOf(memoryStore({ [PREF_FILE]: { limit: 'abc' } }))).toEqual(DEFAULT_PREFS);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('存过的合法值读得回来', () => {
|
|
34
|
+
expect(prefsOf(memoryStore({ [PREF_FILE]: { limit: 10 } })).limit).toBe(10);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('applyPrefs', () => {
|
|
39
|
+
it('写进去并回整份新偏好', () => {
|
|
40
|
+
const store = memoryStore();
|
|
41
|
+
expect(applyPrefs(store, { limit: 20 })).toEqual({ prefs: { limit: 20 } });
|
|
42
|
+
expect(store.files.get(PREF_FILE)).toEqual({ limit: 20 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('越界 / 不是整数 / 没有 limit 都会抛,且不写', () => {
|
|
46
|
+
const store = memoryStore();
|
|
47
|
+
expect(() => applyPrefs(store, { limit: LIMIT_MAX + 1 })).toThrow('只认');
|
|
48
|
+
expect(() => applyPrefs(store, { limit: LIMIT_MIN - 1 })).toThrow('只认');
|
|
49
|
+
expect(() => applyPrefs(store, { limit: 1.5 })).toThrow('只认');
|
|
50
|
+
expect(() => applyPrefs(store, {})).toThrow('没有 limit');
|
|
51
|
+
expect(store.files.has(PREF_FILE)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reset 删文件回默认', () => {
|
|
55
|
+
const store = memoryStore({ [PREF_FILE]: { limit: 10 } });
|
|
56
|
+
expect(applyPrefs(store, { reset: true })).toEqual({ prefs: DEFAULT_PREFS });
|
|
57
|
+
expect(store.files.has(PREF_FILE)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { FlowDefinition } from '@dsh-workbench/core/contract';
|
|
2
|
+
import type { LongTaskStatus } from '@dsh-workbench/shared';
|
|
3
|
+
|
|
4
|
+
export const FLOW_TARGET = 'sample';
|
|
5
|
+
export const RUN_TASK = '__UNIT__-sample';
|
|
6
|
+
export const RUN_RESOURCE = '__UNIT__-run';
|
|
7
|
+
|
|
8
|
+
export interface SampleRun {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly status: LongTaskStatus;
|
|
11
|
+
readonly step: string;
|
|
12
|
+
readonly steps: readonly string[];
|
|
13
|
+
readonly why?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sampleFlow(): FlowDefinition {
|
|
17
|
+
return {
|
|
18
|
+
id: 'sample',
|
|
19
|
+
title: '读目录 → 汇总',
|
|
20
|
+
target: { $ref: 'in.path' },
|
|
21
|
+
steps: [
|
|
22
|
+
{ id: 'read', title: '读目录', action: 'read' },
|
|
23
|
+
{
|
|
24
|
+
id: 'summarize',
|
|
25
|
+
title: '汇总',
|
|
26
|
+
action: 'summarize',
|
|
27
|
+
with: {
|
|
28
|
+
files: { $ref: 'steps.read.files' },
|
|
29
|
+
dirs: { $ref: 'steps.read.dirs' },
|
|
30
|
+
bytes: { $ref: 'steps.read.bytes' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function headlineOf(run: SampleRun): string {
|
|
38
|
+
if (run.status === 'ok') return '样板流程跑完了';
|
|
39
|
+
if (run.status === 'failed') return '样板流程失败';
|
|
40
|
+
if (run.status === 'stopped') return '样板流程已停止';
|
|
41
|
+
if (run.status === 'needs-login') return '样板流程要登录';
|
|
42
|
+
return run.step === '' ? '样板流程正在跑' : `样板流程:${run.step}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function runIsLive(run: SampleRun): boolean {
|
|
46
|
+
return run.status === 'running' || run.status === 'needs-login';
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const UNIT_ID = '__UNIT__';
|
|
2
|
+
export const NOTE_FILE = 'notes.json';
|
|
3
|
+
export const NOTES_ROUTE = `${UNIT_ID}/notes`;
|
|
4
|
+
export const NOTE_RESOURCE = `${UNIT_ID}-add-note`;
|
|
5
|
+
export const NOTE_TEXT_MAX = 200;
|
|
6
|
+
export const NOTE_LIMIT = 20;
|
|
7
|
+
|
|
8
|
+
export interface Note {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly text: string;
|
|
11
|
+
readonly at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function recordOf(value: unknown): Record<string, unknown> {
|
|
15
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function notesOf(raw: unknown): Note[] {
|
|
19
|
+
if (!Array.isArray(raw)) return [];
|
|
20
|
+
const notes: Note[] = [];
|
|
21
|
+
for (const one of raw) {
|
|
22
|
+
const value = recordOf(one);
|
|
23
|
+
if (typeof value['id'] !== 'string' || typeof value['text'] !== 'string') continue;
|
|
24
|
+
if (typeof value['at'] !== 'string') continue;
|
|
25
|
+
notes.push({ id: value['id'], text: value['text'], at: value['at'] });
|
|
26
|
+
if (notes.length >= NOTE_LIMIT) break;
|
|
27
|
+
}
|
|
28
|
+
return notes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function noteProblemOf(raw: unknown): string | undefined {
|
|
32
|
+
if (typeof raw !== 'string') return '笔记要是一句话';
|
|
33
|
+
const text = raw.trim();
|
|
34
|
+
if (text === '') return '写点什么再存';
|
|
35
|
+
const length = [...text].length;
|
|
36
|
+
if (length > NOTE_TEXT_MAX) return `最多 ${String(NOTE_TEXT_MAX)} 字,这条有 ${String(length)} 字`;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function addNoteOf(
|
|
41
|
+
notes: readonly Note[],
|
|
42
|
+
text: string,
|
|
43
|
+
at: string,
|
|
44
|
+
id: string,
|
|
45
|
+
): { readonly note: Note; readonly notes: Note[] } {
|
|
46
|
+
const note: Note = { id, text: text.trim(), at };
|
|
47
|
+
return { note, notes: [note, ...notes].slice(0, NOTE_LIMIT) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function noteIdOf(at: string, seed: string | number): string {
|
|
51
|
+
return `${at}-${String(seed)}`;
|
|
52
|
+
}
|