@flowriki/agent 0.1.0-beta.53 → 0.1.0-beta.56
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.56";
|
|
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;
|
|
@@ -9846,8 +9847,11 @@ var ownerOf = (l) => l.draft ? "recording" : "debug";
|
|
|
9846
9847
|
var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|\bpin\b|cvv|cvc/i;
|
|
9847
9848
|
async function screenOf(l, withImage = false) {
|
|
9848
9849
|
l.session.changed();
|
|
9849
|
-
const screen = await
|
|
9850
|
-
|
|
9850
|
+
const [screen, png, where] = await Promise.all([
|
|
9851
|
+
l.session.screen(),
|
|
9852
|
+
withImage ? l.session.preview() : Promise.resolve(null),
|
|
9853
|
+
l.session.currentScreenName()
|
|
9854
|
+
]);
|
|
9851
9855
|
return {
|
|
9852
9856
|
...png ? { image: png.toString("base64") } : {},
|
|
9853
9857
|
width: screen.size.width,
|
|
@@ -9862,7 +9866,7 @@ async function screenOf(l, withImage = false) {
|
|
|
9862
9866
|
...n2.value ? { value: n2.password ? "\u2022\u2022\u2022" : n2.value } : {},
|
|
9863
9867
|
...n2.id ? { id: n2.id } : {}
|
|
9864
9868
|
})),
|
|
9865
|
-
where
|
|
9869
|
+
where,
|
|
9866
9870
|
device: l.session.deviceName
|
|
9867
9871
|
};
|
|
9868
9872
|
}
|
|
@@ -9890,7 +9894,7 @@ function mobileHandlers(ctx) {
|
|
|
9890
9894
|
const session = live.get(platform)?.session ?? openSessionOn(platform);
|
|
9891
9895
|
if (!session) return { ok: true, result: { image: null } };
|
|
9892
9896
|
try {
|
|
9893
|
-
const png = await session.
|
|
9897
|
+
const png = await session.preview(720);
|
|
9894
9898
|
return { ok: true, result: { image: png.toString("base64"), device: session.deviceName } };
|
|
9895
9899
|
} catch {
|
|
9896
9900
|
return { ok: true, result: { image: null } };
|
|
@@ -10003,18 +10007,18 @@ function mobileHandlers(ctx) {
|
|
|
10003
10007
|
if (op === "run" || step.type === "signIn" || step.type === "assert") {
|
|
10004
10008
|
const done = await runMobileSteps(l.session, [step], { values: l.values, resolveSecret: l.resolveSecret, resolveAuth: l.resolveAuth, ...l.runHelper ? { runHelper: l.runHelper } : {} });
|
|
10005
10009
|
const result = done.results[0];
|
|
10006
|
-
if (op === "run") return { ok: true, result: { result: result ?? null
|
|
10010
|
+
if (op === "run") return { ok: true, result: { result: result ?? null } };
|
|
10007
10011
|
if (done.status !== "passed") throw new Error(result?.detail ?? done.error ?? "it did not work");
|
|
10008
10012
|
} else {
|
|
10009
10013
|
await performMobileStep(l.session, step, { values: l.values, resolveSecret: l.resolveSecret, resolveAuth: l.resolveAuth, ...l.runHelper ? { runHelper: l.runHelper } : {} });
|
|
10010
10014
|
}
|
|
10011
10015
|
} catch (error) {
|
|
10012
10016
|
if (secret2) l.typed.delete(ref);
|
|
10013
|
-
return { ok: true, result: { kept: false, error: error.message.split("\n")[0]
|
|
10017
|
+
return { ok: true, result: { kept: false, error: error.message.split("\n")[0] } };
|
|
10014
10018
|
}
|
|
10015
|
-
await new Promise((r) => setTimeout(r,
|
|
10019
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
10016
10020
|
l.draft?.steps.push(step);
|
|
10017
|
-
return { ok: true, result: { kept: true, step, durationMs: Date.now() - t0, steps: l.draft?.steps ?? []
|
|
10021
|
+
return { ok: true, result: { kept: true, step, durationMs: Date.now() - t0, steps: l.draft?.steps ?? [] } };
|
|
10018
10022
|
}
|
|
10019
10023
|
if (op === "check") {
|
|
10020
10024
|
const assertion = RecordedStepSchema.shape.proposals.element.parse({ enabled: true, ...command.args["assertion"] });
|
|
@@ -10260,9 +10264,10 @@ function eventsFromLine(askId, line) {
|
|
|
10260
10264
|
function createClaudeCli(options) {
|
|
10261
10265
|
const spawn2 = options.spawn ?? nodeSpawn;
|
|
10262
10266
|
const binary = options.binary ?? process.env["FLOWRIKI_CLAUDE_BIN"] ?? "claude";
|
|
10263
|
-
const maxAskMs = options.maxAskMs ??
|
|
10267
|
+
const maxAskMs = options.maxAskMs ?? 60 * 6e4;
|
|
10264
10268
|
const scratchRoot2 = options.scratchRoot ?? path42.join(os22.tmpdir(), "flowriki-claude");
|
|
10265
|
-
|
|
10269
|
+
const running = /* @__PURE__ */ new Map();
|
|
10270
|
+
const MAX_AT_ONCE = 3;
|
|
10266
10271
|
let cancelled = null;
|
|
10267
10272
|
function capture(args, timeoutMs = 15e3) {
|
|
10268
10273
|
return new Promise((resolve2) => {
|
|
@@ -10304,7 +10309,7 @@ function createClaudeCli(options) {
|
|
|
10304
10309
|
return { installed: true, version: version.out.trim().split(/\s+/)[0] ?? null, ...parsed };
|
|
10305
10310
|
},
|
|
10306
10311
|
connect(emit) {
|
|
10307
|
-
if (
|
|
10312
|
+
if (running.size > 0) return { started: false, detail: "The assistant is busy answering a question." };
|
|
10308
10313
|
let child;
|
|
10309
10314
|
try {
|
|
10310
10315
|
child = spawn2(binary, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -10334,88 +10339,107 @@ function createClaudeCli(options) {
|
|
|
10334
10339
|
return { started: true };
|
|
10335
10340
|
},
|
|
10336
10341
|
ask(ask) {
|
|
10337
|
-
if (
|
|
10342
|
+
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.` };
|
|
10338
10343
|
fs43.mkdirSync(scratchRoot2, { recursive: true, mode: 448 });
|
|
10339
|
-
const dir =
|
|
10344
|
+
const dir = path42.join(scratchRoot2, `project-${ask.projectId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
|
|
10345
|
+
fs43.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
10340
10346
|
const mcpConfigPath = path42.join(dir, "mcp.json");
|
|
10341
10347
|
fs43.writeFileSync(
|
|
10342
10348
|
mcpConfigPath,
|
|
10343
10349
|
JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: options.mcp.command, args: options.mcp.args } } }),
|
|
10344
10350
|
{ mode: 384 }
|
|
10345
10351
|
);
|
|
10346
|
-
|
|
10347
|
-
try {
|
|
10348
|
-
child = spawn2(
|
|
10349
|
-
binary,
|
|
10350
|
-
buildAskArgs({
|
|
10351
|
-
prompt: ask.prompt,
|
|
10352
|
-
mcpConfigPath,
|
|
10353
|
-
projectId: ask.projectId,
|
|
10354
|
-
projectName: ask.projectName,
|
|
10355
|
-
...ask.sessionId ? { sessionId: ask.sessionId } : {},
|
|
10356
|
-
...ask.folders?.length ? { folders: ask.folders } : {},
|
|
10357
|
-
...ask.mode ? { mode: ask.mode } : {}
|
|
10358
|
-
}),
|
|
10359
|
-
// THE SCRATCH FOLDER, not the project: nothing here to read or write.
|
|
10360
|
-
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
|
|
10361
|
-
);
|
|
10362
|
-
} catch {
|
|
10363
|
-
fs43.rmSync(dir, { recursive: true, force: true });
|
|
10364
|
-
return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
|
|
10365
|
-
}
|
|
10366
|
-
current = { askId: ask.askId, child };
|
|
10367
|
-
let buffer = "", stderr = "", sawDone = false, ended = false;
|
|
10368
|
-
const handleLine = (line) => {
|
|
10369
|
-
for (const event of eventsFromLine(ask.askId, line)) {
|
|
10370
|
-
if (event.name === "assistant.done") sawDone = true;
|
|
10371
|
-
ask.emit(event.name, event.data);
|
|
10372
|
-
}
|
|
10373
|
-
};
|
|
10374
|
-
const end = (ok, detail) => {
|
|
10375
|
-
if (ended) return;
|
|
10376
|
-
ended = true;
|
|
10377
|
-
clearTimeout(timer);
|
|
10378
|
-
if (buffer.trim()) handleLine(buffer);
|
|
10379
|
-
if (!sawDone) ask.emit("assistant.done", { askId: ask.askId, ok, result: detail });
|
|
10380
|
-
current = null;
|
|
10381
|
-
fs43.rmSync(dir, { recursive: true, force: true });
|
|
10382
|
-
};
|
|
10383
|
-
const timer = setTimeout(() => {
|
|
10384
|
-
child.kill("SIGTERM");
|
|
10385
|
-
end(false, `Stopped after ${Math.max(1, Math.round(maxAskMs / 6e4))} minutes. Whatever it saved so far is on the Branches page \u2014 send \u201Ccontinue\u201D to carry on.`);
|
|
10386
|
-
}, maxAskMs);
|
|
10387
|
-
timer.unref?.();
|
|
10388
|
-
child.stdout?.on("data", (chunk) => {
|
|
10389
|
-
buffer += chunk.toString();
|
|
10390
|
-
let at;
|
|
10391
|
-
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
10392
|
-
handleLine(buffer.slice(0, at));
|
|
10393
|
-
buffer = buffer.slice(at + 1);
|
|
10394
|
-
}
|
|
10395
|
-
});
|
|
10396
|
-
child.stderr?.on("data", (chunk) => {
|
|
10397
|
-
stderr = (stderr + chunk.toString()).slice(-2e3);
|
|
10398
|
-
});
|
|
10399
|
-
child.on("error", () => end(false, "The assistant\u2019s command-line tool could not be started."));
|
|
10400
|
-
child.on(
|
|
10401
|
-
"close",
|
|
10402
|
-
(code) => end(
|
|
10403
|
-
code === 0,
|
|
10404
|
-
cancelled === ask.askId ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
|
|
10405
|
-
)
|
|
10406
|
-
);
|
|
10407
|
-
return { started: true };
|
|
10352
|
+
return launch(ask, dir, mcpConfigPath, ask.sessionId);
|
|
10408
10353
|
},
|
|
10409
10354
|
cancel(askId) {
|
|
10410
|
-
|
|
10355
|
+
const child = running.get(askId);
|
|
10356
|
+
if (!child) return false;
|
|
10411
10357
|
cancelled = askId;
|
|
10412
|
-
|
|
10413
|
-
const child = current.child;
|
|
10358
|
+
child.kill("SIGTERM");
|
|
10414
10359
|
setTimeout(() => child.kill("SIGKILL"), 3e3).unref?.();
|
|
10415
10360
|
return true;
|
|
10416
10361
|
},
|
|
10417
|
-
|
|
10362
|
+
/** Full — no room for another question — rather than "something runs":
|
|
10363
|
+
* a second conversation is welcome while the first works. */
|
|
10364
|
+
busy() {
|
|
10365
|
+
return running.size >= MAX_AT_ONCE;
|
|
10366
|
+
}
|
|
10418
10367
|
};
|
|
10368
|
+
function launch(ask, dir, mcpConfigPath, sessionId) {
|
|
10369
|
+
let child;
|
|
10370
|
+
try {
|
|
10371
|
+
child = spawn2(
|
|
10372
|
+
binary,
|
|
10373
|
+
buildAskArgs({
|
|
10374
|
+
prompt: ask.prompt,
|
|
10375
|
+
mcpConfigPath,
|
|
10376
|
+
projectId: ask.projectId,
|
|
10377
|
+
projectName: ask.projectName,
|
|
10378
|
+
...sessionId ? { sessionId } : {},
|
|
10379
|
+
...ask.folders?.length ? { folders: ask.folders } : {},
|
|
10380
|
+
...ask.mode ? { mode: ask.mode } : {},
|
|
10381
|
+
...ask.brief ? { brief: ask.brief } : {}
|
|
10382
|
+
}),
|
|
10383
|
+
// THE SCRATCH FOLDER, not the project: nothing here to read or write.
|
|
10384
|
+
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
|
|
10385
|
+
);
|
|
10386
|
+
} catch {
|
|
10387
|
+
return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
|
|
10388
|
+
}
|
|
10389
|
+
running.set(ask.askId, child);
|
|
10390
|
+
let buffer = "", stderr = "", sawDone = false, ended = false;
|
|
10391
|
+
let silentFailure = false;
|
|
10392
|
+
const handleLine = (line) => {
|
|
10393
|
+
for (const event of eventsFromLine(ask.askId, line)) {
|
|
10394
|
+
if (event.name === "assistant.done" && event.data["ok"] === false && !String(event.data["result"] ?? "").trim()) {
|
|
10395
|
+
silentFailure = true;
|
|
10396
|
+
continue;
|
|
10397
|
+
}
|
|
10398
|
+
if (event.name === "assistant.done") sawDone = true;
|
|
10399
|
+
ask.emit(event.name, event.data);
|
|
10400
|
+
}
|
|
10401
|
+
};
|
|
10402
|
+
const end = (ok, detail) => {
|
|
10403
|
+
if (ended) return;
|
|
10404
|
+
ended = true;
|
|
10405
|
+
clearTimeout(timer);
|
|
10406
|
+
if (buffer.trim()) handleLine(buffer);
|
|
10407
|
+
running.delete(ask.askId);
|
|
10408
|
+
if (silentFailure && sessionId && /no conversation found/i.test(stderr) && cancelled !== ask.askId) {
|
|
10409
|
+
const again = launch(ask, dir, mcpConfigPath, void 0);
|
|
10410
|
+
if (again.started) return;
|
|
10411
|
+
}
|
|
10412
|
+
if (!sawDone) {
|
|
10413
|
+
const why = detail || stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || "The assistant stopped without saying why.";
|
|
10414
|
+
ask.emit("assistant.done", { askId: ask.askId, ok: ok && !silentFailure, result: ok && !silentFailure ? "" : why });
|
|
10415
|
+
}
|
|
10416
|
+
};
|
|
10417
|
+
const timer = setTimeout(() => {
|
|
10418
|
+
child.kill("SIGTERM");
|
|
10419
|
+
end(false, `Stopped after ${Math.max(1, Math.round(maxAskMs / 6e4))} minutes. Whatever it saved so far is on the Branches page \u2014 send \u201Ccontinue\u201D to carry on.`);
|
|
10420
|
+
}, maxAskMs);
|
|
10421
|
+
timer.unref?.();
|
|
10422
|
+
child.stdout?.on("data", (chunk) => {
|
|
10423
|
+
buffer += chunk.toString();
|
|
10424
|
+
let at;
|
|
10425
|
+
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
10426
|
+
handleLine(buffer.slice(0, at));
|
|
10427
|
+
buffer = buffer.slice(at + 1);
|
|
10428
|
+
}
|
|
10429
|
+
});
|
|
10430
|
+
child.stderr?.on("data", (chunk) => {
|
|
10431
|
+
stderr = (stderr + chunk.toString()).slice(-2e3);
|
|
10432
|
+
});
|
|
10433
|
+
child.on("error", () => end(false, "The assistant\u2019s command-line tool could not be started."));
|
|
10434
|
+
child.on(
|
|
10435
|
+
"close",
|
|
10436
|
+
(code) => end(
|
|
10437
|
+
code === 0,
|
|
10438
|
+
cancelled === ask.askId ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
|
|
10439
|
+
)
|
|
10440
|
+
);
|
|
10441
|
+
return { started: true };
|
|
10442
|
+
}
|
|
10419
10443
|
}
|
|
10420
10444
|
function createAgentRuntime(options) {
|
|
10421
10445
|
const lease = new BrowserLease();
|
|
@@ -10847,6 +10871,7 @@ export {
|
|
|
10847
10871
|
executeStep,
|
|
10848
10872
|
replayCase,
|
|
10849
10873
|
fieldNames,
|
|
10874
|
+
flowrikiHome,
|
|
10850
10875
|
helperRunner,
|
|
10851
10876
|
resolveCheckouts,
|
|
10852
10877
|
start,
|
|
@@ -14,7 +14,9 @@ import { spawn as spawn2 } from "child_process";
|
|
|
14
14
|
import fs3 from "fs";
|
|
15
15
|
import os3 from "os";
|
|
16
16
|
import path3 from "path";
|
|
17
|
+
import { execFile as execFile2 } from "child_process";
|
|
17
18
|
import fs4 from "fs";
|
|
19
|
+
import os4 from "os";
|
|
18
20
|
import path4 from "path";
|
|
19
21
|
|
|
20
22
|
// ../test-model/dist/chunk-I5COYCQ5.js
|
|
@@ -2566,8 +2568,15 @@ async function localCapabilities(target, udid, opts = {}) {
|
|
|
2566
2568
|
"appium:automationName": "UiAutomator2",
|
|
2567
2569
|
...common,
|
|
2568
2570
|
"appium:appPackage": app.id,
|
|
2569
|
-
...app.activity ? { "appium:appActivity": app.activity } : {},
|
|
2571
|
+
...app.activity ?? opts.activity ? { "appium:appActivity": app.activity ?? opts.activity } : {},
|
|
2570
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,
|
|
2571
2580
|
// Its own ports: two phones on one Appium each need their own driver
|
|
2572
2581
|
// server and picture stream, or the second session steals the first's.
|
|
2573
2582
|
...opts.ports?.system ? { "appium:systemPort": opts.ports.system } : {},
|
|
@@ -2647,7 +2656,7 @@ async function cloudDevices(account, key) {
|
|
|
2647
2656
|
if (!res.ok) throw new Error(`${account.provider} answered ${res.status}.`);
|
|
2648
2657
|
return res.json();
|
|
2649
2658
|
};
|
|
2650
|
-
const platformOf = (
|
|
2659
|
+
const platformOf = (os5) => /android/i.test(os5) ? "android" : /ios/i.test(os5) ? "ios" : null;
|
|
2651
2660
|
const out = [];
|
|
2652
2661
|
if (account.provider === "browserstack") {
|
|
2653
2662
|
for (const d of await get("https://api-cloud.browserstack.com/app-automate/devices.json")) {
|
|
@@ -2661,11 +2670,11 @@ async function cloudDevices(account, key) {
|
|
|
2661
2670
|
if (p) out.push({ name: d.name, platform: p, osVersion: d.osVersion });
|
|
2662
2671
|
}
|
|
2663
2672
|
} else {
|
|
2664
|
-
for (const
|
|
2665
|
-
const listed = await get(`https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us&os=${
|
|
2673
|
+
for (const os5 of ["android", "ios"]) {
|
|
2674
|
+
const listed = await get(`https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us&os=${os5}`);
|
|
2666
2675
|
for (const [, devices] of Object.entries(listed)) {
|
|
2667
2676
|
if (!Array.isArray(devices)) continue;
|
|
2668
|
-
for (const d of devices) out.push({ name: d.deviceName, platform:
|
|
2677
|
+
for (const d of devices) out.push({ name: d.deviceName, platform: os5, osVersion: d.platformVersion });
|
|
2669
2678
|
}
|
|
2670
2679
|
}
|
|
2671
2680
|
}
|
|
@@ -3232,7 +3241,7 @@ var MobileSession = class _MobileSession {
|
|
|
3232
3241
|
if (target.device.kind === "cloud") {
|
|
3233
3242
|
const choice2 = target.device;
|
|
3234
3243
|
const account = target.mobile.cloud.find((c) => c.provider === choice2.provider);
|
|
3235
|
-
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.`);
|
|
3236
3245
|
const key = opts.resolveSecret?.(account.keyRef);
|
|
3237
3246
|
if (!key) throw new Error(`The ${CLOUD_PROVIDER_NAME[choice2.provider]} access key is not available to this run.`);
|
|
3238
3247
|
let appRef = null;
|
|
@@ -3266,7 +3275,8 @@ var MobileSession = class _MobileSession {
|
|
|
3266
3275
|
const buildFile = app.build ? await localBuild(app.build) : null;
|
|
3267
3276
|
say(`Opening the app on ${known.name}\u2026`);
|
|
3268
3277
|
const ports = target.platform === "android" ? { system: await freePort(), mjpeg: await freePort() } : { wda: await freePort(), mjpeg: await freePort() };
|
|
3269
|
-
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 } : {} });
|
|
3270
3280
|
const driver = await WebDriverSession.create(endpoint, capabilities).catch((e) => {
|
|
3271
3281
|
const tail = appiumLog().filter((l) => /error/i.test(l)).slice(-2).join(" ");
|
|
3272
3282
|
throw new Error(explainStartFailure(e.message, target.platform) + (tail ? ` (${tail.slice(0, 300)})` : ""));
|
|
@@ -3313,6 +3323,16 @@ var MobileSession = class _MobileSession {
|
|
|
3313
3323
|
}
|
|
3314
3324
|
return png;
|
|
3315
3325
|
}
|
|
3326
|
+
/** The screen as a picture to LOOK at — the recorder's mirror, the run
|
|
3327
|
+
* page's live phone — at most `maxPx` on its longer side. A phone's own
|
|
3328
|
+
* resolution is three to four times what a mirror draws, and every byte
|
|
3329
|
+
* of it crosses the control channel each time; on macOS `sips` shrinks it
|
|
3330
|
+
* in a few milliseconds, elsewhere the full picture is sent. Evidence
|
|
3331
|
+
* files keep the full size. */
|
|
3332
|
+
async preview(maxPx = 900) {
|
|
3333
|
+
const png = await this.screenshot();
|
|
3334
|
+
return shrink(png, maxPx);
|
|
3335
|
+
}
|
|
3316
3336
|
/** Android: the activity on screen. iOS has none worth reading. */
|
|
3317
3337
|
async currentScreenName() {
|
|
3318
3338
|
if (this.platform !== "android") return "";
|
|
@@ -3443,7 +3463,7 @@ var MobileSession = class _MobileSession {
|
|
|
3443
3463
|
await this.terminateApp().catch(() => {
|
|
3444
3464
|
});
|
|
3445
3465
|
await this.driver.execute("mobile: clearApp", this.appArg()).catch((e) => {
|
|
3446
|
-
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.`);
|
|
3447
3467
|
});
|
|
3448
3468
|
await this.launchApp();
|
|
3449
3469
|
}
|
|
@@ -3510,6 +3530,33 @@ var MobileSession = class _MobileSession {
|
|
|
3510
3530
|
await this.driver.close();
|
|
3511
3531
|
}
|
|
3512
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
|
+
}
|
|
3544
|
+
async function shrink(png, maxPx) {
|
|
3545
|
+
if (process.platform !== "darwin") return png;
|
|
3546
|
+
const dir = fs4.mkdtempSync(path4.join(os4.tmpdir(), "flowriki-shot-"));
|
|
3547
|
+
const file = path4.join(dir, "s.png");
|
|
3548
|
+
try {
|
|
3549
|
+
fs4.writeFileSync(file, png);
|
|
3550
|
+
await new Promise((resolve, reject) => {
|
|
3551
|
+
execFile2("/usr/bin/sips", ["-Z", String(maxPx), file], { timeout: 5e3 }, (error) => error ? reject(error) : resolve());
|
|
3552
|
+
});
|
|
3553
|
+
return fs4.readFileSync(file);
|
|
3554
|
+
} catch {
|
|
3555
|
+
return png;
|
|
3556
|
+
} finally {
|
|
3557
|
+
fs4.rmSync(dir, { recursive: true, force: true });
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3513
3560
|
var ANDROID_KEYS = {
|
|
3514
3561
|
enter: 66,
|
|
3515
3562
|
return: 66,
|
|
@@ -3532,11 +3579,14 @@ function explainStartFailure(message, platform) {
|
|
|
3532
3579
|
return `Appium's ${platform === "android" ? "Android (uiautomator2)" : "iOS (xcuitest)"} driver is not installed \u2014 run \`flowriki-agent mobile setup\`.`;
|
|
3533
3580
|
}
|
|
3534
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)) {
|
|
3535
|
-
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.";
|
|
3536
3586
|
}
|
|
3537
3587
|
if (/JAVA_HOME/i.test(message)) return "Java is needed for Android tests \u2014 install a JDK (Android Studio includes one).";
|
|
3538
3588
|
if (/ANDROID_HOME|ANDROID_SDK_ROOT/i.test(message)) return "The Android SDK was not found \u2014 install Android Studio.";
|
|
3539
|
-
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.`;
|
|
3540
3590
|
return `The app could not be opened: ${message}`;
|
|
3541
3591
|
}
|
|
3542
3592
|
var MOBILE_TIMEOUTS = { step: 15e3, assertion: 5e3 };
|
|
@@ -3799,7 +3849,7 @@ async function chooseDevice(platform, mobile, asked, where) {
|
|
|
3799
3849
|
const picked = pickDevice(devices, platform);
|
|
3800
3850
|
if (picked) return picked;
|
|
3801
3851
|
const farm = mobile.cloud[0];
|
|
3802
|
-
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.`);
|
|
3803
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.");
|
|
3804
3854
|
}
|
|
3805
3855
|
async function openMobileSession(opts) {
|
|
@@ -4166,6 +4216,7 @@ export {
|
|
|
4166
4216
|
checkMobileAssertion,
|
|
4167
4217
|
openSessionOn,
|
|
4168
4218
|
MobileSession,
|
|
4219
|
+
shrink,
|
|
4169
4220
|
MOBILE_TIMEOUTS,
|
|
4170
4221
|
performMobileStep,
|
|
4171
4222
|
runMobileSteps,
|
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-H3YHYPVE.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
|
}
|
|
@@ -40,12 +40,13 @@ import {
|
|
|
40
40
|
run,
|
|
41
41
|
runMobileSteps,
|
|
42
42
|
screenLines,
|
|
43
|
+
shrink,
|
|
43
44
|
stopAppium,
|
|
44
45
|
targetFor,
|
|
45
46
|
textOf,
|
|
46
47
|
toolEnv,
|
|
47
48
|
typesSecrets
|
|
48
|
-
} from "./chunk-
|
|
49
|
+
} from "./chunk-O34WXNH6.js";
|
|
49
50
|
export {
|
|
50
51
|
AGENT_HOME,
|
|
51
52
|
APPIUM_DIR,
|
|
@@ -87,6 +88,7 @@ export {
|
|
|
87
88
|
runMobileSteps,
|
|
88
89
|
run as runTool,
|
|
89
90
|
screenLines,
|
|
91
|
+
shrink,
|
|
90
92
|
stopAppium,
|
|
91
93
|
targetFor,
|
|
92
94
|
textOf,
|
package/dist/engine.js
CHANGED
package/package.json
CHANGED