@chenguangyao/devflow-kit 0.1.43 → 0.1.44
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/client/workflow-adapter.js +3 -0
- package/docs/workflow-orchestration.md +97 -6
- package/docs/workflow-ui-prototype.html +46 -1
- package/package.json +4 -5
- package/schemas/status-surface.schema.json +33 -1
- package/schemas/workflow-adapter-catalog.schema.json +104 -0
- package/schemas/workflow-adapter-surface.schema.json +193 -0
- package/schemas/workflow-confirmation-surface.schema.json +3 -1
- package/schemas/workflow-picker.schema.json +6 -1
- package/schemas/workflow-selection-result.schema.json +69 -0
- package/schemas/workflow-selection.schema.json +84 -0
- package/schemas/workflow-ui-command-result.schema.json +29 -0
- package/schemas/workflow-ui-error.schema.json +49 -0
- package/scripts/render-workflow-ui-prototype.js +141 -4
- package/scripts/workflow-adapter-client-example.js +67 -0
- package/scripts/workflow-ui-browser-smoke.js +175 -0
- package/skills/df-orchestrator/SKILL.md +2 -1
- package/skills/df-orchestrator/references/workflow-state-machine.md +2 -2
- package/src/cli/commands/_helpers.js +1 -0
- package/src/cli/commands/flow.js +146 -16
- package/src/cli/commands/help.js +1 -1
- package/src/cli/commands/status.js +2 -5
- package/src/client/workflow-adapter-client.js +291 -0
- package/src/core/workflow-actions.js +25 -0
- package/src/core/workflow-picker.js +16 -8
- package/src/core/workflow-ui-adapter.js +467 -0
- package/src/core/workflow-ui-host.js +837 -0
- package/docs/migration-from-arb.md +0 -232
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const childProcess = require('child_process');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { URL } = require('url');
|
|
7
|
+
|
|
8
|
+
const workflowUiAdapter = require('./workflow-ui-adapter.js');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
11
|
+
const DEFAULT_PORT = 8787;
|
|
12
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
13
|
+
const WORKFLOW_UI_ERROR_SCHEMA = 'https://devflow.dev/schemas/workflow-ui-error.schema.json';
|
|
14
|
+
const WORKFLOW_UI_COMMAND_RESULT_SCHEMA = 'https://devflow.dev/schemas/workflow-ui-command-result.schema.json';
|
|
15
|
+
|
|
16
|
+
async function createWorkflowUiHost(options = {}) {
|
|
17
|
+
const root = options.root || process.cwd();
|
|
18
|
+
const slug = options.slug;
|
|
19
|
+
if (!slug) throw new Error('createWorkflowUiHost requires slug');
|
|
20
|
+
const hostname = options.host || options.hostname || DEFAULT_HOST;
|
|
21
|
+
const port = normalizePort(options.port, 0);
|
|
22
|
+
const binPath = options.binPath || path.resolve(__dirname, '..', '..', 'bin', 'devflow.js');
|
|
23
|
+
const execFile = options.execFile || childProcess.execFile;
|
|
24
|
+
|
|
25
|
+
const requestContext = { root, slug, binPath, execFile, host: hostname, port: null, url: null };
|
|
26
|
+
const server = http.createServer((req, res) => {
|
|
27
|
+
handleRequest(req, res, requestContext).catch((e) => {
|
|
28
|
+
writeWorkflowUiError(res, e.statusCode || 500, {
|
|
29
|
+
code: e.code || 'workflow-ui-error',
|
|
30
|
+
message: e.message,
|
|
31
|
+
details: e.details || null,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
server.once('error', reject);
|
|
38
|
+
server.listen(port, hostname, () => {
|
|
39
|
+
server.off('error', reject);
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const address = server.address();
|
|
45
|
+
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
46
|
+
const url = `http://${hostname}:${actualPort}/`;
|
|
47
|
+
requestContext.port = actualPort;
|
|
48
|
+
requestContext.url = url;
|
|
49
|
+
return {
|
|
50
|
+
type: 'workflow_ui_host',
|
|
51
|
+
slug,
|
|
52
|
+
root,
|
|
53
|
+
host: hostname,
|
|
54
|
+
port: actualPort,
|
|
55
|
+
url,
|
|
56
|
+
server,
|
|
57
|
+
close: () => new Promise((resolve, reject) => {
|
|
58
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleRequest(req, res, context) {
|
|
64
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || DEFAULT_HOST}`);
|
|
65
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
66
|
+
writeHtml(res, renderWorkflowUiHostHtml({ slug: context.slug }));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
70
|
+
await writeDevflowJson(res, context, ['status', `--slug=${context.slug}`, '--json']);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (req.method === 'GET' && url.pathname === '/api/picker') {
|
|
74
|
+
await writeDevflowJson(res, context, ['flow', 'picker', `--slug=${context.slug}`, '--json']);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (req.method === 'GET' && url.pathname === '/api/adapter/catalog') {
|
|
78
|
+
const catalog = workflowUiAdapter.buildWorkflowAdapterCatalog();
|
|
79
|
+
if (isTruthyQuery(url.searchParams.get('validate'))) workflowUiAdapter.assertWorkflowAdapterCatalog(catalog);
|
|
80
|
+
writeJson(res, 200, catalog);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (req.method === 'GET' && url.pathname === '/api/adapter') {
|
|
84
|
+
const host = url.searchParams.get('host') || 'browser';
|
|
85
|
+
const profile = url.searchParams.get('profile');
|
|
86
|
+
const renderMode = url.searchParams.get('render-mode') || url.searchParams.get('renderMode');
|
|
87
|
+
const args = [
|
|
88
|
+
'flow',
|
|
89
|
+
'adapter',
|
|
90
|
+
`--slug=${context.slug}`,
|
|
91
|
+
`--host=${host}`,
|
|
92
|
+
`--port=${context.port || DEFAULT_PORT}`,
|
|
93
|
+
'--json',
|
|
94
|
+
];
|
|
95
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
96
|
+
if (renderMode) args.push(`--render-mode=${renderMode}`);
|
|
97
|
+
if (isTruthyQuery(url.searchParams.get('validate'))) args.push('--validate-contract');
|
|
98
|
+
await writeDevflowJson(res, context, args);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (req.method === 'GET' && url.pathname === '/api/card') {
|
|
102
|
+
await writeDevflowJson(res, context, ['flow', 'card', `--slug=${context.slug}`, '--json']);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (req.method === 'GET' && url.pathname === '/api/diff') {
|
|
106
|
+
await writeDevflowJson(res, context, ['flow', 'diff', `--slug=${context.slug}`, '--json']);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (req.method === 'GET' && url.pathname === '/api/explain') {
|
|
110
|
+
await writeDevflowJson(res, context, ['flow', 'explain', `--slug=${context.slug}`, '--json']);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (req.method === 'POST' && (url.pathname === '/api/apply-selection' || url.pathname === '/api/adapter/apply-selection')) {
|
|
114
|
+
const body = await readJsonBody(req);
|
|
115
|
+
const selectionErrors = validateWorkflowSelection(body);
|
|
116
|
+
if (selectionErrors.length) {
|
|
117
|
+
writeWorkflowUiError(res, 400, {
|
|
118
|
+
code: 'invalid-workflow-selection',
|
|
119
|
+
message: selectionErrors[0],
|
|
120
|
+
details: selectionErrors,
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await writeDevflowJson(res, context, [
|
|
125
|
+
'flow',
|
|
126
|
+
'apply-selection',
|
|
127
|
+
`--slug=${context.slug}`,
|
|
128
|
+
`--selection=${JSON.stringify(body)}`,
|
|
129
|
+
'--json',
|
|
130
|
+
]);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (req.method === 'POST' && url.pathname === '/api/confirm') {
|
|
134
|
+
const result = await runDevflow(context, ['flow', 'confirm', `--slug=${context.slug}`], { json: false });
|
|
135
|
+
writeWorkflowUiCommandResult(res, result.exitCode === 0 ? 200 : 500, {
|
|
136
|
+
ok: result.exitCode === 0,
|
|
137
|
+
slug: context.slug,
|
|
138
|
+
output: result.stdout,
|
|
139
|
+
error: result.stderr || null,
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (req.method === 'POST' && url.pathname === '/api/checkpoint/resolve') {
|
|
144
|
+
const body = await readJsonBody(req);
|
|
145
|
+
if (!body.id || !body.decision) {
|
|
146
|
+
throw new WorkflowUiError(400, 'invalid-checkpoint-resolution', 'checkpoint resolve requires id and decision');
|
|
147
|
+
}
|
|
148
|
+
const result = await runDevflow(context, [
|
|
149
|
+
'checkpoint',
|
|
150
|
+
'resolve',
|
|
151
|
+
`--id=${body.id}`,
|
|
152
|
+
`--decision=${body.decision}`,
|
|
153
|
+
], { json: false });
|
|
154
|
+
writeWorkflowUiCommandResult(res, result.exitCode === 0 ? 200 : 500, {
|
|
155
|
+
ok: result.exitCode === 0,
|
|
156
|
+
slug: context.slug,
|
|
157
|
+
output: result.stdout,
|
|
158
|
+
error: result.stderr || null,
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
writeWorkflowUiError(res, 404, {
|
|
163
|
+
code: 'route-not-found',
|
|
164
|
+
message: `not found: ${req.method} ${url.pathname}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class WorkflowUiError extends Error {
|
|
169
|
+
constructor(statusCode, code, message, details = null) {
|
|
170
|
+
super(message);
|
|
171
|
+
this.name = 'WorkflowUiError';
|
|
172
|
+
this.statusCode = statusCode;
|
|
173
|
+
this.code = code;
|
|
174
|
+
this.details = details;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isTruthyQuery(value) {
|
|
179
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
180
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateWorkflowSelection(value) {
|
|
184
|
+
if (!isPlainObject(value)) return ['selection must be an object'];
|
|
185
|
+
if (value.type === undefined) {
|
|
186
|
+
return validateSelectionItems(value.items, 'items');
|
|
187
|
+
}
|
|
188
|
+
if (value.type === 'workflow_picker_surface' || value.type === 'workflow_chat_selection') {
|
|
189
|
+
return validateSelectionGroups(value.groups);
|
|
190
|
+
}
|
|
191
|
+
return ['selection.type must be workflow_picker_surface or workflow_chat_selection'];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateSelectionGroups(groups) {
|
|
195
|
+
if (!Array.isArray(groups)) return ['groups must be an array'];
|
|
196
|
+
const errors = [];
|
|
197
|
+
groups.forEach((group, groupIndex) => {
|
|
198
|
+
if (!isPlainObject(group)) {
|
|
199
|
+
errors.push(`groups[${groupIndex}] must be an object`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
errors.push(...validateSelectionItems(group.items, `groups[${groupIndex}].items`));
|
|
203
|
+
});
|
|
204
|
+
return errors;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function validateSelectionItems(items, pathName) {
|
|
208
|
+
if (!Array.isArray(items)) return [`${pathName} must be an array`];
|
|
209
|
+
const errors = [];
|
|
210
|
+
items.forEach((item, itemIndex) => {
|
|
211
|
+
const itemPath = `${pathName}[${itemIndex}]`;
|
|
212
|
+
if (!isPlainObject(item)) {
|
|
213
|
+
errors.push(`${itemPath} must be an object`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (typeof item.step !== 'string' || !item.step.trim()) {
|
|
217
|
+
errors.push(`${itemPath}.step must be a non-empty string`);
|
|
218
|
+
}
|
|
219
|
+
if (typeof item.selected !== 'boolean') {
|
|
220
|
+
errors.push(`${itemPath}.selected must be a boolean`);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return errors;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isPlainObject(value) {
|
|
227
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function writeDevflowJson(res, context, args) {
|
|
231
|
+
const result = await runDevflow(context, args, { json: true });
|
|
232
|
+
if (result.json) {
|
|
233
|
+
writeJson(res, 200, result.json);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const command = `devflow ${args.join(' ')}`;
|
|
237
|
+
const failed = result.exitCode !== 0;
|
|
238
|
+
const message = failed
|
|
239
|
+
? firstNonEmptyLine(result.stderr, result.stdout) || `${command} failed`
|
|
240
|
+
: `${command} did not return JSON`;
|
|
241
|
+
writeWorkflowUiError(res, failed ? 500 : 502, {
|
|
242
|
+
code: failed ? 'devflow-command-failed' : 'devflow-json-missing',
|
|
243
|
+
message,
|
|
244
|
+
command,
|
|
245
|
+
exitCode: result.exitCode,
|
|
246
|
+
stdout: result.stdout,
|
|
247
|
+
stderr: result.stderr,
|
|
248
|
+
details: {
|
|
249
|
+
expected: 'json',
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function writeWorkflowUiError(res, statusCode, fields) {
|
|
255
|
+
writeJson(res, statusCode, {
|
|
256
|
+
type: 'workflow_ui_error',
|
|
257
|
+
schema: WORKFLOW_UI_ERROR_SCHEMA,
|
|
258
|
+
ok: false,
|
|
259
|
+
...fields,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function writeWorkflowUiCommandResult(res, statusCode, fields) {
|
|
264
|
+
writeJson(res, statusCode, {
|
|
265
|
+
type: 'workflow_ui_command_result',
|
|
266
|
+
schema: WORKFLOW_UI_COMMAND_RESULT_SCHEMA,
|
|
267
|
+
...fields,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function firstNonEmptyLine(...values) {
|
|
272
|
+
for (const value of values) {
|
|
273
|
+
const line = String(value || '').split(/\r?\n/).map((item) => item.trim()).find(Boolean);
|
|
274
|
+
if (line) return line;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function runDevflow(context, args) {
|
|
280
|
+
return new Promise((resolve) => {
|
|
281
|
+
context.execFile(process.execPath, [context.binPath, ...args], {
|
|
282
|
+
cwd: context.root,
|
|
283
|
+
encoding: 'utf8',
|
|
284
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
285
|
+
}, (err, stdout = '', stderr = '') => {
|
|
286
|
+
const exitCode = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
|
|
287
|
+
const out = String(stdout || err?.stdout || '');
|
|
288
|
+
const errOut = String(stderr || err?.stderr || '');
|
|
289
|
+
resolve({
|
|
290
|
+
exitCode,
|
|
291
|
+
stdout: out,
|
|
292
|
+
stderr: errOut,
|
|
293
|
+
json: tryParseJson(out),
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function tryParseJson(value) {
|
|
300
|
+
const text = String(value || '').trim();
|
|
301
|
+
if (!text) return null;
|
|
302
|
+
try { return JSON.parse(text); } catch (_) { return null; }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readJsonBody(req) {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
let data = '';
|
|
308
|
+
req.setEncoding('utf8');
|
|
309
|
+
req.on('data', (chunk) => {
|
|
310
|
+
data += chunk;
|
|
311
|
+
if (Buffer.byteLength(data) > MAX_BODY_BYTES) {
|
|
312
|
+
reject(new WorkflowUiError(413, 'request-body-too-large', 'request body too large'));
|
|
313
|
+
req.destroy();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
req.on('end', () => {
|
|
317
|
+
if (!data.trim()) {
|
|
318
|
+
resolve({});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
try { resolve(JSON.parse(data)); } catch (e) {
|
|
322
|
+
reject(new WorkflowUiError(400, 'invalid-json-body', `invalid JSON body: ${e.message}`));
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
req.on('error', reject);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderWorkflowUiHostHtml({ slug }) {
|
|
330
|
+
return [
|
|
331
|
+
'<!doctype html>',
|
|
332
|
+
'<html lang="zh-CN">',
|
|
333
|
+
'<head>',
|
|
334
|
+
' <meta charset="utf-8">',
|
|
335
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
336
|
+
` <title>devflow workflow - ${escapeHtml(slug)}</title>`,
|
|
337
|
+
' <style>',
|
|
338
|
+
renderCss(),
|
|
339
|
+
' </style>',
|
|
340
|
+
'</head>',
|
|
341
|
+
'<body data-app="workflow-ui-host">',
|
|
342
|
+
' <main class="page">',
|
|
343
|
+
' <header class="topbar">',
|
|
344
|
+
' <div>',
|
|
345
|
+
' <h1>Workflow 编排</h1>',
|
|
346
|
+
` <p>${escapeHtml(slug)}</p>`,
|
|
347
|
+
' </div>',
|
|
348
|
+
' <div class="top-actions">',
|
|
349
|
+
' <button id="refresh" type="button">刷新</button>',
|
|
350
|
+
' <button id="apply" type="button">应用选择</button>',
|
|
351
|
+
' <button id="confirm" type="button">确认 workflow</button>',
|
|
352
|
+
' </div>',
|
|
353
|
+
' </header>',
|
|
354
|
+
' <section class="band" aria-label="Workflow status">',
|
|
355
|
+
' <h2>当前状态</h2>',
|
|
356
|
+
' <div id="primary-panel"></div>',
|
|
357
|
+
' <pre id="status">loading...</pre>',
|
|
358
|
+
' </section>',
|
|
359
|
+
' <section class="band" aria-label="Workflow picker">',
|
|
360
|
+
' <div class="section-head">',
|
|
361
|
+
' <h2>Skill 选项卡</h2>',
|
|
362
|
+
' <code id="picker-command">/api/picker · /api/adapter</code>',
|
|
363
|
+
' </div>',
|
|
364
|
+
' <div id="picker" class="groups"></div>',
|
|
365
|
+
' </section>',
|
|
366
|
+
' <section class="band" aria-label="Workflow insight">',
|
|
367
|
+
' <div class="section-head">',
|
|
368
|
+
' <h2>编排说明</h2>',
|
|
369
|
+
' <code>/api/diff · /api/explain</code>',
|
|
370
|
+
' </div>',
|
|
371
|
+
' <div id="insight" class="insight-grid"></div>',
|
|
372
|
+
' </section>',
|
|
373
|
+
' <section class="band" aria-label="Workflow result">',
|
|
374
|
+
' <h2>结果</h2>',
|
|
375
|
+
' <pre id="result">等待操作</pre>',
|
|
376
|
+
' <div id="result-card"></div>',
|
|
377
|
+
' </section>',
|
|
378
|
+
' <section class="band" aria-label="Workflow card">',
|
|
379
|
+
' <h2>确认卡</h2>',
|
|
380
|
+
' <div id="card" class="timeline"></div>',
|
|
381
|
+
' </section>',
|
|
382
|
+
' </main>',
|
|
383
|
+
' <script>',
|
|
384
|
+
renderClientScript(),
|
|
385
|
+
' </script>',
|
|
386
|
+
'</body>',
|
|
387
|
+
'</html>',
|
|
388
|
+
'',
|
|
389
|
+
].join('\n');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderClientScript() {
|
|
393
|
+
return `(${browserClientScript.toString()})();`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function browserClientScript() {
|
|
397
|
+
const state = { picker: null, status: null };
|
|
398
|
+
const $ = (id) => document.getElementById(id);
|
|
399
|
+
|
|
400
|
+
async function api(path, options = {}) {
|
|
401
|
+
const res = await fetch(path, {
|
|
402
|
+
method: options.method || 'GET',
|
|
403
|
+
headers: options.body ? { 'content-type': 'application/json' } : undefined,
|
|
404
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
405
|
+
});
|
|
406
|
+
const data = await res.json();
|
|
407
|
+
if (!res.ok && !data.type) throw new Error(data.message || res.statusText);
|
|
408
|
+
return data;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function refresh() {
|
|
412
|
+
const [status, picker, card, diff, explain] = await Promise.all([
|
|
413
|
+
api('/api/status'),
|
|
414
|
+
api('/api/picker'),
|
|
415
|
+
api('/api/card'),
|
|
416
|
+
api('/api/diff'),
|
|
417
|
+
api('/api/explain'),
|
|
418
|
+
]);
|
|
419
|
+
state.picker = picker;
|
|
420
|
+
state.status = status;
|
|
421
|
+
$('status').textContent = JSON.stringify({
|
|
422
|
+
phase: status.phase,
|
|
423
|
+
workflow: status.workflow ? {
|
|
424
|
+
status: status.workflow.status,
|
|
425
|
+
currentStep: status.workflow.currentStep,
|
|
426
|
+
nextStep: status.workflow.nextStep,
|
|
427
|
+
} : null,
|
|
428
|
+
blockingReason: status.blockingReason || null,
|
|
429
|
+
nextAction: status.nextAction || null,
|
|
430
|
+
}, null, 2);
|
|
431
|
+
renderPrimaryPanel(status);
|
|
432
|
+
renderPicker(picker);
|
|
433
|
+
renderCard(card);
|
|
434
|
+
renderWorkflowInsight(diff, explain);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function renderPrimaryPanel(surface) {
|
|
438
|
+
const panel = surface.primaryPanel;
|
|
439
|
+
const actions = surface.availableActions || [];
|
|
440
|
+
if (!panel) {
|
|
441
|
+
$('primary-panel').innerHTML = '';
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const reason = surface.blockingReason || {};
|
|
445
|
+
$('primary-panel').innerHTML = `
|
|
446
|
+
<div class="primary-panel" data-surface="primary-panel">
|
|
447
|
+
<div>
|
|
448
|
+
<strong>${escapeHtml(panel.title || '下一步')}</strong>
|
|
449
|
+
<small>${escapeHtml(reason.message || surface.nextAction || '')}</small>
|
|
450
|
+
</div>
|
|
451
|
+
<div class="inline-actions">
|
|
452
|
+
${actions.map((action) => `
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
class="${action.danger ? 'danger-button' : ''}"
|
|
456
|
+
data-action-id="${escapeAttr(action.id)}"
|
|
457
|
+
data-action-kind="${escapeAttr(action.kind || '')}"
|
|
458
|
+
>${escapeHtml(action.label || action.id)}</button>
|
|
459
|
+
`).join('')}
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
`;
|
|
463
|
+
for (const button of document.querySelectorAll('[data-action-id]')) {
|
|
464
|
+
button.addEventListener('click', async () => {
|
|
465
|
+
const action = actions.find((item) => item.id === button.dataset.actionId);
|
|
466
|
+
if (action) await runSurfaceAction(action, surface);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function runSurfaceAction(action, surface) {
|
|
472
|
+
const command = action.command || '';
|
|
473
|
+
if (action.id === 'confirm-workflow' || command.includes('flow confirm')) {
|
|
474
|
+
await confirmWorkflow();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (action.kind === 'checkpoint-decision') {
|
|
478
|
+
await resolveCheckpointAction(command, action);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (action.kind === 'open-surface' && command.includes('checkpoint show')) {
|
|
482
|
+
renderCheckpointConfirmationSurface({
|
|
483
|
+
confirmationCard: surface.checkpointConfirmationCard,
|
|
484
|
+
checkpoint: surface.pendingCheckpoint,
|
|
485
|
+
});
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (action.kind === 'open-surface' && command.includes('flow card')) {
|
|
489
|
+
const card = await api('/api/card');
|
|
490
|
+
renderCard(card);
|
|
491
|
+
document.querySelector('[aria-label="Workflow card"]').scrollIntoView({ block: 'nearest' });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (action.kind === 'open-surface' && command.includes('flow picker')) {
|
|
495
|
+
document.querySelector('[aria-label="Workflow picker"]').scrollIntoView({ block: 'nearest' });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
$('result-card').innerHTML = `
|
|
499
|
+
<div class="decision-card">
|
|
500
|
+
<strong>需要在终端执行</strong>
|
|
501
|
+
<small>${escapeHtml(action.label || action.id)}</small>
|
|
502
|
+
<code>${escapeHtml(command)}</code>
|
|
503
|
+
</div>
|
|
504
|
+
`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function resolveCheckpointAction(command, action = {}) {
|
|
508
|
+
const id = commandArg(command, '--id');
|
|
509
|
+
const decision = commandArg(command, '--decision');
|
|
510
|
+
if (!id || !decision) {
|
|
511
|
+
if (action.id === 'keep' || command.includes('status')) {
|
|
512
|
+
await refresh();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
$('result-card').innerHTML = `
|
|
516
|
+
<div class="decision-card">
|
|
517
|
+
<strong>需要人工处理</strong>
|
|
518
|
+
<small>${escapeHtml(action.label || action.id || 'checkpoint action')}</small>
|
|
519
|
+
<code>${escapeHtml(command)}</code>
|
|
520
|
+
</div>
|
|
521
|
+
`;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const result = await api('/api/checkpoint/resolve', {
|
|
525
|
+
method: 'POST',
|
|
526
|
+
body: { id, decision },
|
|
527
|
+
});
|
|
528
|
+
$('result').textContent = JSON.stringify(result, null, 2);
|
|
529
|
+
renderResultSurface(result);
|
|
530
|
+
await refresh();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function renderPicker(surface) {
|
|
534
|
+
$('picker-command').textContent = surface.actions && surface.actions.applySelectionFile
|
|
535
|
+
? surface.actions.applySelectionFile
|
|
536
|
+
: '/api/apply-selection';
|
|
537
|
+
$('picker').innerHTML = (surface.groups || []).map((group) => `
|
|
538
|
+
<section class="group">
|
|
539
|
+
<h3>${escapeHtml(group.label || group.id)}</h3>
|
|
540
|
+
<ol>${(group.items || []).map(renderItem).join('')}</ol>
|
|
541
|
+
</section>
|
|
542
|
+
`).join('');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function renderWorkflowInsight(diff, explain) {
|
|
546
|
+
const added = diff.added || [];
|
|
547
|
+
const disabled = diff.disabled || [];
|
|
548
|
+
const moved = diff.moved || [];
|
|
549
|
+
const verifyReports = diff.verifyReports || [];
|
|
550
|
+
const risks = explain.openRiskSignals || [];
|
|
551
|
+
const overrides = explain.overrides || [];
|
|
552
|
+
const verify = explain.verify || {};
|
|
553
|
+
$('insight').innerHTML = `
|
|
554
|
+
<section class="insight-card">
|
|
555
|
+
<h3>相对默认流程</h3>
|
|
556
|
+
${renderInsightList('新增', added)}
|
|
557
|
+
${renderInsightList('禁用', disabled)}
|
|
558
|
+
${renderInsightList('移动', moved)}
|
|
559
|
+
${renderInsightList('验证要求', verifyReports.length ? verifyReports : (verify.requiredReports || []))}
|
|
560
|
+
</section>
|
|
561
|
+
<section class="insight-card">
|
|
562
|
+
<h3>推荐原因</h3>
|
|
563
|
+
${renderInsightList('风险信号', risks.map((risk) => `${risk.type}${risk.reason ? ': ' + risk.reason : ''}`))}
|
|
564
|
+
${verify.reason ? `<p>${escapeHtml(verify.reason)}</p>` : ''}
|
|
565
|
+
${renderInsightList('本次 override', overrides.map(formatOverride))}
|
|
566
|
+
</section>
|
|
567
|
+
`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderInsightList(label, items) {
|
|
571
|
+
const values = Array.isArray(items) ? items.filter(Boolean) : [];
|
|
572
|
+
return `
|
|
573
|
+
<div class="insight-row">
|
|
574
|
+
<strong>${escapeHtml(label)}</strong>
|
|
575
|
+
<span>${values.length ? values.map((item) => `<code>${escapeHtml(item)}</code>`).join('') : '<small>-</small>'}</span>
|
|
576
|
+
</div>
|
|
577
|
+
`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function formatOverride(item) {
|
|
581
|
+
if (!item) return '';
|
|
582
|
+
if (typeof item === 'string') return item;
|
|
583
|
+
return [
|
|
584
|
+
item.type || item.action || 'override',
|
|
585
|
+
item.step || item.skill || null,
|
|
586
|
+
item.reason || null,
|
|
587
|
+
].filter(Boolean).join(': ');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderItem(item) {
|
|
591
|
+
const checked = item.selected ? ' checked' : '';
|
|
592
|
+
const disabled = item.locked ? ' disabled' : '';
|
|
593
|
+
const tags = [
|
|
594
|
+
item.recommended ? 'recommended' : null,
|
|
595
|
+
item.required ? 'required' : null,
|
|
596
|
+
item.optional ? 'optional' : null,
|
|
597
|
+
item.locked ? 'locked' : null,
|
|
598
|
+
].filter(Boolean).join(' / ');
|
|
599
|
+
return `
|
|
600
|
+
<li>
|
|
601
|
+
<label>
|
|
602
|
+
<input type="checkbox" data-step="${escapeAttr(item.step)}"${checked}${disabled}>
|
|
603
|
+
<span>${escapeHtml(item.step)}</span>
|
|
604
|
+
</label>
|
|
605
|
+
<small>${escapeHtml(item.skill || '')}${tags ? ' · ' + escapeHtml(tags) : ''}</small>
|
|
606
|
+
${item.reason ? `<p>${escapeHtml(item.reason)}</p>` : ''}
|
|
607
|
+
</li>
|
|
608
|
+
`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function currentSelection() {
|
|
612
|
+
const selected = new Map(Array.from(document.querySelectorAll('[data-step]')).map((input) => [
|
|
613
|
+
input.dataset.step,
|
|
614
|
+
input.checked,
|
|
615
|
+
]));
|
|
616
|
+
const items = [];
|
|
617
|
+
for (const group of state.picker.groups || []) {
|
|
618
|
+
for (const item of group.items || []) {
|
|
619
|
+
items.push({
|
|
620
|
+
step: item.step,
|
|
621
|
+
skill: item.skill,
|
|
622
|
+
selected: selected.has(item.step) ? selected.get(item.step) : item.selected === true,
|
|
623
|
+
reason: item.selected && selected.get(item.step) === false ? 'disabled from workflow UI' : undefined,
|
|
624
|
+
after: item.after || undefined,
|
|
625
|
+
before: item.before || undefined,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return { items };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function applySelection() {
|
|
633
|
+
const result = await api('/api/apply-selection', { method: 'POST', body: currentSelection() });
|
|
634
|
+
$('result').textContent = JSON.stringify(result, null, 2);
|
|
635
|
+
renderResultSurface(result);
|
|
636
|
+
await refresh();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function confirmWorkflow() {
|
|
640
|
+
const result = await api('/api/confirm', { method: 'POST', body: {} });
|
|
641
|
+
$('result').textContent = JSON.stringify(result, null, 2);
|
|
642
|
+
renderResultSurface(result);
|
|
643
|
+
await refresh();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function renderResultSurface(surface) {
|
|
647
|
+
if (surface.type === 'workflow_policy_confirmation_surface') {
|
|
648
|
+
renderCheckpointConfirmationSurface(surface, 'workflow-policy-card');
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (surface.type === 'checkpoint_confirmation_surface') {
|
|
652
|
+
renderCheckpointConfirmationSurface(surface, 'checkpoint-card');
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (surface.type === 'workflow_selection_result_surface') {
|
|
656
|
+
$('result-card').innerHTML = `
|
|
657
|
+
<div class="decision-card ok">
|
|
658
|
+
<strong>选择已应用</strong>
|
|
659
|
+
<small>added=${(surface.added || []).length} · disabled=${(surface.disabled || []).length}</small>
|
|
660
|
+
<code>${escapeHtml(surface.nextAction || '')}</code>
|
|
661
|
+
</div>
|
|
662
|
+
`;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (surface.type === 'workflow_ui_command_result') {
|
|
666
|
+
$('result-card').innerHTML = `
|
|
667
|
+
<div class="decision-card ${surface.ok ? 'ok' : 'danger'}">
|
|
668
|
+
<strong>${surface.ok ? '命令已执行' : '命令失败'}</strong>
|
|
669
|
+
<small>${escapeHtml(surface.output || surface.error || '')}</small>
|
|
670
|
+
</div>
|
|
671
|
+
`;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
$('result-card').innerHTML = '';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderCheckpointConfirmationSurface(surface, surfaceName = 'checkpoint-card') {
|
|
678
|
+
const card = surface.confirmationCard || {};
|
|
679
|
+
const checkpoint = surface.checkpoint || card.checkpoint || {};
|
|
680
|
+
const primary = card.primaryAction || {};
|
|
681
|
+
const checkpointId = checkpoint.id || commandArg(primary.command, '--id');
|
|
682
|
+
const decision = commandArg(primary.command, '--decision') || primary.id || 'accept-risk';
|
|
683
|
+
const secondary = (card.secondaryActions || [])[0];
|
|
684
|
+
$('result-card').innerHTML = `
|
|
685
|
+
<div class="decision-card policy" data-surface="${escapeAttr(surfaceName)}">
|
|
686
|
+
<strong>${escapeHtml(card.title || '确认下一步')}</strong>
|
|
687
|
+
<p>${escapeHtml(card.question || checkpoint.summary || '这个 workflow 调整会提高风险,是否接受?')}</p>
|
|
688
|
+
<small>${escapeHtml(checkpoint.summary || checkpointId || '')}</small>
|
|
689
|
+
<div class="inline-actions">
|
|
690
|
+
<button type="button" data-checkpoint-id="${escapeAttr(checkpointId)}" data-decision="${escapeAttr(decision)}">${escapeHtml(primary.label || decision || '确认')}</button>
|
|
691
|
+
<button type="button" data-refresh="true">${escapeHtml(secondary ? secondary.id : 'keep')}</button>
|
|
692
|
+
</div>
|
|
693
|
+
<code>${escapeHtml(primary.command || '')}</code>
|
|
694
|
+
</div>
|
|
695
|
+
`;
|
|
696
|
+
const accept = $('result-card').querySelector('[data-checkpoint-id]');
|
|
697
|
+
if (accept) {
|
|
698
|
+
accept.addEventListener('click', async () => {
|
|
699
|
+
const result = await api('/api/checkpoint/resolve', {
|
|
700
|
+
method: 'POST',
|
|
701
|
+
body: {
|
|
702
|
+
id: accept.dataset.checkpointId,
|
|
703
|
+
decision: accept.dataset.decision,
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
$('result').textContent = JSON.stringify(result, null, 2);
|
|
707
|
+
renderResultSurface(result);
|
|
708
|
+
await refresh();
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
const keep = $('result-card').querySelector('[data-refresh]');
|
|
712
|
+
if (keep) keep.addEventListener('click', refresh);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function commandArg(command, name) {
|
|
716
|
+
const tokens = String(command || '').split(/\s+/);
|
|
717
|
+
for (const token of tokens) {
|
|
718
|
+
if (token.startsWith(`${name}=`)) return token.slice(name.length + 1);
|
|
719
|
+
}
|
|
720
|
+
const idx = tokens.indexOf(name);
|
|
721
|
+
return idx === -1 ? '' : tokens[idx + 1] || '';
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function renderCard(surface) {
|
|
725
|
+
const card = surface.confirmationCard || {};
|
|
726
|
+
$('card').innerHTML = (card.steps || []).map((step, idx) => `
|
|
727
|
+
<div class="step">
|
|
728
|
+
<span>${idx + 1}</span>
|
|
729
|
+
<strong>${escapeHtml(step.id)}</strong>
|
|
730
|
+
<small>${escapeHtml(step.skill || '')}</small>
|
|
731
|
+
</div>
|
|
732
|
+
`).join('');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function escapeHtml(value) {
|
|
736
|
+
return String(value == null ? '' : value)
|
|
737
|
+
.replace(/&/g, '&')
|
|
738
|
+
.replace(/</g, '<')
|
|
739
|
+
.replace(/>/g, '>')
|
|
740
|
+
.replace(/"/g, '"')
|
|
741
|
+
.replace(/'/g, ''');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function escapeAttr(value) {
|
|
745
|
+
return escapeHtml(value);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
$('refresh').addEventListener('click', refresh);
|
|
749
|
+
$('apply').addEventListener('click', applySelection);
|
|
750
|
+
$('confirm').addEventListener('click', confirmWorkflow);
|
|
751
|
+
refresh().catch((e) => { $('result').textContent = e.stack || e.message; });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function renderCss() {
|
|
755
|
+
return [
|
|
756
|
+
':root { color-scheme: light; --ink:#162126; --muted:#62737c; --line:#d7e0e5; --panel:#f7fafb; --accent:#12695f; --danger:#9f3d20; }',
|
|
757
|
+
'* { box-sizing: border-box; }',
|
|
758
|
+
'body { margin:0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color:var(--ink); background:#eef3f5; }',
|
|
759
|
+
'.page { width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:28px 0 44px; }',
|
|
760
|
+
'.topbar { display:flex; justify-content:space-between; align-items:flex-end; gap:18px; padding-bottom:16px; }',
|
|
761
|
+
'h1,h2,h3,p { margin:0; }',
|
|
762
|
+
'h1 { font-size:28px; line-height:1.15; }',
|
|
763
|
+
'h2 { font-size:18px; }',
|
|
764
|
+
'h3 { font-size:14px; color:var(--muted); }',
|
|
765
|
+
'p, small { color:var(--muted); }',
|
|
766
|
+
'.top-actions { display:flex; flex-wrap:wrap; gap:8px; }',
|
|
767
|
+
'button { border:0; border-radius:6px; padding:8px 12px; background:var(--accent); color:white; font-weight:700; cursor:pointer; }',
|
|
768
|
+
'button.danger-button { background:var(--danger); }',
|
|
769
|
+
'button:active { transform: translateY(1px); }',
|
|
770
|
+
'.band { margin-top:16px; border:1px solid var(--line); border-radius:8px; background:white; padding:16px; }',
|
|
771
|
+
'.section-head { display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:12px; }',
|
|
772
|
+
'code, pre { max-width:100%; overflow:auto; border:1px solid var(--line); border-radius:6px; background:var(--panel); }',
|
|
773
|
+
'code { padding:5px 7px; font-size:12px; overflow-wrap:anywhere; }',
|
|
774
|
+
'pre { min-height:72px; padding:12px; font-size:12px; line-height:1.45; }',
|
|
775
|
+
'.groups { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:12px; }',
|
|
776
|
+
'.insight-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:12px; }',
|
|
777
|
+
'.insight-card { display:grid; gap:10px; border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--panel); }',
|
|
778
|
+
'.insight-row { display:grid; grid-template-columns:92px minmax(0, 1fr); gap:8px; align-items:start; }',
|
|
779
|
+
'.insight-row span { display:flex; flex-wrap:wrap; gap:6px; min-width:0; }',
|
|
780
|
+
'.group { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px; }',
|
|
781
|
+
'ol { list-style:none; margin:0; padding:0; }',
|
|
782
|
+
'li { min-height:96px; display:grid; gap:6px; align-content:start; border-top:1px solid var(--line); padding:10px 0; }',
|
|
783
|
+
'li:first-child { border-top:0; }',
|
|
784
|
+
'label { display:flex; align-items:center; gap:8px; font-weight:700; }',
|
|
785
|
+
'input { width:16px; height:16px; accent-color:var(--accent); }',
|
|
786
|
+
'li p { color:var(--danger); font-size:12px; }',
|
|
787
|
+
'.timeline { display:grid; grid-template-columns:repeat(8, minmax(96px, 1fr)); gap:8px; overflow-x:auto; }',
|
|
788
|
+
'.step { min-width:96px; display:grid; gap:5px; border:1px solid var(--line); border-radius:8px; padding:10px; background:var(--panel); }',
|
|
789
|
+
'.step span { display:grid; place-items:center; width:22px; height:22px; border-radius:999px; background:var(--accent); color:white; font-size:12px; font-weight:700; }',
|
|
790
|
+
'.decision-card { display:grid; gap:8px; margin-top:12px; border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--panel); }',
|
|
791
|
+
'.decision-card.policy { border-color:#e3b16f; background:#fffaf3; }',
|
|
792
|
+
'.decision-card.ok { border-color:#95c9bf; background:#f5fbf9; }',
|
|
793
|
+
'.decision-card.danger { border-color:#e0a28f; background:#fff6f3; }',
|
|
794
|
+
'.primary-panel { display:flex; justify-content:space-between; gap:12px; align-items:center; margin:12px 0; border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--panel); }',
|
|
795
|
+
'.primary-panel > div:first-child { display:grid; gap:4px; }',
|
|
796
|
+
'.inline-actions { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }',
|
|
797
|
+
'@media (max-width: 860px) { .topbar { align-items:flex-start; flex-direction:column; } .groups, .insight-grid { grid-template-columns:1fr; } .timeline { grid-template-columns:repeat(4, minmax(96px, 1fr)); } }',
|
|
798
|
+
].join('\n');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function writeHtml(res, html) {
|
|
802
|
+
res.writeHead(200, {
|
|
803
|
+
'content-type': 'text/html; charset=utf-8',
|
|
804
|
+
'cache-control': 'no-store',
|
|
805
|
+
});
|
|
806
|
+
res.end(html);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function writeJson(res, status, body) {
|
|
810
|
+
res.writeHead(status, {
|
|
811
|
+
'content-type': 'application/json; charset=utf-8',
|
|
812
|
+
'cache-control': 'no-store',
|
|
813
|
+
});
|
|
814
|
+
res.end(JSON.stringify(body, null, 2));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function normalizePort(value, fallback = DEFAULT_PORT) {
|
|
818
|
+
if (value === undefined || value === null || value === true || value === '') return fallback;
|
|
819
|
+
const port = Number(value);
|
|
820
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`invalid port: ${value}`);
|
|
821
|
+
return port;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function escapeHtml(value) {
|
|
825
|
+
return String(value == null ? '' : value)
|
|
826
|
+
.replace(/&/g, '&')
|
|
827
|
+
.replace(/</g, '<')
|
|
828
|
+
.replace(/>/g, '>')
|
|
829
|
+
.replace(/"/g, '"')
|
|
830
|
+
.replace(/'/g, ''');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
module.exports = {
|
|
834
|
+
createWorkflowUiHost,
|
|
835
|
+
renderWorkflowUiHostHtml,
|
|
836
|
+
normalizePort,
|
|
837
|
+
};
|