@dsh-workbench/shared 0.1.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/dist/api/blocker-table.d.ts +9 -0
- package/dist/api/blocker-table.js +130 -0
- package/dist/api/blocker.d.ts +30 -0
- package/dist/api/blocker.js +1 -0
- package/dist/api/context-ref.d.ts +10 -0
- package/dist/api/context-ref.js +40 -0
- package/dist/api/dsh-client.d.ts +170 -0
- package/dist/api/dsh-client.js +1 -0
- package/dist/api/dsh.d.ts +153 -0
- package/dist/api/dsh.js +1 -0
- package/dist/api/facts.d.ts +27 -0
- package/dist/api/facts.js +146 -0
- package/dist/api/host-write.d.ts +12 -0
- package/dist/api/host-write.js +17 -0
- package/dist/api/long-task.d.ts +18 -0
- package/dist/api/long-task.js +22 -0
- package/dist/api/unit-board.d.ts +14 -0
- package/dist/api/unit-board.js +29 -0
- package/dist/api/unit.d.ts +54 -0
- package/dist/api/unit.js +1 -0
- package/dist/api/workbench-client.d.ts +19 -0
- package/dist/api/workbench-client.js +33 -0
- package/dist/api/workbench-contract.d.ts +32 -0
- package/dist/api/workbench-contract.js +1 -0
- package/dist/components/ApprovalDetail.d.ts +27 -0
- package/dist/components/ApprovalDetail.js +33 -0
- package/dist/components/CardHead.d.ts +16 -0
- package/dist/components/CardHead.js +14 -0
- package/dist/components/ChoiceGroup.d.ts +14 -0
- package/dist/components/ChoiceGroup.js +7 -0
- package/dist/components/LongTaskRow.d.ts +16 -0
- package/dist/components/LongTaskRow.js +28 -0
- package/dist/components/MockStrip.d.ts +14 -0
- package/dist/components/MockStrip.js +7 -0
- package/dist/components/ResourceStates.d.ts +18 -0
- package/dist/components/ResourceStates.js +17 -0
- package/dist/components/SaveBar.d.ts +12 -0
- package/dist/components/SaveBar.js +6 -0
- package/dist/components/StateDot.d.ts +5 -0
- package/dist/components/StateDot.js +10 -0
- package/dist/components/board-module.d.ts +67 -0
- package/dist/components/board-module.js +1 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/icons.js +81 -0
- package/dist/config/constants.d.ts +27 -0
- package/dist/config/constants.js +27 -0
- package/dist/config/storage.d.ts +4 -0
- package/dist/config/storage.js +27 -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/cli-error.d.ts +8 -0
- package/dist/host/cli-error.js +70 -0
- package/dist/host/context-inject.d.ts +24 -0
- package/dist/host/context-inject.js +74 -0
- package/dist/host/data-root.d.ts +3 -0
- package/dist/host/data-root.js +12 -0
- package/dist/host/events.d.ts +9 -0
- package/dist/host/events.js +70 -0
- package/dist/host/facts.d.ts +24 -0
- package/dist/host/facts.js +44 -0
- package/dist/host/git.d.ts +55 -0
- package/dist/host/git.js +64 -0
- package/dist/host/http.d.ts +36 -0
- package/dist/host/http.js +242 -0
- package/dist/host/long-task.d.ts +58 -0
- package/dist/host/long-task.js +148 -0
- package/dist/host/mount-units.d.ts +52 -0
- package/dist/host/mount-units.js +186 -0
- package/dist/host/opencli-runtime.d.ts +22 -0
- package/dist/host/opencli-runtime.js +46 -0
- package/dist/host/opencli.d.ts +37 -0
- package/dist/host/opencli.js +152 -0
- package/dist/host/plugin-source.d.ts +31 -0
- package/dist/host/plugin-source.js +81 -0
- 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/provision.d.ts +14 -0
- package/dist/host/provision.js +12 -0
- package/dist/host/shell.d.ts +14 -0
- package/dist/host/shell.js +23 -0
- package/dist/host/tool-approval.d.ts +29 -0
- package/dist/host/tool-approval.js +190 -0
- package/dist/host/tool-kit.d.ts +19 -0
- package/dist/host/tool-kit.js +37 -0
- package/dist/host/tool-scope.d.ts +32 -0
- package/dist/host/tool-scope.js +84 -0
- package/dist/host/unit-face.d.ts +66 -0
- package/dist/host/unit-face.js +1 -0
- package/dist/host/unit-libs.d.ts +37 -0
- package/dist/host/unit-libs.js +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/value-handoff.d.ts +16 -0
- package/dist/host/value-handoff.js +21 -0
- package/dist/host/version-range.d.ts +2 -0
- package/dist/host/version-range.js +73 -0
- package/dist/host/workspace-store.d.ts +19 -0
- package/dist/host/workspace-store.js +133 -0
- package/dist/host/write-result.d.ts +1 -0
- package/dist/host/write-result.js +18 -0
- package/dist/host.d.ts +19 -0
- package/dist/host.js +19 -0
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/en.js +162 -0
- package/dist/i18n/index.d.ts +172 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/merge.d.ts +6 -0
- package/dist/i18n/merge.js +40 -0
- package/dist/i18n/zh.d.ts +163 -0
- package/dist/i18n/zh.js +162 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/lib/blocker-action.d.ts +13 -0
- package/dist/lib/blocker-action.js +12 -0
- package/dist/lib/busy.d.ts +5 -0
- package/dist/lib/busy.js +7 -0
- package/dist/lib/react.d.ts +10 -0
- package/dist/lib/react.js +9 -0
- package/dist/lib/text.d.ts +4 -0
- package/dist/lib/text.js +48 -0
- package/dist/lib/ui.d.ts +11 -0
- package/dist/lib/ui.js +37 -0
- package/package.json +44 -0
- package/src/api/__tests__/blocker-table.test.ts +253 -0
- package/src/api/__tests__/facts.test.ts +274 -0
- package/src/api/__tests__/host-write.test.ts +55 -0
- package/src/api/__tests__/long-task.test.ts +52 -0
- package/src/api/__tests__/unit-board.test.ts +76 -0
- package/src/api/blocker-table.ts +155 -0
- package/src/api/blocker.ts +25 -0
- package/src/api/context-ref.ts +47 -0
- package/src/api/dsh-client.ts +176 -0
- package/src/api/dsh.ts +156 -0
- package/src/api/facts.ts +166 -0
- package/src/api/host-write.ts +27 -0
- package/src/api/long-task.ts +38 -0
- package/src/api/unit-board.ts +44 -0
- package/src/api/unit.ts +59 -0
- package/src/api/workbench-client.test.ts +92 -0
- package/src/api/workbench-client.ts +59 -0
- package/src/api/workbench-contract.ts +40 -0
- package/src/components/ApprovalDetail.tsx +57 -0
- package/src/components/CardHead.tsx +74 -0
- package/src/components/ChoiceGroup.tsx +38 -0
- package/src/components/LongTaskRow.tsx +65 -0
- package/src/components/MockStrip.tsx +40 -0
- package/src/components/ResourceStates.tsx +44 -0
- package/src/components/SaveBar.tsx +31 -0
- package/src/components/StateDot.tsx +31 -0
- package/src/components/board-module.ts +73 -0
- package/src/components/icons.tsx +232 -0
- package/src/config/constants.test.ts +41 -0
- package/src/config/constants.ts +52 -0
- package/src/config/storage.test.ts +82 -0
- package/src/config/storage.ts +25 -0
- package/src/host/__tests__/fakes.ts +116 -0
- package/src/host/__tests__/opencli-runtime.test.ts +46 -0
- package/src/host/cli-error.ts +77 -0
- package/src/host/data-root.ts +13 -0
- package/src/host/facts.ts +69 -0
- package/src/host/git.ts +99 -0
- package/src/host/http.ts +311 -0
- package/src/host/long-task.ts +235 -0
- package/src/host/opencli-runtime.ts +66 -0
- package/src/host/opencli.ts +210 -0
- package/src/host/plugin-source.ts +145 -0
- package/src/host/provision.ts +34 -0
- package/src/host/shell.ts +42 -0
- package/src/host/tool-approval.ts +243 -0
- package/src/host/tool-kit.ts +56 -0
- package/src/host/unit-face.ts +65 -0
- package/src/host/unit-libs.ts +41 -0
- package/src/host/value-handoff.ts +38 -0
- package/src/host/workspace-store.ts +161 -0
- package/src/host/write-result.ts +16 -0
- package/src/host.ts +19 -0
- package/src/i18n/en.ts +170 -0
- package/src/i18n/index.test.ts +15 -0
- package/src/i18n/index.ts +12 -0
- package/src/i18n/merge.test.ts +60 -0
- package/src/i18n/merge.ts +54 -0
- package/src/i18n/zh.test.ts +40 -0
- package/src/i18n/zh.ts +170 -0
- package/src/index.ts +33 -0
- package/src/lib/blocker-action.test.ts +40 -0
- package/src/lib/blocker-action.ts +28 -0
- package/src/lib/busy.test.ts +27 -0
- package/src/lib/busy.ts +11 -0
- package/src/lib/react.test.ts +26 -0
- package/src/lib/react.ts +16 -0
- package/src/lib/text.test.ts +92 -0
- package/src/lib/text.ts +46 -0
- package/src/lib/ui.test.ts +93 -0
- package/src/lib/ui.ts +38 -0
package/src/host/http.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { DshHostContext, DshRequest, DshResponse } from '../api/dsh.js';
|
|
2
|
+
import { unitFactScope } from '../api/facts.js';
|
|
3
|
+
import { ACTIONS_PREFIX, API_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
|
|
4
|
+
import {
|
|
5
|
+
isDependencyAction,
|
|
6
|
+
PROVISION_ACTIONS,
|
|
7
|
+
type ProvisionAction,
|
|
8
|
+
type ProvisionHandler,
|
|
9
|
+
type ProvisionRequest,
|
|
10
|
+
} from './provision';
|
|
11
|
+
import { WorkbenchDataError } from '../host/opencli.js';
|
|
12
|
+
import { writeResultForClient } from './write-result';
|
|
13
|
+
|
|
14
|
+
export type RouteHandler = (search: URLSearchParams) => Promise<unknown> | unknown;
|
|
15
|
+
|
|
16
|
+
export type RouteTable = Record<string, RouteHandler>;
|
|
17
|
+
|
|
18
|
+
const MAX_PREFS_BODY_BYTES = 8 * 1024;
|
|
19
|
+
|
|
20
|
+
const PROVISION_FACTS = ['capability', 'panels'] as const;
|
|
21
|
+
|
|
22
|
+
function provisionFacts(provision: ProvisionHandler, request: ProvisionRequest): readonly string[] {
|
|
23
|
+
const scopes = (provision.affectedUnits?.(request) ?? []).map(unitFactScope);
|
|
24
|
+
return [...PROVISION_FACTS, ...scopes];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sendJson(res: DshResponse, status: number, body: unknown): void {
|
|
28
|
+
res.statusCode = status;
|
|
29
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
30
|
+
res.setHeader('cache-control', 'no-store');
|
|
31
|
+
res.end(JSON.stringify(body));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toErrorPayload(error: unknown): Record<string, unknown> {
|
|
35
|
+
if (error instanceof WorkbenchDataError) {
|
|
36
|
+
return {
|
|
37
|
+
code: error.code,
|
|
38
|
+
message: error.message,
|
|
39
|
+
...(error.detail === undefined ? {} : { detail: error.detail }),
|
|
40
|
+
...(error.hint === undefined ? {} : { hint: error.hint }),
|
|
41
|
+
...(error.helpUrl === undefined ? {} : { helpUrl: error.helpUrl }),
|
|
42
|
+
...(error.project === undefined ? {} : { project: error.project }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { code: 'internal', message: error instanceof Error ? error.message : String(error) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function flag(search: URLSearchParams, name: string): boolean {
|
|
49
|
+
const raw = search.get(name);
|
|
50
|
+
if (raw === null) return false;
|
|
51
|
+
return raw === '' || raw === '1' || raw.toLowerCase() === 'true';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function param(search: URLSearchParams, name: string): string | undefined {
|
|
55
|
+
const raw = search.get(name);
|
|
56
|
+
return raw === null || raw === '' ? undefined : raw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerApi(ctx: DshHostContext, routes: RouteTable): () => void {
|
|
60
|
+
return ctx.webServer.register({
|
|
61
|
+
kind: 'prefix',
|
|
62
|
+
path: API_PREFIX,
|
|
63
|
+
handler: async (req: DshRequest, res: DshResponse) => {
|
|
64
|
+
const raw = req.url ?? '/';
|
|
65
|
+
const cut = raw.indexOf('?');
|
|
66
|
+
const pathname = cut === -1 ? raw : raw.slice(0, cut);
|
|
67
|
+
const search = new URLSearchParams(cut === -1 ? '' : raw.slice(cut + 1));
|
|
68
|
+
const route = pathname.slice(API_PREFIX.length).replace(/^\//, '');
|
|
69
|
+
|
|
70
|
+
if (req.method !== 'GET') {
|
|
71
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: '只支持 GET' } });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handler = Object.prototype.hasOwnProperty.call(routes, route) ? routes[route] : undefined;
|
|
76
|
+
if (handler === undefined) {
|
|
77
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `没有这个接口:${route}` } });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
sendJson(res, 200, await handler(search));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof WorkbenchDataError) {
|
|
85
|
+
sendJson(res, 200, { ok: false, error: toErrorPayload(error) });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
ctx.logger?.warn?.(`[dsh-workbench] 路由 ${route} 失败:${String(error)}`);
|
|
89
|
+
sendJson(res, 500, { ok: false, error: toErrorPayload(error) });
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function registerProvision(
|
|
96
|
+
ctx: DshHostContext,
|
|
97
|
+
provision: ProvisionHandler,
|
|
98
|
+
log?: { bump(facts: readonly string[]): unknown } | undefined,
|
|
99
|
+
): () => void {
|
|
100
|
+
return ctx.webServer.register({
|
|
101
|
+
kind: 'prefix',
|
|
102
|
+
path: PROVISION_PREFIX,
|
|
103
|
+
handler: async (req: DshRequest, res: DshResponse) => {
|
|
104
|
+
if (req.method !== 'POST') {
|
|
105
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: '机器供给只支持 POST' } });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let payload: unknown;
|
|
109
|
+
try {
|
|
110
|
+
const raw = await readBoundedBody(req);
|
|
111
|
+
if (raw === null) {
|
|
112
|
+
sendJson(res, 413, { ok: false, error: { code: 'body-too-large', message: '请求体太大' } });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
payload = raw === '' ? {} : JSON.parse(raw);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
sendJson(res, 400, { ok: false, error: { code: 'bad-json', message: String(error) } });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const body =
|
|
122
|
+
typeof payload === 'object' && payload !== null
|
|
123
|
+
? (payload as { action?: unknown; unit?: unknown; id?: unknown })
|
|
124
|
+
: {};
|
|
125
|
+
const action = body.action;
|
|
126
|
+
if (typeof action !== 'string' || !PROVISION_ACTIONS.includes(action as ProvisionAction)) {
|
|
127
|
+
sendJson(res, 400, {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: {
|
|
130
|
+
code: 'workbench/bad-action',
|
|
131
|
+
message: `不认识的 action:${String(action)}(可用:${PROVISION_ACTIONS.join(' / ')})`,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const wanted = action as ProvisionAction;
|
|
137
|
+
const target = isDependencyAction(wanted) ? body.id : body.unit;
|
|
138
|
+
if (typeof target !== 'string' || target.trim() === '') {
|
|
139
|
+
sendJson(res, 400, {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: {
|
|
142
|
+
code: isDependencyAction(wanted) ? 'workbench/no-id' : 'workbench/no-unit',
|
|
143
|
+
message: isDependencyAction(wanted) ? '缺少 id' : '缺少 unit',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const key = target.trim();
|
|
150
|
+
const request: ProvisionRequest = isDependencyAction(wanted)
|
|
151
|
+
? { action: wanted, id: key }
|
|
152
|
+
: { action: wanted, unit: key };
|
|
153
|
+
try {
|
|
154
|
+
const result = await provision.handle(request);
|
|
155
|
+
log?.bump(provisionFacts(provision, request));
|
|
156
|
+
sendJson(res, 200, result);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
log?.bump(provisionFacts(provision, request));
|
|
159
|
+
sendJson(res, 200, { ok: false, error: toErrorPayload(error) });
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface PostResource {
|
|
166
|
+
readonly resource: string;
|
|
167
|
+
readonly unit?: string;
|
|
168
|
+
handle(payload: unknown): Promise<unknown> | unknown;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface PostWrite {
|
|
172
|
+
readonly args: unknown;
|
|
173
|
+
readonly result: unknown;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function registerPost(
|
|
177
|
+
ctx: DshHostContext,
|
|
178
|
+
options: {
|
|
179
|
+
readonly prefix: string;
|
|
180
|
+
readonly what: string;
|
|
181
|
+
readonly resources: readonly PostResource[];
|
|
182
|
+
readonly refusedCode: string;
|
|
183
|
+
readonly wrote?: ((resource: string, write: PostWrite) => void) | undefined;
|
|
184
|
+
},
|
|
185
|
+
): () => void {
|
|
186
|
+
const { prefix, what, resources, refusedCode, wrote } = options;
|
|
187
|
+
const table = new Map(resources.map((item) => [item.resource, item.handle] as const));
|
|
188
|
+
return ctx.webServer.register({
|
|
189
|
+
kind: 'prefix',
|
|
190
|
+
path: prefix,
|
|
191
|
+
handler: async (req: DshRequest, res: DshResponse) => {
|
|
192
|
+
if (req.method !== 'POST') {
|
|
193
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: `${what}只支持 POST` } });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const raw = await readBoundedBody(req);
|
|
198
|
+
if (raw === null) {
|
|
199
|
+
sendJson(res, 413, { ok: false, error: { code: 'payload-too-large', message: '请求体过大' } });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let payload: unknown;
|
|
204
|
+
try {
|
|
205
|
+
payload = raw === '' ? {} : JSON.parse(raw);
|
|
206
|
+
} catch {
|
|
207
|
+
sendJson(res, 400, { ok: false, error: { code: 'bad-json', message: '请求体不是 JSON' } });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const requested = (req.url ?? '/').slice(prefix.length).replace(/^\//, '').split('?')[0] ?? '';
|
|
212
|
+
const handle = table.get(requested);
|
|
213
|
+
if (handle === undefined) {
|
|
214
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `没有这个${what}资源:${requested}` } });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const result = await handle(payload);
|
|
219
|
+
wrote?.(requested, { args: payload, result });
|
|
220
|
+
sendJson(res, 200, writeResultForClient(result, refusedCode));
|
|
221
|
+
} catch (error) {
|
|
222
|
+
sendJson(res, 200, { ok: false, error: toErrorPayload(error) });
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function readBoundedBody(req: DshRequest): Promise<string | null> {
|
|
229
|
+
if (typeof req[Symbol.asyncIterator] !== 'function') return '';
|
|
230
|
+
const chunks: Uint8Array[] = [];
|
|
231
|
+
let size = 0;
|
|
232
|
+
for await (const chunk of req as AsyncIterable<Uint8Array>) {
|
|
233
|
+
size += chunk.byteLength;
|
|
234
|
+
if (size > MAX_PREFS_BODY_BYTES) return null;
|
|
235
|
+
chunks.push(chunk);
|
|
236
|
+
}
|
|
237
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function changesHandler(log: {
|
|
241
|
+
epoch(): string;
|
|
242
|
+
since(rev: number): { rev: number; facts: readonly string[] };
|
|
243
|
+
}): RouteHandler {
|
|
244
|
+
return (search) => ({ ok: true, epoch: log.epoch(), ...log.since(Number(param(search, 'since') ?? 0) || 0) });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function preflightHandler(preflight: {
|
|
248
|
+
run(options?: { force?: boolean }): Promise<unknown>;
|
|
249
|
+
snapshot(): unknown;
|
|
250
|
+
}): RouteHandler {
|
|
251
|
+
return (search) => {
|
|
252
|
+
void preflight.run({ force: flag(search, 'force') });
|
|
253
|
+
return { ok: true, ...(preflight.snapshot() as Record<string, unknown>) };
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function capabilityHandler(
|
|
258
|
+
probe: (unit: string | undefined, force: boolean) => Promise<unknown> | unknown,
|
|
259
|
+
): RouteHandler {
|
|
260
|
+
return async (search) => {
|
|
261
|
+
const unit = param(search, 'unit');
|
|
262
|
+
const result = await probe(unit, flag(search, 'force'));
|
|
263
|
+
if (result === undefined) {
|
|
264
|
+
return { ok: false, error: { code: 'workbench/unknown-unit', message: `没有这个单元:${unit}` } };
|
|
265
|
+
}
|
|
266
|
+
return { ok: true, ...(result as Record<string, unknown>) };
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function healthHandler(ctx: DshHostContext): RouteHandler {
|
|
271
|
+
return () => {
|
|
272
|
+
const profile = ctx.get<{ dir?: string }>('profileContext');
|
|
273
|
+
const dir = profile?.dir;
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
plugin: PLUGIN_ID,
|
|
277
|
+
profile: dir === undefined ? null : dir.split('/').pop(),
|
|
278
|
+
profileDir: dir ?? null,
|
|
279
|
+
pid: process.pid,
|
|
280
|
+
now: new Date().toISOString(),
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function registerPrefs(
|
|
286
|
+
ctx: DshHostContext,
|
|
287
|
+
resources: readonly PostResource[],
|
|
288
|
+
wrote?: ((resource: string, write: PostWrite) => void) | undefined,
|
|
289
|
+
): () => void {
|
|
290
|
+
return registerPost(ctx, {
|
|
291
|
+
prefix: PREFS_PREFIX,
|
|
292
|
+
what: '偏好写',
|
|
293
|
+
refusedCode: 'write-refused',
|
|
294
|
+
resources,
|
|
295
|
+
...(wrote === undefined ? {} : { wrote }),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function registerActions(
|
|
300
|
+
ctx: DshHostContext,
|
|
301
|
+
resources: readonly PostResource[],
|
|
302
|
+
wrote?: ((resource: string, write: PostWrite) => void) | undefined,
|
|
303
|
+
): () => void {
|
|
304
|
+
return registerPost(ctx, {
|
|
305
|
+
prefix: ACTIONS_PREFIX,
|
|
306
|
+
what: '动作',
|
|
307
|
+
refusedCode: 'action-refused',
|
|
308
|
+
resources,
|
|
309
|
+
...(wrote === undefined ? {} : { wrote }),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { runsRouteOf, type LongTaskStatus, type LongTaskStep } from '../api/long-task.js';
|
|
2
|
+
|
|
3
|
+
const LIVE: ReadonlySet<string> = new Set<LongTaskStatus>(['running', 'needs-login']);
|
|
4
|
+
|
|
5
|
+
const RECENT_MS = 10 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export interface LongTaskBase {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly workspace: string;
|
|
10
|
+
readonly target: string;
|
|
11
|
+
readonly status: LongTaskStatus;
|
|
12
|
+
readonly step: string;
|
|
13
|
+
readonly steps: readonly string[];
|
|
14
|
+
readonly loginUrl?: string | undefined;
|
|
15
|
+
readonly why?: string | undefined;
|
|
16
|
+
readonly startedAt: string;
|
|
17
|
+
readonly at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LongTaskStart {
|
|
21
|
+
readonly workspace: string;
|
|
22
|
+
readonly target: string;
|
|
23
|
+
readonly payload?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LongTaskChange<S extends LongTaskBase> {
|
|
27
|
+
readonly kind: 'start' | 'step' | 'settle';
|
|
28
|
+
readonly snapshot: S;
|
|
29
|
+
readonly step?: LongTaskStep | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LongTaskDeps<S extends LongTaskBase> {
|
|
33
|
+
readonly run: (options: {
|
|
34
|
+
readonly payload: unknown;
|
|
35
|
+
readonly onStep: (step: LongTaskStep) => void;
|
|
36
|
+
}) => Promise<unknown>;
|
|
37
|
+
readonly step?: ((step: LongTaskStep, current: S) => Partial<S>) | undefined;
|
|
38
|
+
readonly outcome?: ((result: unknown, current: S) => Partial<S>) | undefined;
|
|
39
|
+
readonly view: (snapshot: S) => Readonly<Record<string, unknown>>;
|
|
40
|
+
readonly now?: (() => string) | undefined;
|
|
41
|
+
readonly nextId?: (() => string) | undefined;
|
|
42
|
+
readonly facts?: ((change: LongTaskChange<S>) => readonly string[] | undefined) | undefined;
|
|
43
|
+
readonly recentMs?: number | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LongTaskRegistry<S extends LongTaskBase> {
|
|
47
|
+
start(options: LongTaskStart): S;
|
|
48
|
+
snapshot(id: string): S | null;
|
|
49
|
+
active(workspace: string, target: string): S | null;
|
|
50
|
+
current(workspace: string): readonly Readonly<Record<string, unknown>>[];
|
|
51
|
+
onChange(listener: (change: LongTaskChange<S>) => void): () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDeps<S>): LongTaskRegistry<S> {
|
|
55
|
+
const runs = new Map<string, LongTaskBase>();
|
|
56
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
57
|
+
let counter = 0;
|
|
58
|
+
const nextId = deps.nextId ?? (() => `run-${Date.now()}-${(counter += 1)}`);
|
|
59
|
+
const recentMs = deps.recentMs ?? RECENT_MS;
|
|
60
|
+
const listeners = new Set<(change: LongTaskChange<S>) => void>();
|
|
61
|
+
|
|
62
|
+
const notify = (change: LongTaskChange<S>): void => {
|
|
63
|
+
for (const listener of [...listeners]) {
|
|
64
|
+
try {
|
|
65
|
+
listener(change);
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const patch = (id: string, next: Partial<LongTaskBase>, change: Omit<LongTaskChange<S>, 'snapshot'>): void => {
|
|
72
|
+
const current = runs.get(id);
|
|
73
|
+
if (current === undefined) return;
|
|
74
|
+
const patched = { ...current, ...next, at: now() };
|
|
75
|
+
runs.set(id, patched);
|
|
76
|
+
notify({ ...change, snapshot: patched as S });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
active(workspace, target) {
|
|
81
|
+
for (const snapshot of runs.values()) {
|
|
82
|
+
if (snapshot.workspace !== workspace || snapshot.target !== target) continue;
|
|
83
|
+
if (snapshot.status === 'running' || snapshot.status === 'needs-login') return snapshot as S;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
current(workspace) {
|
|
89
|
+
const floor = Date.parse(now()) - recentMs;
|
|
90
|
+
const newest = new Map<string, LongTaskBase>();
|
|
91
|
+
for (const snapshot of runs.values()) {
|
|
92
|
+
if (snapshot.workspace !== workspace) continue;
|
|
93
|
+
newest.set(snapshot.target, snapshot);
|
|
94
|
+
}
|
|
95
|
+
return [...newest.values()]
|
|
96
|
+
.filter(
|
|
97
|
+
(snapshot) => LIVE.has(snapshot.status) || Date.parse(snapshot.at) >= floor,
|
|
98
|
+
)
|
|
99
|
+
.reverse()
|
|
100
|
+
.map((snapshot) => deps.view(snapshot as S));
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
start(options) {
|
|
104
|
+
const running = this.active(options.workspace, options.target);
|
|
105
|
+
if (running !== null) return running;
|
|
106
|
+
const id = nextId();
|
|
107
|
+
const startedAt = now();
|
|
108
|
+
runs.set(id, {
|
|
109
|
+
id,
|
|
110
|
+
workspace: options.workspace,
|
|
111
|
+
target: options.target,
|
|
112
|
+
status: 'running',
|
|
113
|
+
step: '',
|
|
114
|
+
steps: [],
|
|
115
|
+
startedAt,
|
|
116
|
+
at: startedAt,
|
|
117
|
+
});
|
|
118
|
+
notify({ kind: 'start', snapshot: runs.get(id) as S });
|
|
119
|
+
|
|
120
|
+
const onStep = (step: LongTaskStep): void => {
|
|
121
|
+
const current = runs.get(id);
|
|
122
|
+
if (current === undefined) return;
|
|
123
|
+
const steps = [...current.steps, `${step.kind === 'fail' ? '✗ ' : ''}${step.text}`].slice(-20);
|
|
124
|
+
const extra = deps.step?.(step, current as S) ?? {};
|
|
125
|
+
if (step.loginUrl !== undefined) {
|
|
126
|
+
patch(
|
|
127
|
+
id,
|
|
128
|
+
{ ...extra, status: 'needs-login', step: step.text, steps, loginUrl: step.loginUrl },
|
|
129
|
+
{ kind: 'step', step },
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
patch(
|
|
134
|
+
id,
|
|
135
|
+
{
|
|
136
|
+
...extra,
|
|
137
|
+
status: current.status === 'needs-login' ? 'running' : current.status,
|
|
138
|
+
step: step.text,
|
|
139
|
+
steps,
|
|
140
|
+
...(current.status === 'needs-login' ? { loginUrl: undefined } : {}),
|
|
141
|
+
},
|
|
142
|
+
{ kind: 'step', step },
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
void deps
|
|
147
|
+
.run({ payload: options.payload, onStep })
|
|
148
|
+
.then((result) => {
|
|
149
|
+
const current = runs.get(id);
|
|
150
|
+
patch(
|
|
151
|
+
id,
|
|
152
|
+
{ status: 'ok', ...(deps.outcome?.(result, current as S) ?? {}), loginUrl: undefined },
|
|
153
|
+
{ kind: 'settle' },
|
|
154
|
+
);
|
|
155
|
+
})
|
|
156
|
+
.catch((error: unknown) => {
|
|
157
|
+
patch(
|
|
158
|
+
id,
|
|
159
|
+
{
|
|
160
|
+
status: 'failed',
|
|
161
|
+
why: error instanceof Error ? error.message : String(error),
|
|
162
|
+
loginUrl: undefined,
|
|
163
|
+
},
|
|
164
|
+
{ kind: 'settle' },
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return runs.get(id) as S;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
snapshot(id) {
|
|
172
|
+
return (runs.get(id) as S | undefined) ?? null;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
onChange(listener) {
|
|
176
|
+
listeners.add(listener);
|
|
177
|
+
return () => listeners.delete(listener);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isRegistry(value: unknown): value is LongTaskRegistry<LongTaskBase> {
|
|
183
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
184
|
+
const candidate = value as Partial<LongTaskRegistry<LongTaskBase>>;
|
|
185
|
+
return (
|
|
186
|
+
typeof candidate.start === 'function' &&
|
|
187
|
+
typeof candidate.current === 'function' &&
|
|
188
|
+
typeof candidate.snapshot === 'function' &&
|
|
189
|
+
typeof candidate.onChange === 'function'
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function sharedLongTaskRegistry<S extends LongTaskBase>(
|
|
194
|
+
scope: string,
|
|
195
|
+
key: string,
|
|
196
|
+
deps: LongTaskDeps<S>,
|
|
197
|
+
): LongTaskRegistry<S> {
|
|
198
|
+
const store = globalThis as unknown as Record<symbol, LongTaskRegistry<LongTaskBase> | undefined>;
|
|
199
|
+
const slot = Symbol.for(`dsh-workbench:long-task:${scope}:${key}`);
|
|
200
|
+
const existing = store[slot];
|
|
201
|
+
if (existing !== undefined && isRegistry(existing)) return existing as unknown as LongTaskRegistry<S>;
|
|
202
|
+
const created = createLongTaskRegistry(deps);
|
|
203
|
+
store[slot] = created;
|
|
204
|
+
return created;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface LongTaskHost {
|
|
208
|
+
create<S extends LongTaskBase>(options: { readonly key: string } & LongTaskDeps<S>): LongTaskRegistry<S>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createLongTaskHost(options: {
|
|
212
|
+
readonly scope: string;
|
|
213
|
+
readonly unit: string;
|
|
214
|
+
readonly bump: (facts: readonly string[]) => void;
|
|
215
|
+
}): { readonly host: LongTaskHost; dispose(): void } {
|
|
216
|
+
const offs: Array<() => void> = [];
|
|
217
|
+
return {
|
|
218
|
+
host: {
|
|
219
|
+
create(createOptions) {
|
|
220
|
+
const { key, facts, ...deps } = createOptions;
|
|
221
|
+
const registry = sharedLongTaskRegistry(options.scope, `${options.unit}:${key}`, deps);
|
|
222
|
+
offs.push(
|
|
223
|
+
registry.onChange((change) => {
|
|
224
|
+
const named = [runsRouteOf(options.unit), ...(facts?.(change) ?? [])];
|
|
225
|
+
options.bump([...new Set(named)]);
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
return registry;
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
dispose() {
|
|
232
|
+
for (const off of offs.splice(0)) off();
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { OPENCLI_PIN } from '../config/constants.js';
|
|
2
|
+
import type { UnitLibNeed } from '../api/unit.js';
|
|
3
|
+
import { quoteShellArg } from '../lib/text.js';
|
|
4
|
+
|
|
5
|
+
import { dshHome } from './data-root';
|
|
6
|
+
|
|
7
|
+
export { dshHome };
|
|
8
|
+
|
|
9
|
+
export function isolatedRoot(): string {
|
|
10
|
+
return `${dshHome()}/workbench/opencli`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isolatedHome(): string {
|
|
14
|
+
return `${isolatedRoot()}/home`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const OPENCLI_NPM_PACKAGE = '@jackwener/opencli';
|
|
18
|
+
|
|
19
|
+
export function toolchainDir(): string {
|
|
20
|
+
return `${isolatedRoot()}/toolchain`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toolchainOpencliDir(): string {
|
|
24
|
+
return `${toolchainDir()}/node_modules/${OPENCLI_NPM_PACKAGE}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function vendorPrefix(): string {
|
|
28
|
+
return `${isolatedRoot()}/plugins-npm`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function vendorPeerLink(): { readonly dir: string; readonly parent: string } {
|
|
32
|
+
const base = `${vendorPrefix()}/node_modules`;
|
|
33
|
+
const [scope] = OPENCLI_NPM_PACKAGE.split('/');
|
|
34
|
+
return { dir: `${base}/${OPENCLI_NPM_PACKAGE}`, parent: `${base}/${scope}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function npmCacheDir(): string {
|
|
38
|
+
return `${isolatedRoot()}/npm-cache`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isolatedCommand(): string {
|
|
42
|
+
return `${isolatedRoot()}/bin/opencli`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function libsPrefix(): string {
|
|
46
|
+
return `${isolatedRoot()}/libs`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function libDir(name: string): string {
|
|
50
|
+
return `${libsPrefix()}/node_modules/${name}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function libInstallCommand(options: {
|
|
54
|
+
readonly need: UnitLibNeed;
|
|
55
|
+
readonly version?: string | undefined;
|
|
56
|
+
}): string {
|
|
57
|
+
const { need, version } = options;
|
|
58
|
+
const registry =
|
|
59
|
+
process.env[need.registryEnv]?.trim() || need.registryDefault;
|
|
60
|
+
const spec = version ?? need.version ?? 'latest';
|
|
61
|
+
return `npm i --prefix '${libsPrefix()}' ${quoteShellArg(`${need.name}@${spec}`)} --registry '${registry}' --cache '${npmCacheDir()}'`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isolatedInstallCommand(): string {
|
|
65
|
+
return `npm i --prefix '${toolchainDir()}' ${OPENCLI_NPM_PACKAGE}@${OPENCLI_PIN} --cache '${npmCacheDir()}'`;
|
|
66
|
+
}
|