@flowriki/agent 0.1.0-beta.55 → 0.1.0-beta.57
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.
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
textIndependent,
|
|
69
69
|
validateActors,
|
|
70
70
|
webCloudOf
|
|
71
|
-
} from "./chunk-
|
|
71
|
+
} from "./chunk-O34WXNH6.js";
|
|
72
72
|
|
|
73
73
|
// src/engine/machine.ts
|
|
74
74
|
import fs from "fs";
|
|
@@ -76,7 +76,7 @@ import os from "os";
|
|
|
76
76
|
import path from "path";
|
|
77
77
|
import { createRequire } from "module";
|
|
78
78
|
import { spawnSync } from "child_process";
|
|
79
|
-
var AGENT_VERSION = "0.1.0-beta.
|
|
79
|
+
var AGENT_VERSION = "0.1.0-beta.57";
|
|
80
80
|
async function prepareBrowsers() {
|
|
81
81
|
if (browsersReady()) {
|
|
82
82
|
return { ok: true, already: true, message: "chromium is already installed" };
|
|
@@ -136,7 +136,7 @@ async function checkMachine() {
|
|
|
136
136
|
}
|
|
137
137
|
} catch {
|
|
138
138
|
}
|
|
139
|
-
const { mobileDoctor: mobileDoctor2 } = await import("./dist-
|
|
139
|
+
const { mobileDoctor: mobileDoctor2 } = await import("./dist-RDJIIBQV.js");
|
|
140
140
|
return {
|
|
141
141
|
agentVersion: AGENT_VERSION,
|
|
142
142
|
node: process.versions.node,
|
|
@@ -8857,7 +8857,7 @@ var AgentLink = class {
|
|
|
8857
8857
|
this.opts.onStatus?.(status2, detail);
|
|
8858
8858
|
}
|
|
8859
8859
|
async start() {
|
|
8860
|
-
const backoff = this.opts.backoff ?? [250, 500, 1e3, 2e3, 5e3];
|
|
8860
|
+
const backoff = this.opts.backoff ?? [250, 500, 1e3, 2e3, 5e3, 1e4, 2e4, 3e4];
|
|
8861
8861
|
const sleep2 = this.opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
8862
8862
|
let attempt = 0;
|
|
8863
8863
|
while (!this.stopped) {
|
|
@@ -8878,8 +8878,9 @@ var AgentLink = class {
|
|
|
8878
8878
|
socket.send(JSON.stringify(this.opts.agent.hello()));
|
|
8879
8879
|
this.unsubscribe = this.opts.agent.journal.subscribe(() => this.flush());
|
|
8880
8880
|
this.setStatus("connected");
|
|
8881
|
-
|
|
8881
|
+
const since = Date.now();
|
|
8882
8882
|
await closed;
|
|
8883
|
+
if (Date.now() - since > 1e4) attempt = 0;
|
|
8883
8884
|
this.unsubscribe?.();
|
|
8884
8885
|
this.unsubscribe = null;
|
|
8885
8886
|
this.socket = null;
|
|
@@ -9792,7 +9793,8 @@ function assistantHandlers({ deps }) {
|
|
|
9792
9793
|
const status2 = await assistant.status();
|
|
9793
9794
|
if (!status2.installed) return { ok: false, reason: "failed", detail: status2.detail ?? "the assistant is not installed on this machine" };
|
|
9794
9795
|
if (!status2.signedIn) return { ok: false, reason: "failed", detail: "the assistant is not signed in on this machine" };
|
|
9795
|
-
const
|
|
9796
|
+
const given = command.args["askId"];
|
|
9797
|
+
const askId = typeof given === "string" && /^[\w-]{8,100}$/.test(given) ? given : randomUUID();
|
|
9796
9798
|
const linked = foldersFor(project.id).filter((f) => f.exists).map((f) => f.path);
|
|
9797
9799
|
const mode = command.args["mode"] === "ask" ? "ask" : command.args["mode"] === "do" ? "do" : void 0;
|
|
9798
9800
|
const brief = typeof command.args["brief"] === "string" && command.args["brief"].trim() ? command.args["brief"].slice(0, 4e4) : void 0;
|
|
@@ -10263,9 +10265,10 @@ function eventsFromLine(askId, line) {
|
|
|
10263
10265
|
function createClaudeCli(options) {
|
|
10264
10266
|
const spawn2 = options.spawn ?? nodeSpawn;
|
|
10265
10267
|
const binary = options.binary ?? process.env["FLOWRIKI_CLAUDE_BIN"] ?? "claude";
|
|
10266
|
-
const maxAskMs = options.maxAskMs ??
|
|
10268
|
+
const maxAskMs = options.maxAskMs ?? 60 * 6e4;
|
|
10267
10269
|
const scratchRoot2 = options.scratchRoot ?? path42.join(os22.tmpdir(), "flowriki-claude");
|
|
10268
|
-
|
|
10270
|
+
const running = /* @__PURE__ */ new Map();
|
|
10271
|
+
const MAX_AT_ONCE = 3;
|
|
10269
10272
|
let cancelled = null;
|
|
10270
10273
|
function capture(args, timeoutMs = 15e3) {
|
|
10271
10274
|
return new Promise((resolve2) => {
|
|
@@ -10307,7 +10310,7 @@ function createClaudeCli(options) {
|
|
|
10307
10310
|
return { installed: true, version: version.out.trim().split(/\s+/)[0] ?? null, ...parsed };
|
|
10308
10311
|
},
|
|
10309
10312
|
connect(emit) {
|
|
10310
|
-
if (
|
|
10313
|
+
if (running.size > 0) return { started: false, detail: "The assistant is busy answering a question." };
|
|
10311
10314
|
let child;
|
|
10312
10315
|
try {
|
|
10313
10316
|
child = spawn2(binary, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -10337,7 +10340,7 @@ function createClaudeCli(options) {
|
|
|
10337
10340
|
return { started: true };
|
|
10338
10341
|
},
|
|
10339
10342
|
ask(ask) {
|
|
10340
|
-
if (
|
|
10343
|
+
if (running.size >= MAX_AT_ONCE) return { started: false, detail: `The assistant is already answering ${MAX_AT_ONCE} questions on this machine \u2014 wait for one to finish.` };
|
|
10341
10344
|
fs43.mkdirSync(scratchRoot2, { recursive: true, mode: 448 });
|
|
10342
10345
|
const dir = path42.join(scratchRoot2, `project-${ask.projectId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
|
|
10343
10346
|
fs43.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
@@ -10350,15 +10353,17 @@ function createClaudeCli(options) {
|
|
|
10350
10353
|
return launch(ask, dir, mcpConfigPath, ask.sessionId);
|
|
10351
10354
|
},
|
|
10352
10355
|
cancel(askId) {
|
|
10353
|
-
|
|
10356
|
+
const child = running.get(askId);
|
|
10357
|
+
if (!child) return false;
|
|
10354
10358
|
cancelled = askId;
|
|
10355
|
-
|
|
10356
|
-
const child = current.child;
|
|
10359
|
+
child.kill("SIGTERM");
|
|
10357
10360
|
setTimeout(() => child.kill("SIGKILL"), 3e3).unref?.();
|
|
10358
10361
|
return true;
|
|
10359
10362
|
},
|
|
10363
|
+
/** Full — no room for another question — rather than "something runs":
|
|
10364
|
+
* a second conversation is welcome while the first works. */
|
|
10360
10365
|
busy() {
|
|
10361
|
-
return
|
|
10366
|
+
return running.size >= MAX_AT_ONCE;
|
|
10362
10367
|
}
|
|
10363
10368
|
};
|
|
10364
10369
|
function launch(ask, dir, mcpConfigPath, sessionId) {
|
|
@@ -10382,7 +10387,7 @@ function createClaudeCli(options) {
|
|
|
10382
10387
|
} catch {
|
|
10383
10388
|
return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
|
|
10384
10389
|
}
|
|
10385
|
-
|
|
10390
|
+
running.set(ask.askId, child);
|
|
10386
10391
|
let buffer = "", stderr = "", sawDone = false, ended = false;
|
|
10387
10392
|
let silentFailure = false;
|
|
10388
10393
|
const handleLine = (line) => {
|
|
@@ -10400,7 +10405,7 @@ function createClaudeCli(options) {
|
|
|
10400
10405
|
ended = true;
|
|
10401
10406
|
clearTimeout(timer);
|
|
10402
10407
|
if (buffer.trim()) handleLine(buffer);
|
|
10403
|
-
|
|
10408
|
+
running.delete(ask.askId);
|
|
10404
10409
|
if (silentFailure && sessionId && /no conversation found/i.test(stderr) && cancelled !== ask.askId) {
|
|
10405
10410
|
const again = launch(ask, dir, mcpConfigPath, void 0);
|
|
10406
10411
|
if (again.started) return;
|
|
@@ -10867,6 +10872,7 @@ export {
|
|
|
10867
10872
|
executeStep,
|
|
10868
10873
|
replayCase,
|
|
10869
10874
|
fieldNames,
|
|
10875
|
+
flowrikiHome,
|
|
10870
10876
|
helperRunner,
|
|
10871
10877
|
resolveCheckouts,
|
|
10872
10878
|
start,
|
|
@@ -2568,8 +2568,15 @@ async function localCapabilities(target, udid, opts = {}) {
|
|
|
2568
2568
|
"appium:automationName": "UiAutomator2",
|
|
2569
2569
|
...common,
|
|
2570
2570
|
"appium:appPackage": app.id,
|
|
2571
|
-
...app.activity ? { "appium:appActivity": app.activity } : {},
|
|
2571
|
+
...app.activity ?? opts.activity ? { "appium:appActivity": app.activity ?? opts.activity } : {},
|
|
2572
2572
|
"appium:appWaitActivity": "*",
|
|
2573
|
+
// THE APP IS STARTED BY US, NOT BY THE DRIVER. A session opens, then the
|
|
2574
|
+
// app is closed and opened the way its icon does (activateApp), from
|
|
2575
|
+
// its first screen. The driver's own launch needs ONE launcher activity
|
|
2576
|
+
// and fails on a build that has several ("Intent matches multiple
|
|
2577
|
+
// activities") — a dev build with a debug launcher, typically — while
|
|
2578
|
+
// the icon's way never has to choose.
|
|
2579
|
+
"appium:autoLaunch": false,
|
|
2573
2580
|
// Its own ports: two phones on one Appium each need their own driver
|
|
2574
2581
|
// server and picture stream, or the second session steals the first's.
|
|
2575
2582
|
...opts.ports?.system ? { "appium:systemPort": opts.ports.system } : {},
|
|
@@ -3234,7 +3241,7 @@ var MobileSession = class _MobileSession {
|
|
|
3234
3241
|
if (target.device.kind === "cloud") {
|
|
3235
3242
|
const choice2 = target.device;
|
|
3236
3243
|
const account = target.mobile.cloud.find((c) => c.provider === choice2.provider);
|
|
3237
|
-
if (!account) throw new Error(`${CLOUD_PROVIDER_NAME[choice2.provider]} is not connected \u2014 add it in Settings \u2192
|
|
3244
|
+
if (!account) throw new Error(`${CLOUD_PROVIDER_NAME[choice2.provider]} is not connected \u2014 add it in Settings \u2192 Platforms.`);
|
|
3238
3245
|
const key = opts.resolveSecret?.(account.keyRef);
|
|
3239
3246
|
if (!key) throw new Error(`The ${CLOUD_PROVIDER_NAME[choice2.provider]} access key is not available to this run.`);
|
|
3240
3247
|
let appRef = null;
|
|
@@ -3268,7 +3275,8 @@ var MobileSession = class _MobileSession {
|
|
|
3268
3275
|
const buildFile = app.build ? await localBuild(app.build) : null;
|
|
3269
3276
|
say(`Opening the app on ${known.name}\u2026`);
|
|
3270
3277
|
const ports = target.platform === "android" ? { system: await freePort(), mjpeg: await freePort() } : { wda: await freePort(), mjpeg: await freePort() };
|
|
3271
|
-
const
|
|
3278
|
+
const activity = target.platform === "android" && !app.activity && !buildFile ? await launchActivityOf(udid, app.id) : null;
|
|
3279
|
+
const capabilities = await localCapabilities(target, udid, { buildFile, ports, ...activity ? { activity } : {} });
|
|
3272
3280
|
const driver = await WebDriverSession.create(endpoint, capabilities).catch((e) => {
|
|
3273
3281
|
const tail = appiumLog().filter((l) => /error/i.test(l)).slice(-2).join(" ");
|
|
3274
3282
|
throw new Error(explainStartFailure(e.message, target.platform) + (tail ? ` (${tail.slice(0, 300)})` : ""));
|
|
@@ -3455,7 +3463,7 @@ var MobileSession = class _MobileSession {
|
|
|
3455
3463
|
await this.terminateApp().catch(() => {
|
|
3456
3464
|
});
|
|
3457
3465
|
await this.driver.execute("mobile: clearApp", this.appArg()).catch((e) => {
|
|
3458
|
-
throw new Error(`This device cannot clear the app's data (${e.message}). Reinstall it, or choose "Start clean" in Settings \u2192
|
|
3466
|
+
throw new Error(`This device cannot clear the app's data (${e.message}). Reinstall it, or choose "Start clean" in Settings \u2192 Platforms.`);
|
|
3459
3467
|
});
|
|
3460
3468
|
await this.launchApp();
|
|
3461
3469
|
}
|
|
@@ -3522,6 +3530,17 @@ var MobileSession = class _MobileSession {
|
|
|
3522
3530
|
await this.driver.close();
|
|
3523
3531
|
}
|
|
3524
3532
|
};
|
|
3533
|
+
async function launchActivityOf(udid, appPackage) {
|
|
3534
|
+
const adb = adbPath();
|
|
3535
|
+
if (!adb) return null;
|
|
3536
|
+
try {
|
|
3537
|
+
const out = await run(adb, ["-s", udid, "shell", "cmd", "package", "query-activities", "--brief", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER", appPackage], 1e4, await toolEnv());
|
|
3538
|
+
const found = out.split("\n").map((l) => l.trim()).filter((l) => l.startsWith(`${appPackage}/`)).map((l) => l.slice(appPackage.length + 1));
|
|
3539
|
+
return found.length === 1 ? found[0] : null;
|
|
3540
|
+
} catch {
|
|
3541
|
+
return null;
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3525
3544
|
async function shrink(png, maxPx) {
|
|
3526
3545
|
if (process.platform !== "darwin") return png;
|
|
3527
3546
|
const dir = fs4.mkdtempSync(path4.join(os4.tmpdir(), "flowriki-shot-"));
|
|
@@ -3560,11 +3579,14 @@ function explainStartFailure(message, platform) {
|
|
|
3560
3579
|
return `Appium's ${platform === "android" ? "Android (uiautomator2)" : "iOS (xcuitest)"} driver is not installed \u2014 run \`flowriki-agent mobile setup\`.`;
|
|
3561
3580
|
}
|
|
3562
3581
|
if (/is not installed|does not exist or is not accessible|Activity name .* cannot be found|App with bundle identifier .* unknown/i.test(message)) {
|
|
3563
|
-
return "The app is not installed on that device. Install it, or add its build (.apk / .ipa / .app) in Settings \u2192
|
|
3582
|
+
return "The app is not installed on that device. Install it, or add its build (.apk / .ipa / .app) to the environment in Settings \u2192 Environments.";
|
|
3583
|
+
}
|
|
3584
|
+
if (/Intent matches multiple activities/i.test(message)) {
|
|
3585
|
+
return "The app has more than one launch screen, and the driver could not choose. Set the Android app's First screen (e.g. .MainActivity) on the environment in Settings \u2192 Environments.";
|
|
3564
3586
|
}
|
|
3565
3587
|
if (/JAVA_HOME/i.test(message)) return "Java is needed for Android tests \u2014 install a JDK (Android Studio includes one).";
|
|
3566
3588
|
if (/ANDROID_HOME|ANDROID_SDK_ROOT/i.test(message)) return "The Android SDK was not found \u2014 install Android Studio.";
|
|
3567
|
-
if (/xcodebuild|WebDriverAgent/i.test(message)) return `Appium could not start its helper on the iPhone: ${message}. For a real iPhone, set your Apple team id in Settings \u2192
|
|
3589
|
+
if (/xcodebuild|WebDriverAgent/i.test(message)) return `Appium could not start its helper on the iPhone: ${message}. For a real iPhone, set your Apple team id in Settings \u2192 Platforms.`;
|
|
3568
3590
|
return `The app could not be opened: ${message}`;
|
|
3569
3591
|
}
|
|
3570
3592
|
var MOBILE_TIMEOUTS = { step: 15e3, assertion: 5e3 };
|
|
@@ -3827,7 +3849,7 @@ async function chooseDevice(platform, mobile, asked, where) {
|
|
|
3827
3849
|
const picked = pickDevice(devices, platform);
|
|
3828
3850
|
if (picked) return picked;
|
|
3829
3851
|
const farm = mobile.cloud[0];
|
|
3830
|
-
if (farm) throw new Error(`No ${platform === "ios" ? "iPhone or simulator" : "Android phone or emulator"} is connected to this computer. Choose a ${farm.provider} device in Settings \u2192
|
|
3852
|
+
if (farm) throw new Error(`No ${platform === "ios" ? "iPhone or simulator" : "Android phone or emulator"} is connected to this computer. Choose a ${farm.provider} device in Settings \u2192 Platforms, or connect one.`);
|
|
3831
3853
|
throw new Error(platform === "ios" ? "No iPhone or simulator is available \u2014 install Xcode for simulators, or plug in an iPhone." : "No Android phone or emulator is available \u2014 start an emulator in Android Studio, or plug in a phone with USB debugging on.");
|
|
3832
3854
|
}
|
|
3833
3855
|
async function openMobileSession(opts) {
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
doctor,
|
|
14
14
|
executeStep,
|
|
15
15
|
fieldNames,
|
|
16
|
+
flowrikiHome,
|
|
16
17
|
helperRunner,
|
|
17
18
|
openRunSession,
|
|
18
19
|
prepareBrowsers,
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
runtimeValue,
|
|
24
25
|
start,
|
|
25
26
|
status
|
|
26
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-JFMQJDBI.js";
|
|
27
28
|
import {
|
|
28
29
|
CLOUD_PROVIDERS,
|
|
29
30
|
CLOUD_PROVIDER_NAME,
|
|
@@ -47,9 +48,10 @@ import {
|
|
|
47
48
|
replayMobileCase,
|
|
48
49
|
runMobileSteps,
|
|
49
50
|
screenLines,
|
|
51
|
+
slugify,
|
|
50
52
|
stepRefusedOn,
|
|
51
53
|
targetFor
|
|
52
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-O34WXNH6.js";
|
|
53
55
|
|
|
54
56
|
// src/cli.ts
|
|
55
57
|
import { parseArgs } from "util";
|
|
@@ -145,8 +147,14 @@ var ServiceClient = class {
|
|
|
145
147
|
get origin() {
|
|
146
148
|
return this.connection.service;
|
|
147
149
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
/** One request to the service, retried when the service — or the proxy in
|
|
151
|
+
* front of it — says it is too busy. A 429 or a 503 from the edge means
|
|
152
|
+
* the request was never handled, so trying again is safe for any method;
|
|
153
|
+
* a 502/504 is retried only when nothing could have happened (a GET).
|
|
154
|
+
* Backing off here, inside the tool, is what keeps the model from asking
|
|
155
|
+
* again every two seconds and making the limit worse. */
|
|
156
|
+
async call(path3, init = {}, attempt = 0) {
|
|
157
|
+
const res = await this.fetcher(`${this.connection.service}${path3}`, {
|
|
150
158
|
...init,
|
|
151
159
|
headers: {
|
|
152
160
|
"x-flowriki-device": this.connection.deviceId,
|
|
@@ -161,9 +169,18 @@ var ServiceClient = class {
|
|
|
161
169
|
} catch {
|
|
162
170
|
}
|
|
163
171
|
if (!res.ok) {
|
|
172
|
+
const method = String(init.method ?? "GET").toUpperCase();
|
|
173
|
+
const busy = res.status === 429 || res.status === 503 || method === "GET" && (res.status === 502 || res.status === 504);
|
|
174
|
+
if (busy && attempt < 4) {
|
|
175
|
+
const asked = Number(res.headers.get("retry-after"));
|
|
176
|
+
const wait = Math.min(15e3, res.headers.get("retry-after") !== null && Number.isFinite(asked) && asked >= 0 ? asked * 1e3 : 1e3 * 2 ** attempt);
|
|
177
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
178
|
+
return this.call(path3, init, attempt + 1);
|
|
179
|
+
}
|
|
164
180
|
if (res.status === 404 && !("error" in body)) {
|
|
165
181
|
throw new Error("the Flowriki service does not offer this yet \u2014 it may need updating");
|
|
166
182
|
}
|
|
183
|
+
if (busy) throw new Error(`the Flowriki service is too busy right now (${res.status}) and stayed so after several tries \u2014 wait a minute before asking again, and do not retry in a loop`);
|
|
167
184
|
throw new Error(String(body["error"] ?? `the service answered ${res.status}`));
|
|
168
185
|
}
|
|
169
186
|
return body;
|
|
@@ -288,9 +305,9 @@ var ServiceClient = class {
|
|
|
288
305
|
}
|
|
289
306
|
// ── Manual cycles and bugs: the dashboard's own routes, behind this
|
|
290
307
|
// machine's credential, so the assistant can do what a person can. ──────
|
|
291
|
-
scoped(
|
|
308
|
+
scoped(path3, projectId, extra = {}) {
|
|
292
309
|
const query = new URLSearchParams({ projectId, ...extra });
|
|
293
|
-
return `/api/agent${
|
|
310
|
+
return `/api/agent${path3}?${query}`;
|
|
294
311
|
}
|
|
295
312
|
cycles(projectId) {
|
|
296
313
|
return this.call(this.scoped("/cycles", projectId));
|
|
@@ -333,8 +350,8 @@ var ServiceClient = class {
|
|
|
333
350
|
knowledgePages(projectId) {
|
|
334
351
|
return this.call(this.scoped("/knowledge", projectId));
|
|
335
352
|
}
|
|
336
|
-
knowledgePage(projectId,
|
|
337
|
-
return this.call(this.scoped("/knowledge/page", projectId, { path:
|
|
353
|
+
knowledgePage(projectId, path3) {
|
|
354
|
+
return this.call(this.scoped("/knowledge/page", projectId, { path: path3 }));
|
|
338
355
|
}
|
|
339
356
|
writeKnowledge(projectId, body) {
|
|
340
357
|
return this.call(`/api/agent/knowledge?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(body) });
|
|
@@ -375,6 +392,8 @@ var ServiceClient = class {
|
|
|
375
392
|
};
|
|
376
393
|
|
|
377
394
|
// src/mcp/session.ts
|
|
395
|
+
import fs from "fs";
|
|
396
|
+
import path from "path";
|
|
378
397
|
var MAX_TREE = 14e3;
|
|
379
398
|
var MAX_STEP_VIEW = 6e3;
|
|
380
399
|
function topLevelBlocks(tree) {
|
|
@@ -529,7 +548,7 @@ function describe(step) {
|
|
|
529
548
|
return `${step.type} ${target}${where}`;
|
|
530
549
|
}
|
|
531
550
|
}
|
|
532
|
-
var AuthoringSession = class {
|
|
551
|
+
var AuthoringSession = class _AuthoringSession {
|
|
533
552
|
constructor(service, opts = {}) {
|
|
534
553
|
this.service = service;
|
|
535
554
|
this.opts = opts;
|
|
@@ -546,6 +565,9 @@ var AuthoringSession = class {
|
|
|
546
565
|
/** Values typed into secret fields, by their `typed:` reference. */
|
|
547
566
|
typed = /* @__PURE__ */ new Map();
|
|
548
567
|
draft = null;
|
|
568
|
+
/** The steps as last replayed from the start and passed — a save of the
|
|
569
|
+
* same steps needs no second replay. Cleared by any change to them. */
|
|
570
|
+
verified = null;
|
|
549
571
|
catalog = null;
|
|
550
572
|
/** Text going back to Claude, with every secret typed here masked — a
|
|
551
573
|
* password box's value is in the page's tree, and a careless form puts it
|
|
@@ -595,15 +617,107 @@ var AuthoringSession = class {
|
|
|
595
617
|
if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
|
|
596
618
|
return base;
|
|
597
619
|
}
|
|
598
|
-
/** Open a browser for a new flow, signed in first if asked.
|
|
599
|
-
|
|
620
|
+
/** Open a browser for a new flow, signed in first if asked.
|
|
621
|
+
*
|
|
622
|
+
* A DRAFT LEFT BEHIND IS PICKED UP. Every kept step is written to disk as
|
|
623
|
+
* it is kept, so a flow interrupted — the program stopped, the limit hit,
|
|
624
|
+
* the agent restarted — is not lost: starting the same flow again replays
|
|
625
|
+
* what was kept and carries on from there, unless `fresh` says start over. */
|
|
626
|
+
async start(input, catalog, opts = {}) {
|
|
600
627
|
await this.close();
|
|
601
628
|
this.catalog = catalog;
|
|
602
629
|
this.draft = { ...input, steps: [] };
|
|
603
630
|
this.values = /* @__PURE__ */ new Map();
|
|
604
631
|
this.typed = /* @__PURE__ */ new Map();
|
|
605
632
|
this.lastTree = null;
|
|
606
|
-
|
|
633
|
+
this.verified = null;
|
|
634
|
+
const left = opts.fresh ? null : this.readDraft(input.projectId, input.name);
|
|
635
|
+
if (opts.fresh) this.dropDraft(input.projectId, input.name);
|
|
636
|
+
const opened = isMobile(input.platform) ? await this.startMobile(input.platform, catalog) : await this.startWeb(input, catalog);
|
|
637
|
+
if (!left?.steps.length) return opened;
|
|
638
|
+
return this.resume(left.steps, opened);
|
|
639
|
+
}
|
|
640
|
+
/** The steps of an earlier attempt, performed again in the open session
|
|
641
|
+
* and kept as they pass; the first that fails is where the person — or
|
|
642
|
+
* the model — takes over. */
|
|
643
|
+
async resume(steps, opened) {
|
|
644
|
+
const draft = this.draft;
|
|
645
|
+
let failed = null;
|
|
646
|
+
for (const step of steps) {
|
|
647
|
+
try {
|
|
648
|
+
if (this.mobile) {
|
|
649
|
+
const done = await runMobileSteps(this.mobile, [step], this.mobileResolvers());
|
|
650
|
+
if (done.status !== "passed") throw new Error(done.results[0]?.detail ?? done.error ?? "it did not work");
|
|
651
|
+
} else {
|
|
652
|
+
const { page, run } = this.requireOpen();
|
|
653
|
+
this.page = await executeStep(page, step, {
|
|
654
|
+
pages: run.pages,
|
|
655
|
+
values: this.values,
|
|
656
|
+
baseUrl: this.baseUrl(),
|
|
657
|
+
...this.resolvers ?? {},
|
|
658
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
|
|
659
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
660
|
+
navigationTimeoutMs: 45e3
|
|
661
|
+
});
|
|
662
|
+
await this.settle();
|
|
663
|
+
}
|
|
664
|
+
draft.steps.push(step);
|
|
665
|
+
this.checkpoint();
|
|
666
|
+
} catch (error) {
|
|
667
|
+
failed = `${describe(step)} \u2014 ${error.message.split("\n")[0]}`;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
this.checkpoint();
|
|
672
|
+
const kept = draft.steps.length;
|
|
673
|
+
const head = failed ? `Resumed "${draft.name}": ${kept} of ${steps.length} earlier steps are back; the next one did not work this time and was dropped (${failed}). Carry on from here.` : `Resumed "${draft.name}": its ${kept} earlier step${kept === 1 ? "" : "s"} ${kept === 1 ? "is" : "are"} back. Carry on from here.`;
|
|
674
|
+
const view = this.mobile ? await this.snapshot() : opened.includes("\n") ? await this.snapshot() : opened;
|
|
675
|
+
return `${head}
|
|
676
|
+
|
|
677
|
+
${view}`;
|
|
678
|
+
}
|
|
679
|
+
// ── Drafts on disk ──────────────────────────────────────────────────────
|
|
680
|
+
static draftFile(projectId, name) {
|
|
681
|
+
return path.join(flowrikiHome(), "drafts", projectId.replace(/[^A-Za-z0-9_-]/g, "_"), `${slugify(name) || "flow"}.json`);
|
|
682
|
+
}
|
|
683
|
+
/** Written after every kept step: the draft as it stands, never a typed
|
|
684
|
+
* secret (those stay in memory until the vault has them). */
|
|
685
|
+
checkpoint() {
|
|
686
|
+
const draft = this.draft;
|
|
687
|
+
if (!draft) return;
|
|
688
|
+
this.verified = null;
|
|
689
|
+
try {
|
|
690
|
+
const file = _AuthoringSession.draftFile(draft.projectId, draft.name);
|
|
691
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
|
|
692
|
+
const { preparation: _p, ...rest } = draft;
|
|
693
|
+
fs.writeFileSync(file, JSON.stringify({ ...rest, steps: withoutTyped(draft.steps), savedAt: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
|
|
694
|
+
} catch {
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
readDraft(projectId, name) {
|
|
698
|
+
try {
|
|
699
|
+
const raw = JSON.parse(fs.readFileSync(_AuthoringSession.draftFile(projectId, name), "utf8"));
|
|
700
|
+
const steps = (raw.steps ?? []).map((s) => RecordedStepSchema.parse(s));
|
|
701
|
+
return { steps };
|
|
702
|
+
} catch {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
dropDraft(projectId, name) {
|
|
707
|
+
try {
|
|
708
|
+
fs.rmSync(_AuthoringSession.draftFile(projectId, name), { force: true });
|
|
709
|
+
} catch {
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/** Close and forget the flow being written: close_browser means it. */
|
|
713
|
+
async closeAndDrop() {
|
|
714
|
+
const draft = this.draft;
|
|
715
|
+
await this.close();
|
|
716
|
+
if (draft) this.dropDraft(draft.projectId, draft.name);
|
|
717
|
+
this.draft = null;
|
|
718
|
+
}
|
|
719
|
+
async startWeb(input, catalog) {
|
|
720
|
+
void catalog;
|
|
607
721
|
const base = this.baseUrl();
|
|
608
722
|
this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
|
|
609
723
|
this.page = this.run.page;
|
|
@@ -756,6 +870,7 @@ ${await this.view(false)}`);
|
|
|
756
870
|
}
|
|
757
871
|
await this.settle();
|
|
758
872
|
draft.steps.push(step);
|
|
873
|
+
this.checkpoint();
|
|
759
874
|
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
760
875
|
|
|
761
876
|
${await this.view(false)}`;
|
|
@@ -793,6 +908,7 @@ ${await this.view(false)}`;
|
|
|
793
908
|
proposals: [assertion]
|
|
794
909
|
}])[0]);
|
|
795
910
|
draft.steps.push(step);
|
|
911
|
+
this.checkpoint();
|
|
796
912
|
return `Step ${draft.steps.length} kept: ${describe(step)}.`;
|
|
797
913
|
}
|
|
798
914
|
/** Get a value from outside the page — an email code, a 2FA code, a text,
|
|
@@ -835,6 +951,7 @@ ${await this.view(false)}`;
|
|
|
835
951
|
}
|
|
836
952
|
await this.settle();
|
|
837
953
|
draft.steps.push(step);
|
|
954
|
+
this.checkpoint();
|
|
838
955
|
return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got.value}" (${got.detail}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
|
|
839
956
|
|
|
840
957
|
${await this.view(false)}` : "");
|
|
@@ -854,6 +971,7 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
|
854
971
|
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
855
972
|
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
856
973
|
const [gone] = draft.steps.splice(number - 1, 1);
|
|
974
|
+
this.checkpoint();
|
|
857
975
|
return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
|
|
858
976
|
|
|
859
977
|
${this.list()}`;
|
|
@@ -872,17 +990,34 @@ ${this.list()}`;
|
|
|
872
990
|
];
|
|
873
991
|
const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
|
|
874
992
|
const lines = outcome.steps.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
|
|
993
|
+
if (outcome.status === "passed") this.verified = signatureOf(draft.steps);
|
|
875
994
|
return `${outcome.status === "passed" ? "The whole flow passed from the start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
|
|
876
995
|
${lines.join("\n")}`;
|
|
877
996
|
}
|
|
878
|
-
/** Save the draft as a flow on a new branch, for a person to review.
|
|
879
|
-
|
|
997
|
+
/** Save the draft as a flow on a new branch, for a person to review.
|
|
998
|
+
*
|
|
999
|
+
* REPLAYED FIRST. A flow that was only ever performed step by step, with
|
|
1000
|
+
* pauses to look, has not been shown to run as a test runs it; so unless
|
|
1001
|
+
* the same steps already passed a replay, they are replayed from the
|
|
1002
|
+
* start now, and a flow that fails is not saved — the failing step is
|
|
1003
|
+
* named instead. `force` saves anyway, for a person who knows. */
|
|
1004
|
+
async save(title, where = {}, opts = {}) {
|
|
880
1005
|
const draft = this.draft;
|
|
881
1006
|
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
882
1007
|
if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
|
|
883
1008
|
if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
|
|
884
1009
|
throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
|
|
885
1010
|
}
|
|
1011
|
+
let replayed = "";
|
|
1012
|
+
if (!opts.force && this.verified !== signatureOf(draft.steps)) {
|
|
1013
|
+
const report = await this.replay();
|
|
1014
|
+
if (!/^The whole flow passed/.test(report)) {
|
|
1015
|
+
throw new Error(`Not saved: replayed from the start, the flow did not pass.
|
|
1016
|
+
${report}
|
|
1017
|
+
Fix the step that failed (remove_step, then act again) and save again \u2014 or save_flow with force: true to keep it as it is.`);
|
|
1018
|
+
}
|
|
1019
|
+
replayed = " It was replayed from the start first and passed.";
|
|
1020
|
+
}
|
|
886
1021
|
const checksOff = draft.steps.map((step) => step.type === "assert" ? { ...step, proposals: step.proposals.map((p) => ({ ...p, enabled: false })) } : step);
|
|
887
1022
|
const checkCount = checksOff.filter((step) => step.type === "assert").length;
|
|
888
1023
|
const secretValues = {};
|
|
@@ -908,7 +1043,8 @@ ${lines.join("\n")}`;
|
|
|
908
1043
|
const note = others.length ? `
|
|
909
1044
|
Claude has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 gather_branches puts them into this one, for one review.` : "";
|
|
910
1045
|
const after = draft.after?.length ? ` It starts after ${draft.after.map((a) => `"${a.name}"`).join(", ")}.` : "";
|
|
911
|
-
|
|
1046
|
+
this.dropDraft(draft.projectId, draft.name);
|
|
1047
|
+
return `${what} ${where_}.${replayed}${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 a test case switches on the ones it is about. Nothing is published yet.
|
|
912
1048
|
Review and publish it here: ${review}${note}`;
|
|
913
1049
|
}
|
|
914
1050
|
/** A test case built from flows, without saving: its rows and every check,
|
|
@@ -1080,6 +1216,7 @@ ${await this.view(false)}`);
|
|
|
1080
1216
|
}
|
|
1081
1217
|
await new Promise((r) => setTimeout(r, 600));
|
|
1082
1218
|
draft.steps.push(step);
|
|
1219
|
+
this.checkpoint();
|
|
1083
1220
|
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
1084
1221
|
|
|
1085
1222
|
${await this.view(false)}`;
|
|
@@ -1113,6 +1250,7 @@ ${await this.view(false)}`;
|
|
|
1113
1250
|
proposals: [assertion]
|
|
1114
1251
|
}])[0]);
|
|
1115
1252
|
draft.steps.push(step);
|
|
1253
|
+
this.checkpoint();
|
|
1116
1254
|
return `Step ${draft.steps.length} kept: ${describe(step)}.`;
|
|
1117
1255
|
}
|
|
1118
1256
|
async mobileHelper(input) {
|
|
@@ -1141,6 +1279,7 @@ ${await this.view(false)}`;
|
|
|
1141
1279
|
throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
|
|
1142
1280
|
}
|
|
1143
1281
|
draft.steps.push(step);
|
|
1282
|
+
this.checkpoint();
|
|
1144
1283
|
return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got?.value ?? ""}" (${got?.detail ?? ""}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
|
|
1145
1284
|
|
|
1146
1285
|
${await this.view(false)}` : "");
|
|
@@ -1163,6 +1302,7 @@ ${await this.view(false)}` : "");
|
|
|
1163
1302
|
if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await mobile.openLink(draft.startPath);
|
|
1164
1303
|
const outcome = await runMobileSteps(mobile, steps, { values: /* @__PURE__ */ new Map(), ...resolvers, resolveSecret: (ref) => this.typed.get(ref) ?? resolvers.resolveSecret(ref) });
|
|
1165
1304
|
this.lastTree = null;
|
|
1305
|
+
if (outcome.status === "passed") this.verified = signatureOf(draft.steps);
|
|
1166
1306
|
const lines = outcome.results.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
|
|
1167
1307
|
return `${outcome.status === "passed" ? "The whole flow passed from the app's start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
|
|
1168
1308
|
${lines.join("\n")}`;
|
|
@@ -1175,6 +1315,9 @@ ${lines.join("\n")}`;
|
|
|
1175
1315
|
await page.waitForTimeout(300);
|
|
1176
1316
|
}
|
|
1177
1317
|
};
|
|
1318
|
+
function signatureOf(steps) {
|
|
1319
|
+
return JSON.stringify(steps.map((s) => [s.id, s.type, s.role, s.name, s.value, s.nth ?? null, s.proposals.map((p) => [p.kind, p.value, p.enabled])]));
|
|
1320
|
+
}
|
|
1178
1321
|
|
|
1179
1322
|
// src/mcp/tools.ts
|
|
1180
1323
|
var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
|
|
@@ -1195,6 +1338,9 @@ THE MODEL \u2014 get this right before recording anything:
|
|
|
1195
1338
|
something different.
|
|
1196
1339
|
- A flow records checks (add_check) that prove each thing happened, but they
|
|
1197
1340
|
are SAVED SWITCHED OFF: which ones matter depends on the test.
|
|
1341
|
+
- Every kept step is written down as it is kept: if you are stopped, start_flow
|
|
1342
|
+
with the same name picks the flow up where it was (fresh: true starts over).
|
|
1343
|
+
save_flow replays the flow from the start first and refuses one that fails.
|
|
1198
1344
|
- A TEST CASE is the test. It strings flows together as rows (in the same
|
|
1199
1345
|
browser, or a new one for a second person) and switches on the checks it is
|
|
1200
1346
|
about. Build it with preview_case, then save_case \u2014 which runs it first.
|
|
@@ -1479,6 +1625,7 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
|
1479
1625
|
required: ["profileId"],
|
|
1480
1626
|
additionalProperties: false
|
|
1481
1627
|
},
|
|
1628
|
+
fresh: { type: "boolean", description: "Start over, ignoring a draft of this flow left by an earlier attempt. By default the earlier steps are replayed and kept, and recording carries on from there." },
|
|
1482
1629
|
projectId: { type: "string" }
|
|
1483
1630
|
},
|
|
1484
1631
|
required: ["name", "feature"],
|
|
@@ -1510,7 +1657,7 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
|
1510
1657
|
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1511
1658
|
startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
|
|
1512
1659
|
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1513
|
-
}, catalog);
|
|
1660
|
+
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {} });
|
|
1514
1661
|
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
1515
1662
|
return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} ${isMobile(platform) ? "The app is open on the phone." : "The browser is open."}
|
|
1516
1663
|
|
|
@@ -1632,20 +1779,21 @@ ${page}`);
|
|
|
1632
1779
|
{
|
|
1633
1780
|
name: "save_flow",
|
|
1634
1781
|
title: "Save the flow to a branch",
|
|
1635
|
-
description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. Returns the review link. Refused if the flow checks nothing.",
|
|
1782
|
+
description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. The flow is replayed from the start first, as a test run will run it, unless the same steps already passed replay_flow; one that fails is not saved and the failing step is named. Returns the review link. Refused if the flow checks nothing.",
|
|
1636
1783
|
inputSchema: {
|
|
1637
1784
|
type: "object",
|
|
1638
1785
|
properties: {
|
|
1639
1786
|
title: { type: "string", description: "The branch's title, used only when a new branch is started." },
|
|
1640
1787
|
branch: { type: "string", description: "Optional: a branch id (chg_\u2026) from list_my_branches to save onto instead." },
|
|
1641
|
-
newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." }
|
|
1788
|
+
newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." },
|
|
1789
|
+
force: { type: "boolean", description: "Save even though the replay from the start failed \u2014 only when the person asked for it as it is." }
|
|
1642
1790
|
},
|
|
1643
1791
|
additionalProperties: false
|
|
1644
1792
|
},
|
|
1645
1793
|
run: async (args) => text(await session.save(str(args["title"]), {
|
|
1646
1794
|
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {},
|
|
1647
1795
|
...args["newBranch"] === true ? { newBranch: true } : {}
|
|
1648
|
-
}))
|
|
1796
|
+
}, { ...args["force"] === true ? { force: true } : {} }))
|
|
1649
1797
|
},
|
|
1650
1798
|
{
|
|
1651
1799
|
name: "list_my_branches",
|
|
@@ -2031,7 +2179,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
|
|
|
2031
2179
|
}
|
|
2032
2180
|
}
|
|
2033
2181
|
const { pages } = await service.knowledgePages(projectId);
|
|
2034
|
-
const read = async (
|
|
2182
|
+
const read = async (path3) => pages.some((p) => p.path === path3) ? (await service.knowledgePage(projectId, path3)).content : null;
|
|
2035
2183
|
const [product, glossary, decisions, web, android, ios, tagged] = await Promise.all(["product.md", "glossary.md", "memory/decisions.md", "product/web.md", "product/android.md", "product/ios.md", "codebase.json"].map(read));
|
|
2036
2184
|
const codebases = pages.filter((p) => p.path.startsWith("codebase/"));
|
|
2037
2185
|
const tags = codebaseIndexOf(tagged).folders;
|
|
@@ -2122,8 +2270,7 @@ ${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") |
|
|
|
2122
2270
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
2123
2271
|
annotations: { destructiveHint: true },
|
|
2124
2272
|
run: async () => {
|
|
2125
|
-
await session.
|
|
2126
|
-
session.draft = null;
|
|
2273
|
+
await session.closeAndDrop();
|
|
2127
2274
|
return text("Closed.");
|
|
2128
2275
|
}
|
|
2129
2276
|
}
|
|
@@ -2315,14 +2462,14 @@ function serveWithUpgrades(tools, info, opts) {
|
|
|
2315
2462
|
}
|
|
2316
2463
|
|
|
2317
2464
|
// src/updates.ts
|
|
2318
|
-
import
|
|
2319
|
-
import
|
|
2465
|
+
import fs2 from "fs";
|
|
2466
|
+
import path2 from "path";
|
|
2320
2467
|
var CACHE_MS = 30 * 60 * 1e3;
|
|
2321
2468
|
async function latestPublished(opts = {}) {
|
|
2322
|
-
const file =
|
|
2469
|
+
const file = path2.join(dataDir(), "latest-agent.json");
|
|
2323
2470
|
if (!opts.fresh) {
|
|
2324
2471
|
try {
|
|
2325
|
-
const kept = JSON.parse(
|
|
2472
|
+
const kept = JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
2326
2473
|
if (kept.version && kept.at && Date.now() - kept.at < CACHE_MS) return kept.version;
|
|
2327
2474
|
} catch {
|
|
2328
2475
|
}
|
|
@@ -2333,8 +2480,8 @@ async function latestPublished(opts = {}) {
|
|
|
2333
2480
|
const version = (await res.json()).version ?? null;
|
|
2334
2481
|
if (version) {
|
|
2335
2482
|
try {
|
|
2336
|
-
|
|
2337
|
-
|
|
2483
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true });
|
|
2484
|
+
fs2.writeFileSync(file, JSON.stringify({ version, at: Date.now() }));
|
|
2338
2485
|
} catch {
|
|
2339
2486
|
}
|
|
2340
2487
|
}
|
package/dist/engine.js
CHANGED
package/package.json
CHANGED