@clawling/clawchat-plugin-openclaw 2026.7.6-3 → 2026.7.8-1
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/src/config.js +3 -0
- package/dist/src/liveware-sample.js +538 -0
- package/dist/src/runtime.js +70 -3
- package/dist/src/skill-update.js +66 -5
- package/dist/src/storage.js +65 -0
- package/openclaw.plugin.json +2 -0
- package/package.json +2 -2
- package/skills/clawchat-liveware-sample/SKILL.md +54 -0
- package/skills/clawchat-set-greeting/SKILL.md +44 -0
- package/skills/manifest.json +69 -0
- package/src/config.ts +7 -0
- package/src/liveware-sample.ts +625 -0
- package/src/runtime.ts +70 -2
- package/src/skill-update.ts +68 -6
- package/src/storage.ts +121 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveware Sample auto-boot: download the zero-dependency demo web app from the
|
|
3
|
+
* official install-cli repo (GitHub raw + manifest sha256, same channel as
|
|
4
|
+
* skill hot-update), run it locally, bind a liveware tunnel and register it as
|
|
5
|
+
* a ClawChat app — deterministically, without going through the LLM.
|
|
6
|
+
*
|
|
7
|
+
* Spec: docs/superpowers/specs/2026-07-06-liveware-sample-autoboot-design.md
|
|
8
|
+
*/
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { execFile as nodeExecFile, spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { DEFAULT_SKILLS_REF, OFFICIAL_SKILLS_BASE, type FetchLike } from "./skill-update.ts";
|
|
14
|
+
import type { LivewareSampleRow, LivewareSampleUpsert } from "./storage.ts";
|
|
15
|
+
import type { LivewareLogger } from "./liveware-cli.ts";
|
|
16
|
+
|
|
17
|
+
export const LIVEWARES_TARGET = "openclaw";
|
|
18
|
+
export const LIVEWARE_SAMPLE_ID = "liveware-sample";
|
|
19
|
+
export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
|
|
20
|
+
export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Files the agent owns at runtime; preserved across sample upgrades. */
|
|
23
|
+
const USER_DATA_FILES = ["state.json", "events.jsonl"];
|
|
24
|
+
|
|
25
|
+
export interface LivewareSampleManifestFile {
|
|
26
|
+
path: string;
|
|
27
|
+
sha256: string;
|
|
28
|
+
bytes: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LivewareSampleManifestEntry {
|
|
32
|
+
version: string;
|
|
33
|
+
files: LivewareSampleManifestFile[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function livewaresBase(base: string | undefined, ref: string): string {
|
|
37
|
+
return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/livewares`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function livewaresManifestUrl(ref: string = DEFAULT_SKILLS_REF, base?: string): string {
|
|
41
|
+
return `${livewaresBase(base, ref)}/manifest.json`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function livewareFileUrl(
|
|
45
|
+
filePath: string,
|
|
46
|
+
ref: string = DEFAULT_SKILLS_REF,
|
|
47
|
+
base?: string,
|
|
48
|
+
): string {
|
|
49
|
+
return `${livewaresBase(base, ref)}/${filePath.replace(/^\/+/, "")}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseLivewaresManifest(raw: string): LivewareSampleManifestEntry {
|
|
53
|
+
const parsed = JSON.parse(raw) as {
|
|
54
|
+
schema?: unknown;
|
|
55
|
+
livewares?: Record<string, Record<string, unknown>>;
|
|
56
|
+
};
|
|
57
|
+
const entry = parsed.livewares?.[LIVEWARES_TARGET]?.[LIVEWARE_SAMPLE_ID] as
|
|
58
|
+
| { version?: unknown; files?: unknown }
|
|
59
|
+
| undefined;
|
|
60
|
+
if (!entry) {
|
|
61
|
+
throw new Error(`livewares manifest missing ${LIVEWARES_TARGET}/${LIVEWARE_SAMPLE_ID}`);
|
|
62
|
+
}
|
|
63
|
+
const version = typeof entry.version === "string" ? entry.version.trim() : "";
|
|
64
|
+
if (!version) throw new Error("livewares manifest entry missing version");
|
|
65
|
+
if (!Array.isArray(entry.files) || entry.files.length === 0) {
|
|
66
|
+
throw new Error("livewares manifest entry missing files");
|
|
67
|
+
}
|
|
68
|
+
const files = entry.files.map((f, i) => {
|
|
69
|
+
const v = f as Record<string, unknown>;
|
|
70
|
+
const p = typeof v.path === "string" ? v.path.trim() : "";
|
|
71
|
+
const sha256 = typeof v.sha256 === "string" ? v.sha256.trim().toLowerCase() : "";
|
|
72
|
+
const bytes = typeof v.bytes === "number" ? v.bytes : NaN;
|
|
73
|
+
if (!p || p.includes("..")) throw new Error(`livewares manifest file[${i}] bad path`);
|
|
74
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`livewares manifest file[${i}] bad sha256`);
|
|
75
|
+
if (!Number.isFinite(bytes) || bytes < 0 || bytes > MAX_SAMPLE_FILE_BYTES) {
|
|
76
|
+
throw new Error(`livewares manifest file[${i}] bad bytes`);
|
|
77
|
+
}
|
|
78
|
+
return { path: p, sha256, bytes };
|
|
79
|
+
});
|
|
80
|
+
return { version, files };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchText(fetchFn: FetchLike, url: string): Promise<string> {
|
|
84
|
+
const res = await fetchFn(url);
|
|
85
|
+
if (!res.ok) throw new Error(`fetch ${url} failed: HTTP ${res.status}`);
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
if (Buffer.byteLength(text, "utf8") > MAX_SAMPLE_FILE_BYTES) {
|
|
88
|
+
throw new Error(`fetch ${url} exceeds ${MAX_SAMPLE_FILE_BYTES} bytes`);
|
|
89
|
+
}
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Download + verify + atomically install the sample under `<sampleRoot>/app`.
|
|
95
|
+
* Preserves USER_DATA_FILES from an existing install. Throws on any failure;
|
|
96
|
+
* never leaves a partially-written `app/` dir behind.
|
|
97
|
+
*/
|
|
98
|
+
export async function downloadLivewareSample(opts: {
|
|
99
|
+
fetchFn: FetchLike;
|
|
100
|
+
sampleRoot: string;
|
|
101
|
+
ref?: string;
|
|
102
|
+
base?: string;
|
|
103
|
+
}): Promise<{ version: string; appDir: string }> {
|
|
104
|
+
const ref = opts.ref ?? DEFAULT_SKILLS_REF;
|
|
105
|
+
const entry = parseLivewaresManifest(
|
|
106
|
+
await fetchText(opts.fetchFn, livewaresManifestUrl(ref, opts.base)),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const appDir = path.join(opts.sampleRoot, "app");
|
|
110
|
+
const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
|
|
111
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
112
|
+
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
113
|
+
try {
|
|
114
|
+
for (const file of entry.files) {
|
|
115
|
+
const text = await fetchText(opts.fetchFn, livewareFileUrl(file.path, ref, opts.base));
|
|
116
|
+
const buf = Buffer.from(text, "utf8");
|
|
117
|
+
const actual = crypto.createHash("sha256").update(buf).digest("hex");
|
|
118
|
+
if (actual !== file.sha256) {
|
|
119
|
+
throw new Error(`sha256 mismatch for ${file.path}: expected ${file.sha256} got ${actual}`);
|
|
120
|
+
}
|
|
121
|
+
await fs.promises.writeFile(path.join(tmpDir, path.basename(file.path)), buf);
|
|
122
|
+
}
|
|
123
|
+
// Preserve agent/user-owned data files from a previous install.
|
|
124
|
+
for (const name of USER_DATA_FILES) {
|
|
125
|
+
const prev = path.join(appDir, name);
|
|
126
|
+
if (fs.existsSync(prev)) {
|
|
127
|
+
await fs.promises.copyFile(prev, path.join(tmpDir, name));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await fs.promises.rm(appDir, { recursive: true, force: true });
|
|
131
|
+
await fs.promises.rename(tmpDir, appDir);
|
|
132
|
+
return { version: entry.version, appDir };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type SpawnLike = typeof nodeSpawn;
|
|
140
|
+
export type ExecFileLike = typeof nodeExecFile;
|
|
141
|
+
|
|
142
|
+
const SERVER_START_TIMEOUT_MS = 10_000;
|
|
143
|
+
const TUNNEL_START_TIMEOUT_MS = 30_000;
|
|
144
|
+
const CLI_TIMEOUT_MS = 30_000;
|
|
145
|
+
|
|
146
|
+
/** Wait for a line matching `match` on the child's stdout/stderr, else kill+reject. */
|
|
147
|
+
function waitForOutput(
|
|
148
|
+
child: ChildProcess,
|
|
149
|
+
match: (accumulated: string) => string | null,
|
|
150
|
+
timeoutMs: number,
|
|
151
|
+
label: string,
|
|
152
|
+
): Promise<string> {
|
|
153
|
+
return new Promise<string>((resolve, reject) => {
|
|
154
|
+
let acc = "";
|
|
155
|
+
let done = false;
|
|
156
|
+
const finish = (err: Error | null, value?: string) => {
|
|
157
|
+
if (done) return;
|
|
158
|
+
done = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
child.stdout?.removeListener("data", onData);
|
|
161
|
+
child.stderr?.removeListener("data", onData);
|
|
162
|
+
child.removeListener("exit", onExit);
|
|
163
|
+
if (err) {
|
|
164
|
+
try { child.kill("SIGTERM"); } catch { /* already dead */ }
|
|
165
|
+
reject(err);
|
|
166
|
+
} else {
|
|
167
|
+
resolve(value as string);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const timer = setTimeout(() => finish(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
171
|
+
const onData = (chunk: Buffer) => {
|
|
172
|
+
acc += chunk.toString();
|
|
173
|
+
const hit = match(acc);
|
|
174
|
+
if (hit != null) finish(null, hit);
|
|
175
|
+
};
|
|
176
|
+
const onExit = (code: number | null) => finish(new Error(`${label} exited early (code ${code})`));
|
|
177
|
+
child.stdout?.on("data", onData);
|
|
178
|
+
child.stderr?.on("data", onData);
|
|
179
|
+
child.on("exit", onExit);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function startSampleServer(opts: {
|
|
184
|
+
appDir: string;
|
|
185
|
+
port: number;
|
|
186
|
+
spawnFn?: SpawnLike;
|
|
187
|
+
timeoutMs?: number;
|
|
188
|
+
}): Promise<{ child: ChildProcess; port: number }> {
|
|
189
|
+
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
|
190
|
+
const child = spawnFn(
|
|
191
|
+
process.execPath,
|
|
192
|
+
[path.join(opts.appDir, "server.mjs"), "--dir", opts.appDir, "--port", String(opts.port)],
|
|
193
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
194
|
+
);
|
|
195
|
+
const line = await waitForOutput(
|
|
196
|
+
child,
|
|
197
|
+
(acc) => acc.split("\n").find((l) => l.trim().startsWith('{"port"')) ?? null,
|
|
198
|
+
opts.timeoutMs ?? SERVER_START_TIMEOUT_MS,
|
|
199
|
+
"liveware-sample server start",
|
|
200
|
+
);
|
|
201
|
+
return { child, port: (JSON.parse(line) as { port: number }).port };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const URL_RE = /https?:\/\/[^\s"']+/g;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Extract the public URL from `liveware tunnel bind` output. The real CLI prints
|
|
208
|
+
* an aligned table whose `domain` row carries the public host (no scheme), while
|
|
209
|
+
* the only literal URL in the output is the LOCAL `upstreamUrl` (http://127…).
|
|
210
|
+
* So prefer the `domain` field (→ `https://<domain>`); fall back to the first
|
|
211
|
+
* non-local http(s) URL for other output shapes. Returns null until a full
|
|
212
|
+
* `domain` line (trailing newline) has arrived, so streamed/partial chunks are
|
|
213
|
+
* never parsed into a truncated host.
|
|
214
|
+
*/
|
|
215
|
+
export function parseTunnelPublicUrl(output: string): string | null {
|
|
216
|
+
const text = output ?? "";
|
|
217
|
+
const dom = text.match(
|
|
218
|
+
/(?:^|\n)[ \t]*domain\b[ \t:=]+([A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])[ \t]*\r?\n/i,
|
|
219
|
+
);
|
|
220
|
+
if (dom?.[1]) return `https://${dom[1]}`;
|
|
221
|
+
for (const u of text.match(URL_RE) ?? []) {
|
|
222
|
+
if (!/^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])(?:[:/]|$)/i.test(u)) return u;
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function startTunnel(opts: {
|
|
228
|
+
livewarePath: string;
|
|
229
|
+
appId: string;
|
|
230
|
+
port: number;
|
|
231
|
+
spawnFn?: SpawnLike;
|
|
232
|
+
timeoutMs?: number;
|
|
233
|
+
}): Promise<{ child: ChildProcess; publicUrl: string }> {
|
|
234
|
+
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
|
235
|
+
const child = spawnFn(
|
|
236
|
+
opts.livewarePath,
|
|
237
|
+
["tunnel", "bind", opts.appId, `http://127.0.0.1:${opts.port}`],
|
|
238
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
239
|
+
);
|
|
240
|
+
const url = await waitForOutput(
|
|
241
|
+
child,
|
|
242
|
+
(acc) => parseTunnelPublicUrl(acc),
|
|
243
|
+
opts.timeoutMs ?? TUNNEL_START_TIMEOUT_MS,
|
|
244
|
+
"liveware tunnel bind",
|
|
245
|
+
);
|
|
246
|
+
return { child, publicUrl: url };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function livewareLogin(opts: {
|
|
250
|
+
livewarePath: string;
|
|
251
|
+
token: string;
|
|
252
|
+
execFileFn?: ExecFileLike;
|
|
253
|
+
}): Promise<void> {
|
|
254
|
+
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
255
|
+
await new Promise<void>((resolve, reject) => {
|
|
256
|
+
execFileFn(
|
|
257
|
+
opts.livewarePath,
|
|
258
|
+
["login", "--access-token", opts.token],
|
|
259
|
+
{ timeout: CLI_TIMEOUT_MS },
|
|
260
|
+
(err, stdout, stderr) => {
|
|
261
|
+
if (!err) { resolve(); return; }
|
|
262
|
+
const scrub = (s: string) => (opts.token ? String(s).split(opts.token).join("***") : String(s));
|
|
263
|
+
reject(new Error(`liveware login failed: ${scrub(stderr || stdout || String(err))}`.trim()));
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse the app id out of `liveware app create` output. Tolerant of JSON or
|
|
271
|
+
* key/value text. Verified against the real CLI in the final manual task —
|
|
272
|
+
* adjust here if the real output shape differs.
|
|
273
|
+
*/
|
|
274
|
+
export function parseAppCreateOutput(stdout: string): string | null {
|
|
275
|
+
const text = (stdout ?? "").trim();
|
|
276
|
+
if (!text) return null;
|
|
277
|
+
// 1. JSON output: find the first string-valued id-like key, depth-first.
|
|
278
|
+
try {
|
|
279
|
+
const found = findIdDeep(JSON.parse(text));
|
|
280
|
+
if (found) return found;
|
|
281
|
+
} catch { /* not JSON */ }
|
|
282
|
+
// 2. Real `liveware app create` prints an aligned table row `appId <id>`
|
|
283
|
+
// (whitespace-separated, no colon); also tolerate `app id: xxx` / `AppID=xxx`.
|
|
284
|
+
const kv = text.match(/app[ _-]?id\b\s*[:=]?\s*"?([A-Za-z0-9][A-Za-z0-9_-]*)"?/i)
|
|
285
|
+
?? text.match(/\bid\s*[:=]\s*"?([A-Za-z0-9][A-Za-z0-9_-]*)"?/i);
|
|
286
|
+
if (kv?.[1]) return kv[1];
|
|
287
|
+
// 3. A lone id-looking line (≥6 chars, single token).
|
|
288
|
+
for (const line of text.split("\n")) {
|
|
289
|
+
const t = line.trim();
|
|
290
|
+
if (/^[A-Za-z0-9][A-Za-z0-9_-]{5,}$/.test(t)) return t;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function findIdDeep(value: unknown): string | null {
|
|
296
|
+
if (!value || typeof value !== "object") return null;
|
|
297
|
+
const obj = value as Record<string, unknown>;
|
|
298
|
+
for (const key of ["app_id", "appId", "id"]) {
|
|
299
|
+
if (typeof obj[key] === "string" && (obj[key] as string).trim()) {
|
|
300
|
+
return (obj[key] as string).trim();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const v of Object.values(obj)) {
|
|
304
|
+
const found = findIdDeep(v);
|
|
305
|
+
if (found) return found;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function livewareAppCreate(opts: {
|
|
311
|
+
livewarePath: string;
|
|
312
|
+
name: string;
|
|
313
|
+
execFileFn?: ExecFileLike;
|
|
314
|
+
}): Promise<string> {
|
|
315
|
+
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
316
|
+
const stdout = await new Promise<string>((resolve, reject) => {
|
|
317
|
+
execFileFn(
|
|
318
|
+
opts.livewarePath,
|
|
319
|
+
["app", "create", opts.name],
|
|
320
|
+
{ timeout: CLI_TIMEOUT_MS },
|
|
321
|
+
(err, out, errOut) => {
|
|
322
|
+
if (err) { reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim())); return; }
|
|
323
|
+
resolve(String(out ?? ""));
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
const appId = parseAppCreateOutput(stdout);
|
|
328
|
+
if (!appId) throw new Error(`liveware app create: cannot parse app id from output: ${stdout.slice(0, 500)}`);
|
|
329
|
+
return appId;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export type LivewareSampleStoreLike = {
|
|
333
|
+
getLivewareSample(input: { platform: string; accountId: string }): LivewareSampleRow | null;
|
|
334
|
+
upsertLivewareSample(input: LivewareSampleUpsert): void;
|
|
335
|
+
updateLivewareSampleStatus(input: {
|
|
336
|
+
platform: string;
|
|
337
|
+
accountId: string;
|
|
338
|
+
status: "active" | "failed" | "disabled";
|
|
339
|
+
lastError?: string | null;
|
|
340
|
+
}): void;
|
|
341
|
+
markLivewareSampleIntroSent(input: { platform: string; accountId: string }): void;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export type LivewareSampleSupervisorDeps = {
|
|
345
|
+
platform: string;
|
|
346
|
+
accountId: string;
|
|
347
|
+
enabled: boolean;
|
|
348
|
+
store: LivewareSampleStoreLike;
|
|
349
|
+
sampleRoot: string;
|
|
350
|
+
resolveToken: () => string;
|
|
351
|
+
resolveLivewarePath: () => string | null;
|
|
352
|
+
/** Settles when the liveware CLI self-download has finished (either way).
|
|
353
|
+
* start() awaits it before the CLI gate so a first boot doesn't lose the
|
|
354
|
+
* race against ensureLivewareCli and silently skip forever. */
|
|
355
|
+
cliReady?: Promise<void>;
|
|
356
|
+
listApps: () => Promise<{ apps: { app_id?: string }[] }>;
|
|
357
|
+
registerApp: (p: { name: string; appId: string; url: string }) => Promise<unknown>;
|
|
358
|
+
notifyOwner: (text: string) => Promise<boolean>;
|
|
359
|
+
fetchFn: FetchLike;
|
|
360
|
+
ref?: string;
|
|
361
|
+
spawnFn?: SpawnLike;
|
|
362
|
+
execFileFn?: ExecFileLike;
|
|
363
|
+
log?: LivewareLogger;
|
|
364
|
+
setTimeoutFn?: typeof setTimeout;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export const LIVEWARE_SAMPLE_INTRO_TEXT =
|
|
368
|
+
"我给你安装了一个 liveware 演示应用「Liveware Sample」,它已经出现在我们的聊天里。" +
|
|
369
|
+
"点开它看看,然后试试对我说:把标题改成 Hello Liveware。" +
|
|
370
|
+
"你在页面上点的按钮、提交的留言我也能看到,随时问我。";
|
|
371
|
+
|
|
372
|
+
const DEFAULT_SAMPLE_PORT = 43110;
|
|
373
|
+
const RESTART_WINDOW_MS = 30 * 60 * 1000;
|
|
374
|
+
const MAX_RESTARTS_PER_WINDOW = 5;
|
|
375
|
+
const INTRO_RETRY_DELAY_MS = 30_000;
|
|
376
|
+
const INTRO_MAX_TRIES = 20;
|
|
377
|
+
|
|
378
|
+
export class LivewareSampleSupervisor {
|
|
379
|
+
private readonly deps: LivewareSampleSupervisorDeps;
|
|
380
|
+
private serverChild: ChildProcess | null = null;
|
|
381
|
+
private tunnelChild: ChildProcess | null = null;
|
|
382
|
+
private stopped = false;
|
|
383
|
+
private restartTimes: number[] = [];
|
|
384
|
+
private timers = new Set<ReturnType<typeof setTimeout>>();
|
|
385
|
+
/** Bumped on every (re)launch and on killChildren: a child's exit handler
|
|
386
|
+
* only counts if its generation is still current — killing the sibling
|
|
387
|
+
* child during a restart must NOT double-count as a second crash. */
|
|
388
|
+
private generation = 0;
|
|
389
|
+
|
|
390
|
+
constructor(deps: LivewareSampleSupervisorDeps) {
|
|
391
|
+
this.deps = deps;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Entry point. Never throws; every failure is logged + persisted only. */
|
|
395
|
+
async start(): Promise<void> {
|
|
396
|
+
const { deps } = this;
|
|
397
|
+
try {
|
|
398
|
+
if (!deps.enabled) return;
|
|
399
|
+
// ensureLivewareCli downloads the CLI asynchronously on first boot;
|
|
400
|
+
// gate the whole flow on it or resolveLivewarePath() below races to null.
|
|
401
|
+
if (deps.cliReady) await deps.cliReady;
|
|
402
|
+
if (this.stopped) return;
|
|
403
|
+
const key = { platform: deps.platform, accountId: deps.accountId };
|
|
404
|
+
const row = deps.store.getLivewareSample(key);
|
|
405
|
+
if (row?.status === "disabled") return;
|
|
406
|
+
if (row) {
|
|
407
|
+
await this.relaunch(row);
|
|
408
|
+
} else {
|
|
409
|
+
await this.bootstrap();
|
|
410
|
+
}
|
|
411
|
+
} catch (err) {
|
|
412
|
+
// A mid-flow failure (e.g. registerApp rejected) may have left children
|
|
413
|
+
// running without a persisted row — never leak orphan processes.
|
|
414
|
+
this.killChildren();
|
|
415
|
+
deps.log?.warn?.(`clawchat-plugin-openclaw liveware-sample start failed: ${String(err)}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
stop(): void {
|
|
420
|
+
this.stopped = true;
|
|
421
|
+
for (const t of this.timers) clearTimeout(t);
|
|
422
|
+
this.timers.clear();
|
|
423
|
+
this.killChildren();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private killChildren(): void {
|
|
427
|
+
this.generation += 1; // invalidate pending exit handlers before killing
|
|
428
|
+
for (const child of [this.serverChild, this.tunnelChild]) {
|
|
429
|
+
try { child?.kill("SIGTERM"); } catch { /* already dead */ }
|
|
430
|
+
}
|
|
431
|
+
this.serverChild = null;
|
|
432
|
+
this.tunnelChild = null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private schedule(fn: () => void, delayMs: number): void {
|
|
436
|
+
if (this.stopped) return;
|
|
437
|
+
const setTimeoutFn = this.deps.setTimeoutFn ?? setTimeout;
|
|
438
|
+
const t = setTimeoutFn(() => { this.timers.delete(t); fn(); }, delayMs);
|
|
439
|
+
this.timers.add(t);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** True (after killing any spawned children) when stop() arrived mid-flight.
|
|
443
|
+
* bootstrap()/relaunch() must re-check after every await: a child spawned
|
|
444
|
+
* after stop() ran would otherwise never be killed. */
|
|
445
|
+
private bailIfStopped(): boolean {
|
|
446
|
+
if (!this.stopped) return false;
|
|
447
|
+
this.killChildren();
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async bootstrap(): Promise<void> {
|
|
452
|
+
const { deps } = this;
|
|
453
|
+
if (this.stopped) return;
|
|
454
|
+
const livewarePath = deps.resolveLivewarePath();
|
|
455
|
+
if (!livewarePath) { deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip"); return; }
|
|
456
|
+
const token = deps.resolveToken();
|
|
457
|
+
if (!token) { deps.log?.debug?.("liveware-sample: no token; skip"); return; }
|
|
458
|
+
let apps: { apps: { app_id?: string }[] };
|
|
459
|
+
try {
|
|
460
|
+
apps = await deps.listApps();
|
|
461
|
+
} catch (err) {
|
|
462
|
+
deps.log?.debug?.(`liveware-sample: listApps failed; skip bootstrap: ${String(err)}`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (this.bailIfStopped()) return;
|
|
466
|
+
if (apps.apps.length > 0) {
|
|
467
|
+
deps.log?.debug?.("liveware-sample: user already has liveware apps; skip");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const { version, appDir } = await downloadLivewareSample({
|
|
472
|
+
fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
|
|
473
|
+
});
|
|
474
|
+
if (this.bailIfStopped()) return;
|
|
475
|
+
const server = await startSampleServer({
|
|
476
|
+
appDir, port: DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
|
|
477
|
+
});
|
|
478
|
+
this.serverChild = server.child;
|
|
479
|
+
if (this.bailIfStopped()) return;
|
|
480
|
+
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
|
|
481
|
+
const appId = await livewareAppCreate({
|
|
482
|
+
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
|
|
483
|
+
});
|
|
484
|
+
const tunnel = await startTunnel({
|
|
485
|
+
livewarePath, appId, port: server.port, spawnFn: deps.spawnFn,
|
|
486
|
+
});
|
|
487
|
+
this.tunnelChild = tunnel.child;
|
|
488
|
+
if (this.bailIfStopped()) return;
|
|
489
|
+
await deps.registerApp({ name: LIVEWARE_SAMPLE_APP_NAME, appId, url: tunnel.publicUrl });
|
|
490
|
+
deps.store.upsertLivewareSample({
|
|
491
|
+
platform: deps.platform, accountId: deps.accountId,
|
|
492
|
+
appId, appName: LIVEWARE_SAMPLE_APP_NAME, port: server.port,
|
|
493
|
+
publicUrl: tunnel.publicUrl, sampleVersion: version, status: "active",
|
|
494
|
+
});
|
|
495
|
+
this.watchChildren();
|
|
496
|
+
await this.deliverIntro();
|
|
497
|
+
deps.log?.debug?.(`liveware-sample: bootstrap complete at ${tunnel.publicUrl}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private async relaunch(row: LivewareSampleRow): Promise<void> {
|
|
501
|
+
const { deps } = this;
|
|
502
|
+
if (this.stopped) return;
|
|
503
|
+
const livewarePath = deps.resolveLivewarePath();
|
|
504
|
+
if (!livewarePath) { deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip relaunch"); return; }
|
|
505
|
+
// The user deleting the app tile is an explicit opt-out — never reinstall.
|
|
506
|
+
try {
|
|
507
|
+
const apps = await deps.listApps();
|
|
508
|
+
if (!apps.apps.some((a) => a.app_id === row.app_id)) {
|
|
509
|
+
deps.store.updateLivewareSampleStatus({
|
|
510
|
+
platform: deps.platform, accountId: deps.accountId, status: "disabled",
|
|
511
|
+
lastError: "app removed by user",
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
} catch (err) {
|
|
516
|
+
deps.log?.debug?.(`liveware-sample: listApps failed; skip relaunch: ${String(err)}`);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (this.bailIfStopped()) return;
|
|
520
|
+
|
|
521
|
+
let version = row.sample_version;
|
|
522
|
+
let appDir = path.join(deps.sampleRoot, "app");
|
|
523
|
+
try {
|
|
524
|
+
const dl = await downloadLivewareSample({
|
|
525
|
+
fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
|
|
526
|
+
});
|
|
527
|
+
version = dl.version;
|
|
528
|
+
appDir = dl.appDir;
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (!fs.existsSync(path.join(appDir, "server.mjs"))) throw err; // no local copy either — genuine failure
|
|
531
|
+
deps.log?.debug?.(`liveware-sample: download failed, reusing local copy: ${String(err)}`);
|
|
532
|
+
}
|
|
533
|
+
if (this.bailIfStopped()) return;
|
|
534
|
+
const server = await startSampleServer({
|
|
535
|
+
appDir, port: row.port || DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
|
|
536
|
+
});
|
|
537
|
+
this.serverChild = server.child;
|
|
538
|
+
if (this.bailIfStopped()) return;
|
|
539
|
+
const tunnel = await startTunnel({
|
|
540
|
+
livewarePath, appId: row.app_id, port: server.port, spawnFn: deps.spawnFn,
|
|
541
|
+
});
|
|
542
|
+
this.tunnelChild = tunnel.child;
|
|
543
|
+
if (this.bailIfStopped()) return;
|
|
544
|
+
if (tunnel.publicUrl !== row.public_url) {
|
|
545
|
+
await deps.registerApp({
|
|
546
|
+
name: row.app_name, appId: row.app_id, url: tunnel.publicUrl,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
deps.store.upsertLivewareSample({
|
|
550
|
+
platform: deps.platform, accountId: deps.accountId,
|
|
551
|
+
appId: row.app_id, appName: row.app_name, port: server.port,
|
|
552
|
+
publicUrl: tunnel.publicUrl, sampleVersion: version, status: "active",
|
|
553
|
+
});
|
|
554
|
+
this.watchChildren();
|
|
555
|
+
if (row.intro_sent === 0) await this.deliverIntro();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Restart (bounded backoff) when either child dies while we are not stopping. */
|
|
559
|
+
private watchChildren(): void {
|
|
560
|
+
const gen = ++this.generation;
|
|
561
|
+
const onChildExit = (): void => {
|
|
562
|
+
if (this.stopped || gen !== this.generation) return;
|
|
563
|
+
this.killChildren();
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
this.restartTimes = this.restartTimes.filter((t) => now - t < RESTART_WINDOW_MS);
|
|
566
|
+
if (this.restartTimes.length >= MAX_RESTARTS_PER_WINDOW) {
|
|
567
|
+
this.deps.store.updateLivewareSampleStatus({
|
|
568
|
+
platform: this.deps.platform, accountId: this.deps.accountId,
|
|
569
|
+
status: "failed", lastError: "sample process crash-looping; restart cap reached",
|
|
570
|
+
});
|
|
571
|
+
this.deps.log?.warn?.("liveware-sample: restart cap reached; marked failed");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const n = this.restartTimes.length;
|
|
575
|
+
this.restartTimes.push(now);
|
|
576
|
+
const delay = Math.min(5_000 * 2 ** n, 60_000);
|
|
577
|
+
this.schedule(() => {
|
|
578
|
+
const row = this.deps.store.getLivewareSample({
|
|
579
|
+
platform: this.deps.platform, accountId: this.deps.accountId,
|
|
580
|
+
});
|
|
581
|
+
if (!row || row.status === "disabled") return;
|
|
582
|
+
void this.relaunch(row).catch((err) => {
|
|
583
|
+
// A partially-completed relaunch may have spawned children before
|
|
584
|
+
// failing — never leave them running unwatched.
|
|
585
|
+
this.killChildren();
|
|
586
|
+
this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
|
|
587
|
+
this.deps.store.updateLivewareSampleStatus({
|
|
588
|
+
platform: this.deps.platform, accountId: this.deps.accountId,
|
|
589
|
+
status: "failed", lastError: String(err),
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
}, delay);
|
|
593
|
+
};
|
|
594
|
+
for (const child of [this.serverChild, this.tunnelChild]) {
|
|
595
|
+
if (!child) continue;
|
|
596
|
+
child.once("exit", onChildExit);
|
|
597
|
+
// The child may have died during the awaits between spawn and here — its
|
|
598
|
+
// 'exit' event has already fired, so the once() above would never see it
|
|
599
|
+
// and the crash would be silently lost. Invoke the handler manually; the
|
|
600
|
+
// generation guard makes this idempotent with any late-arriving event.
|
|
601
|
+
if (child.exitCode != null || child.signalCode != null) onChildExit();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Deliver the intro chat message; retries while the WS client is not ready. */
|
|
606
|
+
private async deliverIntro(tryIndex = 0): Promise<void> {
|
|
607
|
+
if (this.stopped) return;
|
|
608
|
+
const { deps } = this;
|
|
609
|
+
let delivered = false;
|
|
610
|
+
try {
|
|
611
|
+
delivered = await deps.notifyOwner(LIVEWARE_SAMPLE_INTRO_TEXT);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
deps.log?.debug?.(`liveware-sample: intro send error: ${String(err)}`);
|
|
614
|
+
}
|
|
615
|
+
if (delivered) {
|
|
616
|
+
deps.store.markLivewareSampleIntroSent({ platform: deps.platform, accountId: deps.accountId });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (tryIndex + 1 >= INTRO_MAX_TRIES) {
|
|
620
|
+
deps.log?.debug?.("liveware-sample: intro not delivered; will retry next boot");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
this.schedule(() => { void this.deliverIntro(tryIndex + 1); }, INTRO_RETRY_DELAY_MS);
|
|
624
|
+
}
|
|
625
|
+
}
|