@worca/app 1.2.0-rc.1 → 1.2.0-rc.3
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 -2
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +358 -19
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +262 -0
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +653 -22
- package/ui/public/ask-panel.mjs +4 -2
- package/ui/public/export-slug.mjs +30 -0
- package/ui/public/graph/composer.mjs +24 -3
- package/ui/public/graph/inspector.mjs +4 -1
- package/ui/public/index.html +127 -1
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +65 -5
- package/ui/server.mjs +305 -90
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// src/core/ui-instance.mjs
|
|
2
|
+
// The web UI is a singleton over the machine-wide store, so `worca ui` needs to
|
|
3
|
+
// know whether one is already up before it spawns another — and needs a way to
|
|
4
|
+
// stop the one that is. This module is the CLI side of that lifecycle; the server
|
|
5
|
+
// side (GET /api/health, POST /api/shutdown, the instance file) lives in
|
|
6
|
+
// ui/server.mjs.
|
|
7
|
+
//
|
|
8
|
+
// Discovery is the Jupyter runtime-file pattern: the server writes
|
|
9
|
+
// <worcaHome>/ui.json ({ pid, host, port, token, version, startedAt }) once it is
|
|
10
|
+
// listening and removes it on exit. The file is a HINT, never the truth — a
|
|
11
|
+
// crashed server leaves it behind, so every decision re-probes the port:
|
|
12
|
+
//
|
|
13
|
+
// probeUi({ port }) -> { state: 'worca', info } a Worca UI answered /api/health
|
|
14
|
+
// -> { state: 'busy' } something else owns the port
|
|
15
|
+
// -> { state: 'free' } nothing is listening
|
|
16
|
+
//
|
|
17
|
+
// Stopping goes through POST /api/shutdown with the file's bearer token so the
|
|
18
|
+
// server runs its graceful path (chat channel workers die cleanly) on every
|
|
19
|
+
// platform — a bare signal is a hard kill on Windows. The signal is the fallback
|
|
20
|
+
// when the token is unavailable (file missing, or a server too old to have one).
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
import fsp from 'node:fs/promises';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
import { worcaHome } from './projects.mjs';
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_UI_PORT = 4317;
|
|
32
|
+
export const DEFAULT_UI_HOST = '127.0.0.1';
|
|
33
|
+
/** What GET /api/health must report as `name` for the occupant to count as a Worca UI. */
|
|
34
|
+
export const UI_HEALTH_NAME = '@worca/app';
|
|
35
|
+
|
|
36
|
+
/** Absolute path of the instance file for the current worcaHome. */
|
|
37
|
+
export function uiInstanceFile() {
|
|
38
|
+
return join(worcaHome(), 'ui.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A fresh shutdown token (hex, 32 bytes of entropy). */
|
|
42
|
+
export function newUiToken() {
|
|
43
|
+
return randomBytes(32).toString('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Persist the running instance's coordinates. Atomic (tmp + rename) and 0600:
|
|
48
|
+
* the token authorizes a shutdown, so it must not be world-readable.
|
|
49
|
+
*/
|
|
50
|
+
export async function writeUiInstance({ pid, host, port, token, version, startedAt }) {
|
|
51
|
+
const file = uiInstanceFile();
|
|
52
|
+
await fsp.mkdir(join(file, '..'), { recursive: true });
|
|
53
|
+
const tmp = `${file}.${pid}.tmp`;
|
|
54
|
+
const body = JSON.stringify({ pid, host, port, token, version, startedAt }, null, 2) + '\n';
|
|
55
|
+
await fsp.writeFile(tmp, body, { mode: 0o600 });
|
|
56
|
+
await fsp.rename(tmp, file);
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The instance file's contents, or null when missing/corrupt/not an object. */
|
|
61
|
+
export function readUiInstance() {
|
|
62
|
+
try {
|
|
63
|
+
const data = JSON.parse(fs.readFileSync(uiInstanceFile(), 'utf8'));
|
|
64
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
65
|
+
const port = Number(data.port);
|
|
66
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
67
|
+
return { ...data, port, pid: Number(data.pid) || null };
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove the instance file. With `ifPid`, only when the file still belongs to
|
|
75
|
+
* that process — an old server exiting late must not delete the file a newer
|
|
76
|
+
* one just wrote. Synchronous so it is usable from a process 'exit' handler.
|
|
77
|
+
*/
|
|
78
|
+
export function removeUiInstance({ ifPid } = {}) {
|
|
79
|
+
const file = uiInstanceFile();
|
|
80
|
+
try {
|
|
81
|
+
if (ifPid !== undefined) {
|
|
82
|
+
const cur = readUiInstance();
|
|
83
|
+
if (cur && cur.pid && cur.pid !== ifPid) return false;
|
|
84
|
+
}
|
|
85
|
+
fs.unlinkSync(file);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Host as it appears inside a URL (IPv6 literals need brackets). */
|
|
93
|
+
export function urlHost(host) {
|
|
94
|
+
if (!host) return 'localhost';
|
|
95
|
+
if (host === '127.0.0.1' || host === '::1' || host === '[::1]' || host === 'localhost') return 'localhost';
|
|
96
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The URL a browser should open for a UI bound to host:port. */
|
|
100
|
+
export function uiUrl({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT } = {}) {
|
|
101
|
+
return `http://${urlHost(host)}:${port}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Host to CONNECT to (bind-any addresses are not dialable). */
|
|
105
|
+
function dialHost(host) {
|
|
106
|
+
if (!host || host === '0.0.0.0' || host === '::') return DEFAULT_UI_HOST;
|
|
107
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every error code nested in a request failure (Node wraps them in `cause`, sometimes an AggregateError). */
|
|
111
|
+
function errorCodes(err) {
|
|
112
|
+
const out = new Set();
|
|
113
|
+
const walk = (e, depth) => {
|
|
114
|
+
if (!e || depth > 4) return;
|
|
115
|
+
if (typeof e.code === 'string') out.add(e.code);
|
|
116
|
+
if (e.cause) walk(e.cause, depth + 1);
|
|
117
|
+
if (Array.isArray(e.errors)) for (const inner of e.errors) walk(inner, depth + 1);
|
|
118
|
+
};
|
|
119
|
+
walk(err, 0);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One-shot HTTP call on a throwaway connection. NOT fetch(): the lifecycle verbs
|
|
125
|
+
* call process.exit() right after a probe, and undici's pooled keep-alive socket is
|
|
126
|
+
* still closing at that moment — on Windows libuv asserts on it
|
|
127
|
+
* (`!(handle->flags & UV_HANDLE_CLOSING)`, src/win/async.c) and the CLI dies with
|
|
128
|
+
* 0xC0000409 instead of its exit code, after printing the right message. A bare
|
|
129
|
+
* `agent: false` + `Connection: close` request leaves nothing behind to tear down.
|
|
130
|
+
* Rejects with the socket error (its `code` intact, e.g. ECONNREFUSED) or the
|
|
131
|
+
* signal's AbortError.
|
|
132
|
+
*/
|
|
133
|
+
function request(url, { method = 'GET', headers = {}, signal } = {}) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const req = http.request(url, { method, agent: false, signal, headers: { ...headers, connection: 'close' } }, (res) => {
|
|
136
|
+
const chunks = [];
|
|
137
|
+
res.on('data', (c) => chunks.push(c));
|
|
138
|
+
res.on('error', reject);
|
|
139
|
+
res.on('end', () => resolve({
|
|
140
|
+
status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
141
|
+
text: Buffer.concat(chunks).toString('utf8'),
|
|
142
|
+
}));
|
|
143
|
+
});
|
|
144
|
+
req.on('error', reject);
|
|
145
|
+
req.end();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** GET a JSON object from the UI, or null (non-2xx, non-JSON, non-object). Network errors propagate. */
|
|
150
|
+
async function getJson(url, signal) {
|
|
151
|
+
const res = await request(url, { signal, headers: { accept: 'application/json' } });
|
|
152
|
+
if (!res.ok) return null;
|
|
153
|
+
try {
|
|
154
|
+
const data = JSON.parse(res.text);
|
|
155
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Ask host:port whether a Worca UI is listening there.
|
|
163
|
+
*
|
|
164
|
+
* @returns {Promise<{state:'worca', info:object} | {state:'busy'} | {state:'free'}>}
|
|
165
|
+
* 'busy' covers every occupant that is not a Worca UI: a non-JSON answer, a
|
|
166
|
+
* different `name`, a hang (timeout) or a reset. Only a clean connection
|
|
167
|
+
* refusal is 'free'. A Worca UI from before /api/health existed is recognised
|
|
168
|
+
* by its settings route and reported with `info.legacy = true` (no pid, no
|
|
169
|
+
* token — it cannot be stopped from here, only from its own terminal).
|
|
170
|
+
*/
|
|
171
|
+
export async function probeUi({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT, timeoutMs = 1500 } = {}) {
|
|
172
|
+
const ctl = new AbortController();
|
|
173
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
174
|
+
const base = `http://${dialHost(host)}:${port}`;
|
|
175
|
+
try {
|
|
176
|
+
const info = await getJson(`${base}/api/health`, ctl.signal);
|
|
177
|
+
if (info) return info.name === UI_HEALTH_NAME ? { state: 'worca', info } : { state: 'busy' };
|
|
178
|
+
const legacy = await getJson(`${base}/api/settings`, ctl.signal);
|
|
179
|
+
if (legacy && typeof legacy.projectsRootDefault === 'string' && 'askMaxTurns' in legacy) {
|
|
180
|
+
return { state: 'worca', info: { name: UI_HEALTH_NAME, legacy: true } };
|
|
181
|
+
}
|
|
182
|
+
return { state: 'busy' };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const codes = errorCodes(err);
|
|
185
|
+
if (codes.has('ECONNREFUSED')) return { state: 'free' };
|
|
186
|
+
return { state: 'busy' };
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
193
|
+
|
|
194
|
+
/** Poll until probeUi reports `state` (or any of `states`), or give up after timeoutMs. */
|
|
195
|
+
export async function waitForUiState({ host, port, states, timeoutMs = 10000, intervalMs = 100 } = {}) {
|
|
196
|
+
const want = new Set(Array.isArray(states) ? states : [states]);
|
|
197
|
+
const deadline = Date.now() + timeoutMs;
|
|
198
|
+
for (;;) {
|
|
199
|
+
const r = await probeUi({ host, port, timeoutMs: Math.min(1500, Math.max(200, deadline - Date.now())) });
|
|
200
|
+
if (want.has(r.state)) return r;
|
|
201
|
+
if (Date.now() >= deadline) return null;
|
|
202
|
+
await sleep(intervalMs);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** True when a process with that pid exists (signal 0 probes without killing). */
|
|
207
|
+
export function processAlive(pid) {
|
|
208
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
209
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err && err.code === 'EPERM'; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Stop the Worca UI on host:port, gracefully when possible.
|
|
214
|
+
*
|
|
215
|
+
* Order: (1) POST /api/shutdown with the instance file's token — the server
|
|
216
|
+
* answers 202 and exits through its signal path; (2) if that is refused or no
|
|
217
|
+
* token is known, SIGTERM the pid the health probe reported; (3) wait for the
|
|
218
|
+
* port to free up. Idempotent: a port with no Worca UI is `notRunning`, not an
|
|
219
|
+
* error. The instance file is cleaned up whenever the port ends up free.
|
|
220
|
+
*
|
|
221
|
+
* @returns {Promise<{status:'stopped', method:'request'|'signal', pid:number|null}
|
|
222
|
+
* |{status:'not-running'}
|
|
223
|
+
* |{status:'busy'}
|
|
224
|
+
* |{status:'failed', pid:number|null, reason:string}
|
|
225
|
+
* |{status:'timeout', pid:number|null}>}
|
|
226
|
+
*/
|
|
227
|
+
export async function stopUi({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT, token, timeoutMs = 10000 } = {}) {
|
|
228
|
+
const probe = await probeUi({ host, port });
|
|
229
|
+
if (probe.state === 'free') { removeUiInstance(); return { status: 'not-running' }; }
|
|
230
|
+
if (probe.state === 'busy') return { status: 'busy' };
|
|
231
|
+
if (probe.info.legacy) {
|
|
232
|
+
return { status: 'failed', pid: null, reason: 'it is an older Worca UI without shutdown support — stop it from its own terminal (Ctrl+C) and start again' };
|
|
233
|
+
}
|
|
234
|
+
const pid = Number(probe.info.pid) || null;
|
|
235
|
+
|
|
236
|
+
const file = readUiInstance();
|
|
237
|
+
const bearer = token || (file && file.port === port ? file.token : null);
|
|
238
|
+
let method = null;
|
|
239
|
+
|
|
240
|
+
if (bearer) {
|
|
241
|
+
try {
|
|
242
|
+
const res = await request(`http://${dialHost(host)}:${port}/api/shutdown`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { authorization: `Bearer ${bearer}`, accept: 'application/json' },
|
|
245
|
+
signal: AbortSignal.timeout(3000),
|
|
246
|
+
});
|
|
247
|
+
if (res.status === 202 || res.status === 200) method = 'request';
|
|
248
|
+
} catch {
|
|
249
|
+
// The server may drop the connection while exiting — the wait below decides.
|
|
250
|
+
method = 'request';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!method && pid) {
|
|
254
|
+
try { process.kill(pid, 'SIGTERM'); method = 'signal'; } catch { /* already gone, or not ours */ }
|
|
255
|
+
}
|
|
256
|
+
if (!method) return { status: 'failed', pid, reason: 'no shutdown token in the instance file and no pid to signal' };
|
|
257
|
+
|
|
258
|
+
const freed = await waitForUiState({ host, port, states: ['free'], timeoutMs });
|
|
259
|
+
if (!freed) return { status: 'timeout', pid };
|
|
260
|
+
removeUiInstance();
|
|
261
|
+
return { status: 'stopped', method, pid };
|
|
262
|
+
}
|