@flowriki/agent 0.1.0-beta.2 → 0.1.0-beta.4

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.
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/engine.ts
4
- import os4 from "os";
4
+ import os5 from "os";
5
+ import { createRequire as createRequire4 } from "module";
6
+ import { spawnSync } from "child_process";
5
7
 
6
8
  // ../agent-core/dist/index.js
7
9
  import { z as z4 } from "zod";
@@ -11,13 +13,14 @@ import crypto22 from "crypto";
11
13
  import { createRequire as createRequire3 } from "module";
12
14
  import { WebSocket, WebSocketServer } from "ws";
13
15
  import fs23 from "fs";
16
+ import os2 from "os";
14
17
  import path23 from "path";
15
18
 
16
19
  // ../orchestrator/dist/index.js
17
20
  import fs4 from "fs";
18
21
  import path3 from "path";
19
22
 
20
- // ../protocol/dist/chunk-CS3GWM4N.js
23
+ // ../protocol/dist/chunk-ROEX7TWK.js
21
24
  import { z } from "zod";
22
25
  var LocatorOverrideSchema = z.object({
23
26
  strategy: z.enum(["css", "xpath", "id", "testId", "text", "label", "placeholder", "altText", "title"]),
@@ -249,6 +252,9 @@ var RecordedStepSchema = RecordedTargetSchema.extend({
249
252
  * stays unresolved and is reported, never invented. */
250
253
  secretRef: z.string().regex(SECRET_REF_PATTERN).optional()
251
254
  }).strict();
255
+ function slugify(name) {
256
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
257
+ }
252
258
  var CURRENT_SCHEMA_VERSION = 5;
253
259
  var FlowDocumentSchema = z.object({
254
260
  schemaVersion: z.literal(CURRENT_SCHEMA_VERSION),
@@ -572,6 +578,7 @@ var ProjectMarkerSchema = z3.object({
572
578
  /** `v1:ed25519:<base64url>` */
573
579
  signature: z3.string()
574
580
  });
581
+ var PRODUCTION_SERVICE = "https://flowriki-cloud.onrender.com";
575
582
 
576
583
  // ../core/dist/index.js
577
584
  function proposeAssertions(before, after) {
@@ -4200,9 +4207,6 @@ function clampRepeat(value) {
4200
4207
  if (!Number.isFinite(n) || n < 2) return DEFAULT_FLAKINESS_REPEAT;
4201
4208
  return Math.min(n, MAX_FLAKINESS_REPEAT);
4202
4209
  }
4203
- function slugify(name) {
4204
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4205
- }
4206
4210
  function uniqueRef(flowSlug, name, taken) {
4207
4211
  const base = `${flowSlug}_${slugify(name) || "secret"}`;
4208
4212
  if (!taken.has(base)) {
@@ -5427,6 +5431,20 @@ function ensureRootCommit(root) {
5427
5431
  git2(root, ["commit", "--allow-empty", "-m", "Initialise repository"]);
5428
5432
  return git2(root, ["rev-list", "--max-parents=0", "HEAD"]).split("\n").pop();
5429
5433
  }
5434
+ function deliveredRun(delivered) {
5435
+ const { flow, secrets, config } = delivered;
5436
+ return {
5437
+ readConfig: () => config,
5438
+ // By id, and only the one that was sent. A machine serving no checkout has
5439
+ // no other flow to confuse it with.
5440
+ getFlow: (id) => id === flow.id ? flow : null,
5441
+ readSecrets: () => secrets,
5442
+ baseUrlFor: (f) => f.baseUrl ?? config.baseUrl ?? "",
5443
+ environmentFor: (f) => ({ ...config.environment, ...f.environment ?? {} }),
5444
+ // See RunSource.resolveFile: there is no project directory here.
5445
+ resolveFile: () => void 0
5446
+ };
5447
+ }
5430
5448
 
5431
5449
  // ../agent-core/dist/index.js
5432
5450
  var PROTOCOL_VERSION = 1;
@@ -5833,7 +5851,9 @@ var Agent = class {
5833
5851
  `this agent speaks protocol ${PROTOCOL_VERSION}; the command is version ${command.v}`
5834
5852
  );
5835
5853
  }
5836
- const project = this.opts.projects.find((p) => p.id === command.projectId);
5854
+ const delivered = command.args["flow"];
5855
+ const produces = command.name.startsWith("record.") || command.name === "run.cancel";
5856
+ const project = this.opts.projects.find((p) => p.id === command.projectId) ?? (delivered || produces ? { id: command.projectId, name: String(delivered?.name ?? command.projectId), root: "" } : void 0);
5837
5857
  if (!project) {
5838
5858
  return this.refuse(
5839
5859
  command.id,
@@ -6043,16 +6063,39 @@ var AgentLink = class {
6043
6063
  }
6044
6064
  }
6045
6065
  };
6066
+ var HEARTBEAT_MS = 3e4;
6046
6067
  function adapt(socket) {
6047
6068
  let closed = false;
6048
6069
  const closeHandlers = [];
6049
6070
  const fire = () => {
6050
6071
  if (closed) return;
6051
6072
  closed = true;
6073
+ clearInterval(heartbeat);
6052
6074
  for (const handler of closeHandlers) handler();
6053
6075
  };
6054
6076
  socket.on("close", fire);
6055
6077
  socket.on("error", fire);
6078
+ let answered = true;
6079
+ socket.on("pong", () => {
6080
+ answered = true;
6081
+ });
6082
+ const heartbeat = setInterval(() => {
6083
+ if (!answered) {
6084
+ try {
6085
+ socket.terminate();
6086
+ } catch {
6087
+ }
6088
+ fire();
6089
+ return;
6090
+ }
6091
+ answered = false;
6092
+ try {
6093
+ socket.ping();
6094
+ } catch {
6095
+ fire();
6096
+ }
6097
+ }, HEARTBEAT_MS);
6098
+ heartbeat.unref?.();
6056
6099
  return {
6057
6100
  send: (data) => {
6058
6101
  if (socket.readyState !== WebSocket.OPEN) return;
@@ -6104,6 +6147,15 @@ function dialWebSocket(opts) {
6104
6147
  socket.once("error", failed);
6105
6148
  });
6106
6149
  }
6150
+ var scratchRoots = /* @__PURE__ */ new Map();
6151
+ function scratchRoot(projectId) {
6152
+ let root = scratchRoots.get(projectId);
6153
+ if (!root) {
6154
+ root = fs23.mkdtempSync(path23.join(os2.tmpdir(), `flowriki-record-${projectId.replace(/[^\w-]/g, "")}-`));
6155
+ scratchRoots.set(projectId, root);
6156
+ }
6157
+ return root;
6158
+ }
6107
6159
  function buildHandlers(deps) {
6108
6160
  const { storeFor, storage = null, onActivity, lease, recorder } = deps;
6109
6161
  const debugging = deps.debugger;
@@ -6389,14 +6441,22 @@ function buildHandlers(deps) {
6389
6441
  if (command.flowRevision === void 0) {
6390
6442
  return { ok: false, reason: "stale_revision", detail: "reload the flow and supply its revision before running" };
6391
6443
  }
6392
- const store = storeFor(project.root);
6393
6444
  const flowId = String(command.args["flowId"] ?? "");
6394
- const flow = store.getFlow(flowId);
6445
+ const carried = command.args["flow"];
6446
+ const source = carried ? deliveredRun({
6447
+ flow: carried,
6448
+ secrets: command.args["secrets"] ?? {},
6449
+ config: command.args["config"] ?? {}
6450
+ }) : storeFor(project.root);
6451
+ if (!carried && !project.root) {
6452
+ return { ok: false, reason: "failed", detail: "this machine has no checkout of that project" };
6453
+ }
6454
+ const flow = source.getFlow(flowId);
6395
6455
  if (!flow) return { ok: false, reason: "failed", detail: `no flow ${flowId} in this project` };
6396
6456
  if (flow.revision !== command.flowRevision) {
6397
6457
  return { ok: false, reason: "stale_revision", detail: "the flow changed \u2014 reload it before running" };
6398
6458
  }
6399
- const missing = store.unresolvedSecrets(flow);
6459
+ const missing = carried ? [] : storeFor(project.root).unresolvedSecrets(flow);
6400
6460
  if (missing.length) {
6401
6461
  return {
6402
6462
  ok: false,
@@ -6418,7 +6478,7 @@ function buildHandlers(deps) {
6418
6478
  signal: aborter.signal,
6419
6479
  ...typeof command.args["headless"] === "boolean" ? { headless: command.args["headless"] } : {},
6420
6480
  ...command.args["repeat"] ? { repeat: Number(command.args["repeat"]) } : {},
6421
- store,
6481
+ store: source,
6422
6482
  storage,
6423
6483
  flowId,
6424
6484
  expectedRevision: command.flowRevision,
@@ -6473,14 +6533,28 @@ function buildHandlers(deps) {
6473
6533
  const url = optional("url");
6474
6534
  const route = optional("path");
6475
6535
  const recordedBy = optional("recordedBy");
6536
+ const headless = typeof command.args["headless"] === "boolean" ? command.args["headless"] : void 0;
6537
+ const root = project.root || scratchRoot(project.id);
6538
+ if (!project.root) {
6539
+ if (!/^f_[a-z0-9]+(?:-[a-z0-9]+)*$/.test(featureId)) throw new Error("Invalid feature ID");
6540
+ const workspace = new Store(root);
6541
+ if (!workspace.listFeatures().some((feature) => feature.id === featureId)) workspace.createFeature(featureId.slice(2));
6542
+ }
6476
6543
  const started = await recorder.start({
6477
- root: project.root,
6544
+ // A SCRATCH DIRECTORY when this machine has no checkout of the
6545
+ // project. The recorder writes as it goes — that is what makes a
6546
+ // half-finished recording survivable — and the flow it produces is
6547
+ // returned on stop for the service to commit to the repository. A
6548
+ // machine that has the project checked out still records into it.
6549
+ root,
6550
+ projectId: project.id,
6478
6551
  featureId,
6479
6552
  name,
6480
6553
  ...url ? { url } : {},
6481
6554
  ...route ? { path: route } : {},
6482
6555
  ...recordedBy ? { recordedBy } : {},
6483
- ...command.args["environment"] ? { environment: command.args["environment"] } : {}
6556
+ ...command.args["environment"] ? { environment: command.args["environment"] } : {},
6557
+ ...headless === void 0 ? {} : { headless }
6484
6558
  });
6485
6559
  lease?.activate("run");
6486
6560
  onActivity?.({ kind: "recording", projectId: project.id, detail: name });
@@ -6495,7 +6569,7 @@ function buildHandlers(deps) {
6495
6569
  "record.stop": async (_command, project) => {
6496
6570
  if (!recorder) return { ok: false, reason: "failed", detail: "this agent cannot record \u2014 no browser is configured" };
6497
6571
  try {
6498
- const saved = await recorder.stop();
6572
+ const saved = await recorder.stop(project.id);
6499
6573
  if (!saved.ok) return { ok: false, reason: "failed", detail: saved.error };
6500
6574
  return { ok: true, result: { flow: saved.flow, projectId: project.id } };
6501
6575
  } finally {
@@ -6572,12 +6646,15 @@ function createAgentRuntime(options) {
6572
6646
  let journal = null;
6573
6647
  const coordinators = /* @__PURE__ */ new Map();
6574
6648
  let recordingIn = null;
6649
+ let recordingProject = null;
6650
+ let lastRecording = null;
6575
6651
  const coordinatorFor = (projectRoot) => {
6576
6652
  let existing = coordinators.get(projectRoot);
6577
6653
  if (!existing) {
6578
6654
  existing = new RecordingCoordinator({
6579
6655
  store: new Store(projectRoot),
6580
6656
  emit: (event) => {
6657
+ if (event.type === "recording.stopped") lastRecording = { ok: true, flow: event.flow };
6581
6658
  journal?.append({ name: `recording.${event.type.replace(/^recording\./, "")}`, data: event });
6582
6659
  }
6583
6660
  });
@@ -6636,13 +6713,17 @@ function createAgentRuntime(options) {
6636
6713
  const coordinator = coordinatorFor(opts.root);
6637
6714
  const { root: _root, ...rest } = opts;
6638
6715
  const started = await coordinator.start(rest);
6716
+ recordingProject = opts["projectId"];
6717
+ lastRecording = null;
6639
6718
  recordingIn = coordinator;
6640
6719
  return started;
6641
6720
  },
6642
- stop: async () => {
6643
- if (!recordingIn) return { ok: false, error: "no recording is in progress on this machine" };
6721
+ stop: async (projectId) => {
6722
+ if (projectId && recordingProject && projectId !== recordingProject) return { ok: false, error: "This recording belongs to another project" };
6723
+ if (lastRecording?.ok) return lastRecording;
6724
+ if (!recordingIn) return lastRecording ?? { ok: false, error: "no recording is in progress on this machine" };
6644
6725
  const result = await recordingIn.stop();
6645
- recordingIn = null;
6726
+ lastRecording = result;
6646
6727
  return result;
6647
6728
  },
6648
6729
  discard: async () => {
@@ -6708,20 +6789,20 @@ import fs7 from "fs";
6708
6789
 
6709
6790
  // src/paths.ts
6710
6791
  import fs6 from "fs";
6711
- import os2 from "os";
6792
+ import os3 from "os";
6712
6793
  import path5 from "path";
6713
6794
  function dataDir(env = process.env) {
6714
6795
  const override = env["FLOWRIKI_HOME"]?.trim();
6715
6796
  if (override) return path5.resolve(override);
6716
6797
  if (process.platform === "win32") {
6717
6798
  const appData = env["APPDATA"]?.trim();
6718
- return appData ? path5.join(appData, "Flowriki") : path5.join(os2.homedir(), "AppData", "Roaming", "Flowriki");
6799
+ return appData ? path5.join(appData, "Flowriki") : path5.join(os3.homedir(), "AppData", "Roaming", "Flowriki");
6719
6800
  }
6720
6801
  if (process.platform === "darwin") {
6721
- return path5.join(os2.homedir(), "Library", "Application Support", "Flowriki");
6802
+ return path5.join(os3.homedir(), "Library", "Application Support", "Flowriki");
6722
6803
  }
6723
6804
  const state = env["XDG_STATE_HOME"]?.trim();
6724
- return state ? path5.join(state, "flowriki") : path5.join(os2.homedir(), ".local", "state", "flowriki");
6805
+ return state ? path5.join(state, "flowriki") : path5.join(os3.homedir(), ".local", "state", "flowriki");
6725
6806
  }
6726
6807
  function ensureDataDir(env = process.env) {
6727
6808
  const dir = dataDir(env);
@@ -6788,7 +6869,6 @@ function forgetConnection(service, env = process.env) {
6788
6869
  var fingerprint = (secret) => crypto3.createHash("sha256").update(secret).digest("hex").slice(0, 12);
6789
6870
 
6790
6871
  // src/service.ts
6791
- var PRODUCTION_SERVICE = "https://flowriki-cloud.onrender.com";
6792
6872
  var BadServiceUrl = class extends Error {
6793
6873
  };
6794
6874
  function resolveService(explicit, env = process.env) {
@@ -6811,11 +6891,11 @@ function resolveService(explicit, env = process.env) {
6811
6891
 
6812
6892
  // src/lock.ts
6813
6893
  import fs8 from "fs";
6814
- import os3 from "os";
6894
+ import os4 from "os";
6815
6895
  function takeLock(env = process.env) {
6816
6896
  ensureDataDir(env);
6817
6897
  const file = lockPath(env);
6818
- const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os3.hostname() };
6898
+ const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os4.hostname() };
6819
6899
  for (let attempt = 0; attempt < 2; attempt += 1) {
6820
6900
  try {
6821
6901
  fs8.writeFileSync(file, JSON.stringify(mine), { flag: "wx", mode: 384 });
@@ -6891,7 +6971,7 @@ function openBrowser(url, env = process.env) {
6891
6971
  }
6892
6972
 
6893
6973
  // src/engine.ts
6894
- var AGENT_VERSION = "0.1.0-beta.2";
6974
+ var AGENT_VERSION = "0.1.0-beta.4";
6895
6975
  async function connect(options = {}) {
6896
6976
  let service;
6897
6977
  try {
@@ -6908,7 +6988,7 @@ async function connect(options = {}) {
6908
6988
  message: "the stored connection is no longer accepted \u2014 asking for a new one"
6909
6989
  });
6910
6990
  }
6911
- const machineName = options.machineName?.trim() || `${os4.userInfo().username}@${os4.hostname()}`;
6991
+ const machineName = options.machineName?.trim() || `${os5.userInfo().username}@${os5.hostname()}`;
6912
6992
  let asked;
6913
6993
  try {
6914
6994
  const res = await fetch(`${service}/api/pairing-requests`, {
@@ -7015,6 +7095,13 @@ async function start(options = {}) {
7015
7095
  message: `this installation is not connected to ${service} yet`
7016
7096
  };
7017
7097
  }
7098
+ if (!browsersReady()) {
7099
+ options.onStatus?.("preparing", "downloading the browser Flowriki records with \u2014 this happens once");
7100
+ const prepared = await prepareBrowsers();
7101
+ if (!prepared.ok) {
7102
+ return { ok: false, reason: "refused", message: `this machine cannot record yet \u2014 ${prepared.message}` };
7103
+ }
7104
+ }
7018
7105
  const lock = takeLock();
7019
7106
  if (!lock.ok) {
7020
7107
  return {
@@ -7061,6 +7148,36 @@ async function start(options = {}) {
7061
7148
  }
7062
7149
  };
7063
7150
  }
7151
+ async function prepareBrowsers() {
7152
+ if (browsersReady()) {
7153
+ return { ok: true, already: true, message: "chromium is already installed" };
7154
+ }
7155
+ let cli;
7156
+ try {
7157
+ cli = createRequire4(import.meta.url).resolve("playwright/cli.js");
7158
+ } catch {
7159
+ return {
7160
+ ok: false,
7161
+ already: false,
7162
+ message: "playwright is not installed beside this agent \u2014 reinstall it"
7163
+ };
7164
+ }
7165
+ const run = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
7166
+ if (run.status !== 0) {
7167
+ return {
7168
+ ok: false,
7169
+ already: false,
7170
+ message: `could not install chromium (exit ${run.status ?? "unknown"}) \u2014 try \`npx playwright@${playwrightVersion() ?? "latest"} install chromium\` yourself`
7171
+ };
7172
+ }
7173
+ return { ok: browsersReady(), already: false, message: "chromium installed" };
7174
+ }
7175
+ function browsersReady() {
7176
+ return inspectPlaywright().chromiumInstalled === true;
7177
+ }
7178
+ function playwrightVersion() {
7179
+ return inspectPlaywright().version;
7180
+ }
7064
7181
  function status(serviceArg) {
7065
7182
  const service = resolveService(serviceArg);
7066
7183
  const connection = connectionFor(service);
@@ -7122,7 +7239,7 @@ async function doctor(serviceArg) {
7122
7239
  }
7123
7240
  function inspectPlaywright() {
7124
7241
  try {
7125
- const browsersPath = process.env["PLAYWRIGHT_BROWSERS_PATH"] ?? (process.platform === "win32" ? path6.join(os4.homedir(), "AppData", "Local", "ms-playwright") : process.platform === "darwin" ? path6.join(os4.homedir(), "Library", "Caches", "ms-playwright") : path6.join(os4.homedir(), ".cache", "ms-playwright"));
7242
+ const browsersPath = process.env["PLAYWRIGHT_BROWSERS_PATH"] ?? (process.platform === "win32" ? path6.join(os5.homedir(), "AppData", "Local", "ms-playwright") : process.platform === "darwin" ? path6.join(os5.homedir(), "Library", "Caches", "ms-playwright") : path6.join(os5.homedir(), ".cache", "ms-playwright"));
7126
7243
  const chromiumInstalled = fs9.existsSync(browsersPath) ? fs9.readdirSync(browsersPath).some((d) => d.startsWith("chromium")) : false;
7127
7244
  return { version: null, browsersPath, chromiumInstalled };
7128
7245
  } catch {
@@ -7140,6 +7257,8 @@ export {
7140
7257
  connect,
7141
7258
  resolveCheckouts,
7142
7259
  start,
7260
+ prepareBrowsers,
7261
+ browsersReady,
7143
7262
  status,
7144
7263
  doctor,
7145
7264
  disconnect
package/dist/cli.js CHANGED
@@ -4,10 +4,11 @@ import {
4
4
  connect,
5
5
  disconnect,
6
6
  doctor,
7
+ prepareBrowsers,
7
8
  resolveCheckouts,
8
9
  start,
9
10
  status
10
- } from "./chunk-EYRGRDNB.js";
11
+ } from "./chunk-7JLRYAQ2.js";
11
12
 
12
13
  // src/cli.ts
13
14
  import { parseArgs } from "util";
@@ -27,6 +28,7 @@ ${bold("USAGE")}
27
28
  flowriki-agent start run the agent (stays in the foreground)
28
29
  flowriki-agent status is it connected, and what is prepared
29
30
  flowriki-agent doctor diagnose setup problems
31
+ flowriki-agent prepare download the browser recording needs
30
32
  flowriki-agent disconnect forget this computer's credential
31
33
 
32
34
  ${bold("OPTIONS")}
@@ -129,8 +131,7 @@ Run \`flowriki-agent --help\`.`);
129
131
  console.log(`${bold("Flowriki agent")} ${dim(AGENT_VERSION)}`);
130
132
  console.log(dim(` connected as ${started.connection.machineName}`));
131
133
  if (started.projects.length === 0) {
132
- console.log(yellow(" no projects prepared \u2014 this machine is online and idle"));
133
- console.log(dim(" Add one: flowriki-agent start --project /path/to/a/checkout"));
134
+ console.log(dim(" ready \u2014 this machine records and runs whatever the dashboard asks for"));
134
135
  }
135
136
  for (const project of started.projects) {
136
137
  console.log(dim(` project ${project.name} (${project.root})`));
@@ -150,6 +151,11 @@ Run \`flowriki-agent --help\`.`);
150
151
  return await new Promise(() => {
151
152
  });
152
153
  }
154
+ case "prepare": {
155
+ const result = await prepareBrowsers();
156
+ console.log(result.ok ? green(result.message) : red(result.message));
157
+ return result.ok ? 0 : 1;
158
+ }
153
159
  case "status": {
154
160
  const state = status(service);
155
161
  if (values.json) {
package/dist/engine.js CHANGED
@@ -1,18 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  AGENT_VERSION,
4
+ browsersReady,
4
5
  connect,
5
6
  disconnect,
6
7
  doctor,
8
+ prepareBrowsers,
7
9
  resolveCheckouts,
8
10
  start,
9
11
  status
10
- } from "./chunk-EYRGRDNB.js";
12
+ } from "./chunk-7JLRYAQ2.js";
11
13
  export {
12
14
  AGENT_VERSION,
15
+ browsersReady,
13
16
  connect,
14
17
  disconnect,
15
18
  doctor,
19
+ prepareBrowsers,
16
20
  resolveCheckouts,
17
21
  start,
18
22
  status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowriki/agent",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Run Flowriki UI tests on your own computer. Connects this machine to your Flowriki account.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",