@flowriki/agent 0.1.0-beta.7 → 0.1.0-beta.9

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,33 +1,449 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/engine.ts
4
- import os5 from "os";
5
- import { createRequire as createRequire4 } from "module";
3
+ // src/engine/machine.ts
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ import { createRequire } from "module";
6
8
  import { spawnSync } from "child_process";
9
+ var AGENT_VERSION = "0.1.0-beta.9";
10
+ async function prepareBrowsers() {
11
+ if (browsersReady()) {
12
+ return { ok: true, already: true, message: "chromium is already installed" };
13
+ }
14
+ let cli;
15
+ try {
16
+ cli = createRequire(import.meta.url).resolve("playwright/cli.js");
17
+ } catch {
18
+ return {
19
+ ok: false,
20
+ already: false,
21
+ message: "playwright is not installed beside this agent \u2014 reinstall it"
22
+ };
23
+ }
24
+ const run = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
25
+ if (run.status !== 0) {
26
+ return {
27
+ ok: false,
28
+ already: false,
29
+ message: `could not install chromium (exit ${run.status ?? "unknown"}) \u2014 try \`npx playwright@${playwrightVersion() ?? "latest"} install chromium\` yourself`
30
+ };
31
+ }
32
+ return { ok: browsersReady(), already: false, message: "chromium installed" };
33
+ }
34
+ function browsersReady() {
35
+ return inspectPlaywright().chromiumInstalled === true;
36
+ }
37
+ function playwrightVersion() {
38
+ return inspectPlaywright().version;
39
+ }
40
+ function inspectPlaywright() {
41
+ try {
42
+ const browsersPath = process.env["PLAYWRIGHT_BROWSERS_PATH"] ?? (process.platform === "win32" ? path.join(os.homedir(), "AppData", "Local", "ms-playwright") : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Caches", "ms-playwright") : path.join(os.homedir(), ".cache", "ms-playwright"));
43
+ const chromiumInstalled = fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath).some((d) => d.startsWith("chromium")) : false;
44
+ return { version: null, browsersPath, chromiumInstalled };
45
+ } catch {
46
+ return { version: null, browsersPath: null, chromiumInstalled: null };
47
+ }
48
+ }
7
49
 
8
- // ../agent-core/dist/index.js
9
- import { z as z4 } from "zod";
10
- import fs6 from "fs";
11
- import path5 from "path";
12
- import crypto22 from "crypto";
13
- import { createRequire as createRequire3 } from "module";
14
- import { WebSocket, WebSocketServer } from "ws";
15
- import fs23 from "fs";
50
+ // src/engine/connection.ts
51
+ import os3 from "os";
52
+
53
+ // src/credentials.ts
54
+ import crypto2 from "crypto";
55
+ import fs3 from "fs";
56
+
57
+ // src/paths.ts
58
+ import fs2 from "fs";
16
59
  import os2 from "os";
17
- import path24 from "path";
60
+ import path2 from "path";
61
+ function dataDir(env = process.env) {
62
+ const override = env["FLOWRIKI_HOME"]?.trim();
63
+ if (override) return path2.resolve(override);
64
+ if (process.platform === "win32") {
65
+ const appData = env["APPDATA"]?.trim();
66
+ return appData ? path2.join(appData, "Flowriki") : path2.join(os2.homedir(), "AppData", "Roaming", "Flowriki");
67
+ }
68
+ if (process.platform === "darwin") {
69
+ return path2.join(os2.homedir(), "Library", "Application Support", "Flowriki");
70
+ }
71
+ const state = env["XDG_STATE_HOME"]?.trim();
72
+ return state ? path2.join(state, "flowriki") : path2.join(os2.homedir(), ".local", "state", "flowriki");
73
+ }
74
+ function ensureDataDir(env = process.env) {
75
+ const dir = dataDir(env);
76
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
77
+ if (process.platform !== "win32") {
78
+ try {
79
+ fs2.chmodSync(dir, 448);
80
+ } catch {
81
+ }
82
+ }
83
+ return dir;
84
+ }
85
+ var credentialsPath = (env = process.env) => path2.join(dataDir(env), "credentials.json");
86
+ var lockPath = (env = process.env) => path2.join(dataDir(env), "agent.lock");
87
+ var ledgerPath = (deviceId, env = process.env) => path2.join(dataDir(env), `ledger-${deviceId}.sqlite`);
88
+
89
+ // src/credentials.ts
90
+ function readFile(env = process.env) {
91
+ try {
92
+ const raw = JSON.parse(fs3.readFileSync(credentialsPath(env), "utf8"));
93
+ if (Array.isArray(raw.connections)) return { version: 3, connections: raw.connections };
94
+ const older = Array.isArray(raw.devices) ? raw.devices : raw.deviceId ? [raw] : [];
95
+ const connections = older.filter((d) => d.deviceId && d.secret).map((d) => ({
96
+ service: (d.url ?? "").replace(/^ws/, "http").replace(/\/agent$/, ""),
97
+ deviceId: d.deviceId,
98
+ secret: d.secret,
99
+ machineName: "this machine",
100
+ connectedAt: d.pairedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
101
+ ...d.checkouts ? { checkouts: d.checkouts } : {}
102
+ })).filter((c) => c.service);
103
+ return { version: 3, connections };
104
+ } catch {
105
+ return { version: 3, connections: [] };
106
+ }
107
+ }
108
+ function connectionFor(service, env = process.env) {
109
+ return readFile(env).connections.find((c) => c.service === service) ?? null;
110
+ }
111
+ function allConnections(env = process.env) {
112
+ return readFile(env).connections;
113
+ }
114
+ function saveConnection(connection, env = process.env) {
115
+ ensureDataDir(env);
116
+ const file = credentialsPath(env);
117
+ const next = {
118
+ version: 3,
119
+ connections: [...readFile(env).connections.filter((c) => c.service !== connection.service), connection]
120
+ };
121
+ const temporary = `${file}.${process.pid}.tmp`;
122
+ fs3.writeFileSync(temporary, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
123
+ fs3.renameSync(temporary, file);
124
+ }
125
+ function forgetConnection(service, env = process.env) {
126
+ const before = readFile(env).connections;
127
+ const after = before.filter((c) => c.service !== service);
128
+ if (after.length === before.length) return false;
129
+ ensureDataDir(env);
130
+ const file = credentialsPath(env);
131
+ const temporary = `${file}.${process.pid}.tmp`;
132
+ fs3.writeFileSync(temporary, JSON.stringify({ version: 3, connections: after }, null, 2) + "\n", { mode: 384 });
133
+ fs3.renameSync(temporary, file);
134
+ return true;
135
+ }
136
+ var fingerprint = (secret) => crypto2.createHash("sha256").update(secret).digest("hex").slice(0, 12);
137
+
138
+ // ../contracts/dist/chunk-PMU57KFW.js
139
+ import { z } from "zod";
140
+ var PROTOCOL_VERSION = 1;
141
+ var PAIRING_CODE_TTL_MS = 5 * 60 * 1e3;
142
+ var JOURNAL_LIMIT = 1e3;
143
+ var CommandNameSchema = z.enum([
144
+ "record.start",
145
+ "record.stop",
146
+ "record.discard",
147
+ "record.status",
148
+ "record.dialog",
149
+ "flow.run",
150
+ "flow.save",
151
+ "flow.update",
152
+ "flow.delete",
153
+ "flow.verify",
154
+ "flow.get",
155
+ "debug.start",
156
+ "debug.status",
157
+ "debug.command",
158
+ "debug.end",
159
+ "config.save",
160
+ "run.cancel",
161
+ "feature.create",
162
+ "secret.set",
163
+ "project.list",
164
+ "project.status",
165
+ "project.claim",
166
+ "runs.list",
167
+ "runs.stats",
168
+ "run.get",
169
+ "artifact.fetch"
170
+ ]);
171
+ var EFFECTFUL = /* @__PURE__ */ new Set([
172
+ "record.start",
173
+ "record.stop",
174
+ "record.discard",
175
+ // Answering a dialog is a click on the page under test. Re-delivering it
176
+ // after a dropped socket would answer the NEXT dialog with the previous
177
+ // reply — `record.status` stays out of this set because reading is safe to
178
+ // repeat, and that is the whole distinction.
179
+ "record.dialog",
180
+ "flow.run",
181
+ "flow.save",
182
+ "flow.update",
183
+ "flow.delete",
184
+ // Verification and a debug session both open a browser on somebody's machine
185
+ // and both can be told to record steps into a draft. Neither is a read.
186
+ "flow.verify",
187
+ "debug.start",
188
+ "debug.command",
189
+ "debug.end",
190
+ "config.save",
191
+ // Setting a secret writes a file. Idempotent — the same value written twice
192
+ // is the same value — but it is a write, and the deduplication path is the
193
+ // right default for one.
194
+ "secret.set",
195
+ // Creating a feature makes a directory and a file. A blind retry after a
196
+ // dropped socket would find it already there and report a failure for
197
+ // something that had in fact succeeded.
198
+ "feature.create",
199
+ // Claiming writes a file, makes a commit and pushes. Blind retry after a
200
+ // dropped socket would commit twice.
201
+ "project.claim",
202
+ "run.cancel"
203
+ ]);
204
+ var CommandSchema = z.object({
205
+ /** Unique per command. The agent stores it, so a redelivery after a
206
+ * reconnect is recognised rather than performed twice. */
207
+ id: z.string().min(1).max(128),
208
+ v: z.number().int().positive(),
209
+ name: CommandNameSchema,
210
+ /** Which project on the agent's machine. Never a path — a name the user has
211
+ * explicitly mapped, so the service cannot reach arbitrary files. */
212
+ projectId: z.string().min(1),
213
+ /** The revision the caller believes it is acting on, where that matters. A
214
+ * save built on a stale copy is refused rather than applied. */
215
+ flowRevision: z.number().int().nonnegative().optional(),
216
+ /** Wall-clock expiry. A command that surfaced from a queue an hour late must
217
+ * not start a browser on somebody's machine. */
218
+ expiresAt: z.number().int().positive(),
219
+ /** When the service stamped this, by the service's clock.
220
+ *
221
+ * Present so the agent never has to compare a timestamp minted on another
222
+ * machine against its own clock. `expiresAt` alone made the deadline
223
+ * meaningless the moment two clocks disagreed: a machine running five
224
+ * minutes fast rejected every command the instant it arrived, saying it had
225
+ * expired before it got there — which is true only if you believe both
226
+ * clocks. Optional, so an older service that does not send it still works;
227
+ * the agent falls back to the absolute deadline it has always used. */
228
+ issuedAt: z.number().int().positive().optional(),
229
+ args: z.record(z.unknown()).default({})
230
+ // UNKNOWN FIELDS ARE IGNORED, not refused.
231
+ //
232
+ // Strictness here looked like caution and was a compatibility trap: the day
233
+ // the service added `issuedAt`, every agent older than that release began
234
+ // refusing every command as unrecognised — over a field it had no need to
235
+ // read. The allowlist on command NAMES is the security boundary and is
236
+ // unchanged; which extra keys ride along is not, because nothing acts on a
237
+ // field it does not know about.
238
+ });
239
+ var AckSchema = z.object({
240
+ type: z.literal("ack"),
241
+ commandId: z.string().min(1),
242
+ /** `accepted` means the agent has taken the work on, not that it finished.
243
+ * `duplicate` means it has done this exact command before and is returning
244
+ * the first outcome. */
245
+ status: z.enum(["accepted", "duplicate", "refused"]),
246
+ reason: z.string().optional(),
247
+ result: z.record(z.unknown()).optional()
248
+ }).strict();
249
+ var AgentEventSchema = z.object({
250
+ type: z.literal("event"),
251
+ /** Monotonic per agent connection-independent stream. A client that
252
+ * reconnects says which sequence it last saw and is sent the rest. */
253
+ seq: z.number().int().nonnegative(),
254
+ at: z.number().int().nonnegative(),
255
+ name: z.string().min(1),
256
+ commandId: z.string().optional(),
257
+ sessionId: z.string().optional(),
258
+ runId: z.string().optional(),
259
+ data: z.record(z.unknown()).default({})
260
+ }).strict();
261
+ var GapSchema = z.object({
262
+ type: z.literal("gap"),
263
+ from: z.number().int().nonnegative(),
264
+ to: z.number().int().nonnegative()
265
+ }).strict();
266
+ var HelloSchema = z.object({
267
+ type: z.literal("hello"),
268
+ protocolVersion: z.number().int().positive(),
269
+ agentVersion: z.string(),
270
+ deviceId: z.string().min(1),
271
+ projects: z.array(z.object({ id: z.string(), name: z.string() })).default([])
272
+ }).strict();
273
+ var AgentMessageSchema = z.discriminatedUnion("type", [
274
+ AckSchema,
275
+ AgentEventSchema,
276
+ GapSchema,
277
+ HelloSchema
278
+ ]);
279
+
280
+ // ../contracts/dist/index.js
281
+ import { z as z2 } from "zod";
282
+ var MARKER_PATH = ".flowriki/project.json";
283
+ var ProjectMarkerSchema = z2.object({
284
+ schemaVersion: z2.literal(1),
285
+ projectId: z2.string().min(1),
286
+ /** Shown to people, and NOT covered by the signature.
287
+ *
288
+ * Deliberate: projects get renamed, and signing the name would invalidate the
289
+ * marker in every existing clone the moment somebody did. Anything reading
290
+ * this must present it as what the file says rather than as something
291
+ * checked - the id is the verified identity. */
292
+ name: z2.string(),
293
+ boundAt: z2.string(),
294
+ /** The repository's first commit at the moment of binding.
295
+ *
296
+ * This is what ties the marker to THIS history rather than to any folder
297
+ * somebody pastes it into. Null only where the repository had no commits at
298
+ * all when it was claimed. */
299
+ rootCommit: z2.string().nullable(),
300
+ /** `v1:ed25519:<base64url>` */
301
+ signature: z2.string()
302
+ });
303
+ var PRODUCTION_SERVICE = "https://flowriki-cloud.onrender.com";
304
+
305
+ // src/service.ts
306
+ var BadServiceUrl = class extends Error {
307
+ };
308
+ function resolveService(explicit, env = process.env) {
309
+ const raw = explicit?.trim() || env["FLOWRIKI_SERVICE_URL"]?.trim() || PRODUCTION_SERVICE;
310
+ let url;
311
+ try {
312
+ url = new URL(raw);
313
+ } catch {
314
+ throw new BadServiceUrl(`"${raw}" is not a URL \u2014 expected something like https://flowriki.example`);
315
+ }
316
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
317
+ throw new BadServiceUrl(`"${raw}" is not http or https`);
318
+ }
319
+ const loopback = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname);
320
+ if (url.protocol === "http:" && !loopback) {
321
+ throw new BadServiceUrl(`refusing to use plain http for ${url.hostname} \u2014 a device credential would cross the network in the clear`);
322
+ }
323
+ return url.origin + (url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""));
324
+ }
325
+
326
+ // src/open-browser.ts
327
+ import { spawn } from "child_process";
328
+ function canOpenBrowser(env = process.env) {
329
+ if (process.platform === "win32" || process.platform === "darwin") return true;
330
+ return Boolean(env["DISPLAY"] || env["WAYLAND_DISPLAY"]);
331
+ }
332
+ function openBrowser(url, env = process.env) {
333
+ if (!canOpenBrowser(env)) return "no-display";
334
+ try {
335
+ if (process.platform === "win32") {
336
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true, windowsHide: true }).unref();
337
+ } else if (process.platform === "darwin") {
338
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
339
+ } else {
340
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
341
+ }
342
+ return "opened";
343
+ } catch {
344
+ return "failed";
345
+ }
346
+ }
347
+
348
+ // src/engine/connection.ts
349
+ async function connect(options = {}) {
350
+ let service;
351
+ try {
352
+ service = resolveService(options.service);
353
+ } catch (error) {
354
+ return { ok: false, reason: "refused", message: error.message };
355
+ }
356
+ const existing = connectionFor(service);
357
+ if (existing) {
358
+ const still = await verifyConnection(existing).catch(() => false);
359
+ if (still) return { ok: true, connection: existing, reused: true };
360
+ options.onProgress?.({
361
+ kind: "stale",
362
+ message: "the stored connection is no longer accepted \u2014 asking for a new one"
363
+ });
364
+ }
365
+ const machineName = options.machineName?.trim() || `${os3.userInfo().username}@${os3.hostname()}`;
366
+ let asked;
367
+ try {
368
+ const res = await fetch(`${service}/api/pairing-requests`, {
369
+ method: "POST",
370
+ headers: { "content-type": "application/json" },
371
+ body: JSON.stringify({ machineName, agentVersion: AGENT_VERSION }),
372
+ signal: options.signal ?? null
373
+ });
374
+ if (!res.ok) {
375
+ return { ok: false, reason: "refused", message: `${service} refused the request (${res.status})` };
376
+ }
377
+ asked = await res.json();
378
+ } catch (error) {
379
+ return { ok: false, reason: "unreachable", message: `could not reach ${service} \u2014 ${error.message}` };
380
+ }
381
+ const approve = `${service}${asked.approveUrl}`;
382
+ options.onProgress?.({
383
+ kind: "approve",
384
+ message: "waiting for approval in your browser",
385
+ detail: { url: approve, machineName, canOpen: options.open !== false && canOpenBrowser() }
386
+ });
387
+ if (options.open !== false) openBrowser(approve);
388
+ const pollMs = options.pollMs ?? 1500;
389
+ const deadline = Date.now() + (options.timeoutMs ?? 10 * 60 * 1e3);
390
+ while (Date.now() < deadline) {
391
+ if (options.signal?.aborted) return { ok: false, reason: "aborted", message: "cancelled" };
392
+ const res = await fetch(`${service}/api/pairing-requests/${encodeURIComponent(asked.requestId)}/claim`, {
393
+ method: "POST",
394
+ headers: { "content-type": "application/json" },
395
+ body: JSON.stringify({ claim: asked.claim })
396
+ }).catch(() => null);
397
+ if (res?.status === 200) {
398
+ const granted = await res.json();
399
+ const connection = {
400
+ service,
401
+ deviceId: granted.deviceId,
402
+ secret: granted.secret,
403
+ machineName,
404
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
405
+ ...existing?.checkouts ? { checkouts: existing.checkouts } : {}
406
+ };
407
+ saveConnection(connection);
408
+ return { ok: true, connection, reused: false };
409
+ }
410
+ if (res && res.status !== 202) {
411
+ const body = await res.json().catch(() => ({}));
412
+ return { ok: false, reason: "refused", message: body.error ?? `refused (${res.status})` };
413
+ }
414
+ await new Promise((r) => setTimeout(r, pollMs));
415
+ }
416
+ return { ok: false, reason: "expired", message: "nobody approved this in time" };
417
+ }
418
+ async function verifyConnection(connection) {
419
+ const res = await fetch(`${connection.service}/api/agent-check`, {
420
+ method: "POST",
421
+ headers: { "content-type": "application/json" },
422
+ body: JSON.stringify({ deviceId: connection.deviceId, secret: connection.secret })
423
+ });
424
+ if (res.status === 404) return true;
425
+ return res.ok;
426
+ }
427
+
428
+ // src/engine/checkouts.ts
429
+ import fs8 from "fs";
430
+ import path8 from "path";
431
+
432
+ // ../execution/dist/index.js
433
+ import fs7 from "fs";
434
+ import path7 from "path";
18
435
 
19
- // ../orchestrator/dist/index.js
20
- import fs5 from "fs";
21
- import path4 from "path";
436
+ // ../engine-playwright/dist/index.js
437
+ import { chromium, firefox, webkit } from "playwright";
22
438
 
23
- // ../protocol/dist/chunk-ROEX7TWK.js
24
- import { z } from "zod";
25
- var LocatorOverrideSchema = z.object({
26
- strategy: z.enum(["css", "xpath", "id", "testId", "text", "label", "placeholder", "altText", "title"]),
27
- value: z.string()
439
+ // ../test-model/dist/chunk-ROEX7TWK.js
440
+ import { z as z3 } from "zod";
441
+ var LocatorOverrideSchema = z3.object({
442
+ strategy: z3.enum(["css", "xpath", "id", "testId", "text", "label", "placeholder", "altText", "title"]),
443
+ value: z3.string()
28
444
  }).strict();
29
- var CaptureModeSchema = z.enum(["off", "on-failure", "always"]);
30
- var ArtifactPolicySchema = z.object({
445
+ var CaptureModeSchema = z3.enum(["off", "on-failure", "always"]);
446
+ var ArtifactPolicySchema = z3.object({
31
447
  /** Masked before the shutter opens — password fields are covered. */
32
448
  screenshot: CaptureModeSchema.optional(),
33
449
  /** NOT maskable. A video shows every character typed, so a flow holding a
@@ -38,33 +454,33 @@ var ArtifactPolicySchema = z.object({
38
454
  trace: CaptureModeSchema.optional(),
39
455
  /** Cheap, small, and the first thing anyone asks for when a step fails for
40
456
  * no visible reason. */
41
- console: z.boolean().optional(),
457
+ console: z3.boolean().optional(),
42
458
  /** How many runs keep their artifacts. Video is megabytes per run, so an
43
459
  * unbounded history fills a disk quietly. */
44
- retainRuns: z.number().int().positive().max(1e4).optional(),
460
+ retainRuns: z3.number().int().positive().max(1e4).optional(),
45
461
  /** Deliberate opt-out of the secret guard, for a project with nothing to
46
462
  * protect. Off by default: leaking a password into a video that nobody
47
463
  * thinks of as sensitive is not a mistake worth making by accident. */
48
- allowUnmaskedArtifacts: z.boolean().optional()
464
+ allowUnmaskedArtifacts: z3.boolean().optional()
49
465
  }).strict();
50
- var ExecutionEnvironmentSchema = z.object({
51
- browser: z.enum(["chromium", "firefox", "webkit"]).optional(),
52
- viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
53
- locale: z.string().optional(),
54
- timezoneId: z.string().optional(),
55
- colorScheme: z.enum(["light", "dark", "no-preference"]).optional(),
466
+ var ExecutionEnvironmentSchema = z3.object({
467
+ browser: z3.enum(["chromium", "firefox", "webkit"]).optional(),
468
+ viewport: z3.object({ width: z3.number().int().positive(), height: z3.number().int().positive() }).optional(),
469
+ locale: z3.string().optional(),
470
+ timezoneId: z3.string().optional(),
471
+ colorScheme: z3.enum(["light", "dark", "no-preference"]).optional(),
56
472
  /** How long a single step may take to find its target and act. */
57
- timeoutMs: z.number().int().positive().max(3e5).optional(),
473
+ timeoutMs: z3.number().int().positive().max(3e5).optional(),
58
474
  /** How long a page load or history traversal may take. Separate from the
59
475
  * step timeout on purpose: "this page is slow to load" and "this element
60
476
  * does not exist" are different problems, and one number for both made
61
477
  * every real site fail during load rather than during the assertion. */
62
- navigationTimeoutMs: z.number().int().positive().max(3e5).optional(),
478
+ navigationTimeoutMs: z3.number().int().positive().max(3e5).optional(),
63
479
  /** How long an assertion may wait for the state it expects. Usually shorter
64
480
  * than a step — an outcome that has not appeared quickly usually is not
65
481
  * coming — but slow back ends need it raised. */
66
- assertionTimeoutMs: z.number().int().positive().max(3e5).optional(),
67
- openClosedShadowRoots: z.boolean().optional(),
482
+ assertionTimeoutMs: z3.number().int().positive().max(3e5).optional(),
483
+ openClosedShadowRoots: z3.boolean().optional(),
68
484
  /** Let the browser window take the whole screen, and the page take the whole
69
485
  * window.
70
486
  *
@@ -76,20 +492,20 @@ var ExecutionEnvironmentSchema = z.object({
76
492
  *
77
493
  * Recorded ON the flow, like the rest of the environment, so a run always
78
494
  * says which of the two it was. */
79
- fullWindow: z.boolean().optional()
495
+ fullWindow: z3.boolean().optional()
80
496
  }).strict();
81
- var RecordedTargetSchema = z.object({
82
- role: z.string(),
83
- name: z.string(),
84
- scope: z.string().optional(),
85
- scopeRole: z.string().optional(),
86
- frame: z.array(z.string()).optional(),
87
- pageId: z.string().optional(),
88
- nameFrom: z.enum(["adjacent-label", "text"]).optional(),
497
+ var RecordedTargetSchema = z3.object({
498
+ role: z3.string(),
499
+ name: z3.string(),
500
+ scope: z3.string().optional(),
501
+ scopeRole: z3.string().optional(),
502
+ frame: z3.array(z3.string()).optional(),
503
+ pageId: z3.string().optional(),
504
+ nameFrom: z3.enum(["adjacent-label", "text"]).optional(),
89
505
  locator: LocatorOverrideSchema.optional(),
90
- nth: z.number().int().nonnegative().optional()
506
+ nth: z3.number().int().nonnegative().optional()
91
507
  });
92
- var RecordedAssertionKindSchema = z.enum([
508
+ var RecordedAssertionKindSchema = z3.enum([
93
509
  // Proposed automatically from a page-signature diff. Heuristic shapes, not
94
510
  // things a person picks by name.
95
511
  "screen",
@@ -115,679 +531,299 @@ var RecordedAssertionKindSchema = z.enum([
115
531
  "title",
116
532
  "heading"
117
533
  ]);
118
- var AssertionOpSchema = z.enum(["equals", "contains", "matches", "gt", "gte", "lt", "lte"]);
119
- var RecordedAssertionSchema = z.object({
534
+ var AssertionOpSchema = z3.enum(["equals", "contains", "matches", "gt", "gte", "lt", "lte"]);
535
+ var RecordedAssertionSchema = z3.object({
120
536
  kind: RecordedAssertionKindSchema,
121
- value: z.union([z.string(), z.array(z.string())]),
537
+ value: z3.union([z3.string(), z3.array(z3.string())]),
122
538
  /** Defaults to "equals" when absent, so older flows keep their meaning. */
123
539
  op: AssertionOpSchema.optional(),
124
540
  /** Negates the whole assertion, which is what gives every reading a mirror
125
541
  * without doubling the catalogue. `visible` + `not` IS `hidden`. */
126
- not: z.boolean().optional(),
542
+ not: z3.boolean().optional(),
127
543
  /** Which attribute, for kind "attribute". Ignored otherwise. */
128
- attr: z.string().optional(),
544
+ attr: z3.string().optional(),
129
545
  /** A failure records the check and lets the run carry on, instead of
130
546
  * stopping the flow. Chosen by whoever authored the assertion: some checks
131
547
  * are the point of the test and some are worth knowing about. */
132
- soft: z.boolean().optional(),
133
- why: z.string().optional(),
134
- enabled: z.boolean().optional(),
135
- confidence: z.enum(["low", "high"]).optional(),
548
+ soft: z3.boolean().optional(),
549
+ why: z3.string().optional(),
550
+ enabled: z3.boolean().optional(),
551
+ confidence: z3.enum(["low", "high"]).optional(),
136
552
  target: RecordedTargetSchema.optional()
137
- }).strict();
138
- var PageSignatureSchema = z.object({
139
- url: z.string(),
140
- title: z.string(),
141
- heading: z.string(),
142
- alerts: z.array(z.string()),
143
- fields: z.array(z.string()),
144
- /** Every short piece of visible text on the page, each tagged with enough
145
- * identity to tell two elements apart.
146
- *
147
- * The generic net beneath `alerts`, which only sees a message when the page
148
- * announced itself with role=alert or a class we happened to guess — so a
149
- * bare <div id="error">Your username is invalid!</div> was invisible to it.
150
- *
151
- * `key` exists because comparing bare strings is not enough: a page that
152
- * quotes its own error message in body copy (a docs page, help text, an
153
- * empty state) already contains that string before anything happens, so the
154
- * real message appearing looked like nothing had changed. The key is used
155
- * ONLY to diff two snapshots of the same page a moment apart — never to
156
- * find an element, which stays role + accessible name. */
157
- texts: z.array(z.object({ key: z.string(), text: z.string() })).default([])
158
- });
159
- var AssertionKindSchema = z.enum(["screen", "message", "heading", "fields_visible"]);
160
- var AssertionSchema = z.object({
161
- id: z.string(),
162
- kind: AssertionKindSchema,
163
- value: z.union([z.string(), z.array(z.string())]),
164
- /** Human-readable reason it was proposed — shown next to the tick box. */
165
- why: z.string().optional(),
166
- /** Present only on INFERRED proposals — ones derived from a text diff rather
167
- * than from something the page stated about itself. They arrive unticked,
168
- * because a guess that silently becomes a passing assertion is worse than no
169
- * assertion: it makes the suite look green for a reason nobody chose. */
170
- confidence: z.literal("low").optional(),
171
- /** Unticked assertions stay in the file as a record of what was offered. */
172
- enabled: z.boolean().default(true),
173
- source: z.enum(["proposed", "manual"]).default("proposed")
174
- });
175
- var AssertionProposalSchema = AssertionSchema.omit({ id: true, enabled: true, source: true });
176
- var FeatureSchema = z.object({
177
- schemaVersion: z.literal(1),
178
- id: z.string(),
179
- /** Directory name. Stable even when the display name is edited. */
180
- slug: z.string(),
181
- name: z.string(),
182
- description: z.string().default(""),
183
- /** Manual ordering in the UI; ties break by name. */
184
- order: z.number().int().default(0),
185
- createdAt: z.string(),
186
- createdBy: z.string().default("")
187
- });
188
- var SECRET_REF_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
189
- var isSecretRefName = (value) => SECRET_REF_PATTERN.test(value);
190
- var ScrollPrerequisiteSchema = z.object({
191
- x: z.number(),
192
- y: z.number(),
193
- container: RecordedTargetSchema.optional(),
194
- legacySelector: z.string().optional()
195
- }).strict();
196
- var DialogAnswerSchema = z.object({
197
- type: z.enum(["alert", "confirm", "prompt", "beforeunload"]),
198
- message: z.string(),
199
- accept: z.boolean(),
200
- promptText: z.string().optional()
201
- }).strict();
202
- var RecordedStepSchema = RecordedTargetSchema.extend({
203
- id: z.string().min(1),
204
- type: z.enum(["fill", "select", "click", "dblclick", "rightclick", "drag", "check", "press", "hover", "upload", "scroll", "back", "forward", "navigate", "closePage", "unsupported", "setRange", "assert"]),
205
- scope: z.string(),
206
- value: z.string(),
207
- secret: z.boolean(),
208
- legacySelector: z.string(),
209
- proposals: z.array(RecordedAssertionSchema),
210
- values: z.array(z.string()).optional(),
211
- to: RecordedTargetSchema.optional(),
212
- manual: z.boolean().optional(),
213
- verified: z.boolean().optional(),
214
- /** Skip this step without deleting it.
215
- *
216
- * There is a real gap between "this step is wrong" and "this field is broken
217
- * today". Deleting is the only way to express the second without this, which
218
- * loses the step's identity, its assertions and its place in the order — and
219
- * the person then has to re-record it once the field is fixed.
220
- *
221
- * Absent means enabled, so every flow recorded before this stays enabled
222
- * without a migration. */
223
- disabled: z.boolean().optional(),
224
- /** How long a single step may take to find its target and act. */
225
- timeoutMs: z.number().int().positive().max(3e5).optional(),
226
- /** How long a page load or history traversal may take. Separate from the
227
- * step timeout on purpose: "this page is slow to load" and "this element
228
- * does not exist" are different problems, and one number for both made
229
- * every real site fail during load rather than during the assertion. */
230
- navigationTimeoutMs: z.number().int().positive().max(3e5).optional(),
231
- /** How long an assertion may wait for the state it expects. Usually shorter
232
- * than a step — an outcome that has not appeared quickly usually is not
233
- * coming — but slow back ends need it raised. */
234
- assertionTimeoutMs: z.number().int().positive().max(3e5).optional(),
235
- // Popups are bound to the action that created them; subsequent steps use pageId.
236
- opensPage: z.string().optional(),
237
- dialogs: z.array(DialogAnswerSchema).optional(),
238
- downloads: z.array(z.string()).optional(),
239
- warning: z.string().optional(),
240
- scroll: ScrollPrerequisiteSchema.optional(),
241
- /** The NAME of a secret held outside this document.
242
- *
243
- * Explicit, because `secret: true` with the reference living in `value` made
244
- * a reference and a password the same shape. Nothing could then tell an
245
- * unresolvable reference from a leaked literal, and the code that tried
246
- * guessed from whether the local secret store happened to contain the key —
247
- * so on a machine that had never held the secret, a perfectly good reference
248
- * was "healed" into a password whose value was its own name.
249
- *
250
- * When this is set, `value` is empty and carries nothing: the secret is
251
- * looked up by this name at run time, and a name that is not on this machine
252
- * stays unresolved and is reported, never invented. */
253
- secretRef: z.string().regex(SECRET_REF_PATTERN).optional()
254
- }).strict();
255
- function slugify(name) {
256
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
257
- }
258
- var CURRENT_SCHEMA_VERSION = 5;
259
- var FlowDocumentSchema = z.object({
260
- schemaVersion: z.literal(CURRENT_SCHEMA_VERSION),
261
- id: z.string().min(1),
262
- featureId: z.string().min(1),
263
- name: z.string().min(1),
264
- slug: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
265
- /** Where this flow starts, when it is pinned to somewhere of its own.
266
- *
267
- * OPTIONAL, and normally absent. The origin belongs to the project, not to
268
- * each flow: a flow that carries its own hard-codes an environment into a
269
- * git-tracked file, and the POC proved how that ends — an ephemeral port,
270
- * `http://127.0.0.1:65226/`, committed and unrunnable on any other machine.
271
- * Absent means "the project's base URL", which is what a flow recorded today
272
- * stores. Present is honoured, so every flow recorded before this keeps
273
- * working exactly as it did. */
274
- baseUrl: z.string().url().optional(),
275
- /** The route, resolved against whichever base URL applies. */
276
- startPath: z.string(),
277
- recordedAt: z.string(),
278
- recordedBy: z.string(),
279
- updatedAt: z.string(),
280
- steps: z.array(RecordedStepSchema),
281
- environment: ExecutionEnvironmentSchema.optional(),
282
- path: z.string().optional(),
283
- /** Bumped on every save. An edit carries the revision it was made against,
284
- * so a save built on a stale copy is refused instead of overwriting work
285
- * somebody else — or a debug session on the same machine — has since done.
286
- * Documents written before this default to 0. */
287
- revision: z.number().int().nonnegative().default(0)
288
- }).strict().superRefine((flow, ctx) => {
289
- const ids = /* @__PURE__ */ new Set();
290
- flow.steps.forEach((s2, i) => {
291
- if (ids.has(s2.id)) ctx.addIssue({ code: "custom", path: ["steps", i, "id"], message: "Step IDs must be unique" });
292
- ids.add(s2.id);
293
- });
294
- });
295
- var surfaceOf = (s2) => JSON.stringify([s2["pageId"] ?? "", s2["frame"] ?? []]);
296
- var containerOf = (s2) => JSON.stringify([targetOfStep(s2), s2["legacySelector"] ?? ""]);
297
- function targetOfStep(s2) {
298
- const t = { role: s2["role"] ?? "", name: s2["name"] ?? "", scope: s2["scope"] ?? "" };
299
- for (const key of ["scopeRole", "frame", "pageId", "nameFrom", "locator", "nth"]) {
300
- if (s2[key] !== void 0) t[key] = s2[key];
301
- }
302
- return t;
303
- }
304
- function migrateScrollPrerequisites(doc) {
305
- const steps = Array.isArray(doc.steps) ? doc.steps : [];
306
- const out = [];
307
- const pending = [];
308
- const isScroll = (s2) => s2["type"] === "scroll" && s2["scroll"] !== void 0 && s2["scroll"] !== null;
309
- const hasChecks = (s2) => Array.isArray(s2["proposals"]) && s2["proposals"].length > 0;
310
- const carry = (s2) => {
311
- const pos = s2["scroll"] ?? {};
312
- const prerequisite = { x: Number(pos.x ?? 0), y: Number(pos.y ?? 0), container: targetOfStep(s2) };
313
- if (typeof s2["legacySelector"] === "string" && s2["legacySelector"]) prerequisite["legacySelector"] = s2["legacySelector"];
314
- return prerequisite;
315
- };
316
- for (const step of steps) {
317
- if (isScroll(step)) {
318
- const previous = pending.at(-1);
319
- const same = previous && !hasChecks(previous) && !hasChecks(step) && surfaceOf(previous) === surfaceOf(step) && containerOf(previous) === containerOf(step) && Boolean(previous["disabled"]) === Boolean(step["disabled"]);
320
- if (same) pending[pending.length - 1] = step;
321
- else pending.push(step);
322
- continue;
323
- }
324
- const last = pending.at(-1);
325
- if (last && !hasChecks(last) && !step["scroll"] && surfaceOf(last) === surfaceOf(step) && Boolean(last["disabled"]) === Boolean(step["disabled"])) {
326
- step["scroll"] = carry(pending.pop());
327
- }
328
- for (const p of pending.splice(0)) out.push(p);
329
- out.push(step);
330
- }
331
- for (const p of pending.splice(0)) out.push(p);
332
- doc.steps = out;
333
- }
334
- function migrateRadioCheckedState(doc) {
335
- for (const step of Array.isArray(doc.steps) ? doc.steps : []) {
336
- if (step["type"] === "check" && step["role"] === "radio" && step["value"] === "") step["value"] = "true";
337
- }
338
- }
339
- function migrateExplicitSecretRefs(doc) {
340
- for (const step of Array.isArray(doc.steps) ? doc.steps : []) {
341
- if (step["secret"] !== true) continue;
342
- if (typeof step["secretRef"] === "string" && step["secretRef"]) continue;
343
- const value = typeof step["value"] === "string" ? step["value"] : "";
344
- if (value && isSecretRefName(value)) {
345
- step["secretRef"] = value;
346
- step["value"] = "";
347
- }
348
- }
349
- }
350
- function migrateBareModifierPresses(doc) {
351
- const MODIFIERS = ["Meta", "Control", "Alt", "Shift"];
352
- const invented = (step) => step["type"] === "press" && typeof step["value"] === "string" && MODIFIERS.some((m) => step["value"] === `${m}+${m}` || step["value"] === m);
353
- doc.steps = (Array.isArray(doc.steps) ? doc.steps : []).filter((step) => !invented(step));
354
- }
355
- var MIGRATIONS = {
356
- 2: (doc) => {
357
- migrateScrollPrerequisites(doc);
358
- migrateRadioCheckedState(doc);
359
- },
360
- 3: migrateExplicitSecretRefs,
361
- 4: migrateBareModifierPresses
362
- };
363
- function parseFlowDocument(input) {
364
- const raw = structuredClone(input);
365
- let version = typeof raw?.schemaVersion === "number" ? raw.schemaVersion : 2;
366
- if (version > CURRENT_SCHEMA_VERSION) {
367
- throw new Error(
368
- `this flow was written by a newer version of Flowriki (schema ${version}, this build understands ${CURRENT_SCHEMA_VERSION}) \u2014 upgrade to open it`
369
- );
370
- }
371
- while (version < CURRENT_SCHEMA_VERSION) {
372
- const migrate = MIGRATIONS[version];
373
- if (!migrate) throw new Error(`no migration path from schema version ${version}`);
374
- migrate(raw);
375
- version += 1;
376
- raw.schemaVersion = version;
377
- }
378
- return FlowDocumentSchema.parse(raw);
379
- }
380
-
381
- // ../protocol/dist/chunk-S4QJBHEH.js
382
- import { z as z2 } from "zod";
383
- var EngineSchema = z2.enum(["chromium", "firefox", "webkit"]);
384
- var CapabilitySchema = z2.object({
385
- id: z2.string(),
386
- provider: z2.string().default("local"),
387
- engine: EngineSchema,
388
- /** Branded local builds: "chrome", "msedge". */
389
- channel: z2.string().optional(),
390
- browserVersion: z2.string().optional(),
391
- os: z2.string().optional(),
392
- osVersion: z2.string().optional(),
393
- device: z2.string().optional(),
394
- viewport: z2.object({ width: z2.number().int(), height: z2.number().int() }).optional()
395
- });
396
- var ExecutionConfigSchema = z2.object({
397
- concurrency: z2.number().int().positive().default(4),
398
- defaultBrowser: z2.string().default("chromium"),
399
- retries: z2.number().int().nonnegative().default(0),
400
- browsers: z2.record(z2.string(), CapabilitySchema.omit({ id: true }).partial({ provider: true })),
401
- suites: z2.record(
402
- z2.string(),
403
- z2.object({
404
- flows: z2.array(z2.string()).default([]),
405
- browser: z2.string().optional(),
406
- /** Opt-in cross-browser fan-out for this suite only. */
407
- matrix: z2.array(z2.string()).optional()
408
- })
409
- ).default({})
410
- });
411
- var RunStatusSchema = z2.enum([
412
- "queued",
413
- "running",
414
- "passed",
415
- "failed",
416
- "cancelled",
417
- "errored"
418
- ]);
419
- var TaskStatusSchema = z2.enum([
420
- "queued",
421
- "running",
422
- "passed",
423
- "failed",
424
- "skipped",
425
- "cancelled",
426
- "errored"
427
- ]);
428
- var FailureKindSchema = z2.enum([
429
- "not_found",
430
- "ambiguous",
431
- "timeout",
432
- "assertion",
433
- "navigation",
434
- "crash",
435
- "unknown"
436
- ]);
437
- var RunCheckSchema = z2.object({
438
- id: z2.string(),
439
- assertionId: z2.string().nullable(),
440
- kind: z2.string(),
441
- expected: z2.string().nullable(),
442
- actual: z2.string().nullable(),
443
- ok: z2.boolean(),
444
- /** A failed soft check is reported but does not fail its step, so a run can
445
- * be green and still carry findings. Stored per check rather than derived,
446
- * because the flow it came from can be edited afterwards and history has to
447
- * keep saying what actually happened. */
448
- soft: z2.boolean().default(false),
449
- detail: z2.string().nullable()
450
- });
451
- var RunStepSchema = z2.object({
452
- id: z2.string(),
453
- ordinal: z2.number().int().nonnegative(),
454
- /** The STABLE document step id — this is what joins runs across time and
455
- * makes flake-rate a view over existing columns rather than a migration. */
456
- stepId: z2.string(),
457
- action: z2.string(),
458
- targetRole: z2.string().nullable(),
459
- targetName: z2.string().nullable(),
460
- targetScope: z2.string().nullable(),
461
- /** What the step typed, chose or pressed.
462
- *
463
- * A report that says `fill "Password"` and nothing else cannot tell you
464
- * whether the run used the right credentials, which is most of what anyone
465
- * reads a report to find out.
466
- *
467
- * NEVER set for a secret — see `secret` below. A run report is the thing
468
- * people paste into a ticket. */
469
- value: z2.string().nullable().default(null),
470
- /** The step typed a secret, so `value` is deliberately absent rather than
471
- * merely unknown. Stored, because "no value" and "a value withheld" render
472
- * differently and history has to keep saying which it was. */
473
- secret: z2.boolean().default(false),
474
- status: z2.enum(["passed", "failed", "skipped"]),
475
- failureKind: FailureKindSchema.nullable(),
476
- detail: z2.string().nullable(),
477
- durationMs: z2.number().int().nonnegative().nullable(),
478
- /** Where the browser was when this step ran. Recorded on failure, and
479
- * frequently the whole answer — the element is missing because a step
480
- * before it navigated somewhere unexpected, not because it was renamed. */
481
- pageUrl: z2.string().nullable().default(null),
482
- pageTitle: z2.string().nullable().default(null),
483
- checks: z2.array(RunCheckSchema).default([])
484
- });
485
- var RunTaskSchema = z2.object({
486
- id: z2.string(),
487
- ordinal: z2.number().int().nonnegative(),
488
- caseName: z2.string(),
489
- rowId: z2.string().nullable(),
490
- capability: CapabilitySchema,
491
- provider: z2.string(),
492
- attempt: z2.number().int().positive().default(1),
493
- maxAttempts: z2.number().int().positive().default(1),
494
- status: TaskStatusSchema,
495
- workerId: z2.string().nullable(),
496
- /** Cloud provider's own session, so a failure is one click from their video. */
497
- sessionId: z2.string().nullable(),
498
- sessionUrl: z2.string().nullable(),
499
- detail: z2.string().nullable(),
500
- stepsRun: z2.number().int().nonnegative().default(0),
501
- stepsTotal: z2.number().int().nonnegative().default(0),
502
- durationMs: z2.number().int().nonnegative().nullable(),
503
- steps: z2.array(RunStepSchema).default([])
504
- });
505
- var ArtifactKindSchema = z2.enum(["screenshot", "video", "trace", "console", "har"]);
506
- var ArtifactSchema = z2.object({
507
- id: z2.string(),
508
- runId: z2.string(),
509
- runStepId: z2.string().nullable(),
510
- kind: ArtifactKindSchema,
511
- relPath: z2.string(),
512
- bytes: z2.number().int().nonnegative().nullable(),
513
- masked: z2.boolean().default(false),
514
- createdAt: z2.string()
515
- });
516
- var RunSummarySchema = z2.object({
517
- id: z2.string(),
518
- projectId: z2.string(),
519
- /** What the run was FOR.
520
- *
521
- * `flake` is a deliberate repetition of one flow to measure how often it
522
- * passes. It is kept out of pass-rate statistics: a 98/100 flakiness check is
523
- * a useful finding, not a failing suite, and counting it as one would make
524
- * measuring reliability look like losing it. */
525
- kind: z2.enum(["smoke", "test", "adhoc", "flake"]),
526
- trigger: z2.enum(["ui", "cli", "ci"]),
527
- status: RunStatusSchema,
528
- flowId: z2.string().nullable(),
529
- flowName: z2.string().nullable(),
530
- gitSha: z2.string().nullable(),
531
- gitBranch: z2.string().nullable(),
532
- gitDirty: z2.boolean().default(false),
533
- env: z2.string().nullable(),
534
- concurrency: z2.number().int().positive().nullable(),
535
- tasksTotal: z2.number().int().nonnegative().default(0),
536
- tasksPassed: z2.number().int().nonnegative().default(0),
537
- tasksFailed: z2.number().int().nonnegative().default(0),
538
- /** Rolled up from the run's tasks rather than stored on the run, so they
539
- * cannot drift from the step rows they describe. "9 of 12" in a list is
540
- * what tells a reader where a run died; "1 of 1 tasks" tells them nothing
541
- * until there is more than one browser in play. */
542
- stepsRun: z2.number().int().nonnegative().default(0),
543
- stepsTotal: z2.number().int().nonnegative().default(0),
544
- queuedAt: z2.string(),
545
- startedAt: z2.string().nullable(),
546
- finishedAt: z2.string().nullable(),
547
- durationMs: z2.number().int().nonnegative().nullable()
553
+ }).strict();
554
+ var PageSignatureSchema = z3.object({
555
+ url: z3.string(),
556
+ title: z3.string(),
557
+ heading: z3.string(),
558
+ alerts: z3.array(z3.string()),
559
+ fields: z3.array(z3.string()),
560
+ /** Every short piece of visible text on the page, each tagged with enough
561
+ * identity to tell two elements apart.
562
+ *
563
+ * The generic net beneath `alerts`, which only sees a message when the page
564
+ * announced itself with role=alert or a class we happened to guess — so a
565
+ * bare <div id="error">Your username is invalid!</div> was invisible to it.
566
+ *
567
+ * `key` exists because comparing bare strings is not enough: a page that
568
+ * quotes its own error message in body copy (a docs page, help text, an
569
+ * empty state) already contains that string before anything happens, so the
570
+ * real message appearing looked like nothing had changed. The key is used
571
+ * ONLY to diff two snapshots of the same page a moment apart — never to
572
+ * find an element, which stays role + accessible name. */
573
+ texts: z3.array(z3.object({ key: z3.string(), text: z3.string() })).default([])
548
574
  });
549
- var RunDetailSchema = RunSummarySchema.extend({
550
- errorMessage: z2.string().nullable(),
551
- artifactsDir: z2.string().nullable(),
552
- tasks: z2.array(RunTaskSchema).default([]),
553
- /** Screenshots and the like, addressed by `runStepId`. Kept flat rather than
554
- * nested inside each step: an artifact can belong to the run as a whole. */
555
- artifacts: z2.array(ArtifactSchema).default([])
575
+ var AssertionKindSchema = z3.enum(["screen", "message", "heading", "fields_visible"]);
576
+ var AssertionSchema = z3.object({
577
+ id: z3.string(),
578
+ kind: AssertionKindSchema,
579
+ value: z3.union([z3.string(), z3.array(z3.string())]),
580
+ /** Human-readable reason it was proposed — shown next to the tick box. */
581
+ why: z3.string().optional(),
582
+ /** Present only on INFERRED proposals — ones derived from a text diff rather
583
+ * than from something the page stated about itself. They arrive unticked,
584
+ * because a guess that silently becomes a passing assertion is worse than no
585
+ * assertion: it makes the suite look green for a reason nobody chose. */
586
+ confidence: z3.literal("low").optional(),
587
+ /** Unticked assertions stay in the file as a record of what was offered. */
588
+ enabled: z3.boolean().default(true),
589
+ source: z3.enum(["proposed", "manual"]).default("proposed")
556
590
  });
557
-
558
- // ../protocol/dist/index.js
559
- import { z as z3 } from "zod";
560
- var MARKER_PATH = ".flowriki/project.json";
561
- var ProjectMarkerSchema = z3.object({
591
+ var AssertionProposalSchema = AssertionSchema.omit({ id: true, enabled: true, source: true });
592
+ var FeatureSchema = z3.object({
562
593
  schemaVersion: z3.literal(1),
563
- projectId: z3.string().min(1),
564
- /** Shown to people, and NOT covered by the signature.
565
- *
566
- * Deliberate: projects get renamed, and signing the name would invalidate the
567
- * marker in every existing clone the moment somebody did. Anything reading
568
- * this must present it as what the file says rather than as something
569
- * checked - the id is the verified identity. */
594
+ id: z3.string(),
595
+ /** Directory name. Stable even when the display name is edited. */
596
+ slug: z3.string(),
570
597
  name: z3.string(),
571
- boundAt: z3.string(),
572
- /** The repository's first commit at the moment of binding.
573
- *
574
- * This is what ties the marker to THIS history rather than to any folder
575
- * somebody pastes it into. Null only where the repository had no commits at
576
- * all when it was claimed. */
577
- rootCommit: z3.string().nullable(),
578
- /** `v1:ed25519:<base64url>` */
579
- signature: z3.string()
598
+ description: z3.string().default(""),
599
+ /** Manual ordering in the UI; ties break by name. */
600
+ order: z3.number().int().default(0),
601
+ createdAt: z3.string(),
602
+ createdBy: z3.string().default("")
580
603
  });
581
- var PRODUCTION_SERVICE = "https://flowriki-cloud.onrender.com";
582
-
583
- // ../core/dist/index.js
584
- function proposeAssertions(before, after) {
585
- const out = [];
586
- if (!before || !after) return out;
587
- if (before.url !== after.url)
588
- out.push({
589
- kind: "screen",
590
- value: after.heading || after.title,
591
- why: "navigated to a new screen"
592
- });
593
- for (const a of after.alerts.filter((x) => !before.alerts.includes(x)))
594
- out.push({ kind: "message", value: a, why: "a message appeared that was not there before" });
595
- if (before.url === after.url && before.heading !== after.heading && after.heading)
596
- out.push({ kind: "heading", value: after.heading, why: "the heading changed in place" });
597
- const newFields = after.fields.filter((f) => !before.fields.includes(f));
598
- if (newFields.length)
599
- out.push({ kind: "fields_visible", value: newFields, why: "new fields became visible" });
600
- if (before.url === after.url) {
601
- const seenKeys = new Set(before.texts.map((t) => t.key));
602
- const covered = /* @__PURE__ */ new Set([after.heading, ...after.alerts]);
603
- const appeared = after.texts.filter((t) => !seenKeys.has(t.key) && !covered.has(t.text));
604
- for (const t of appeared.slice(0, 2))
605
- out.push({
606
- kind: "message",
607
- value: t.text,
608
- why: "this text appeared after the action",
609
- confidence: "low"
610
- });
611
- }
612
- return out;
613
- }
614
- function hasObservableChange(before, after) {
615
- return proposeAssertions(before, after).some((p) => p.confidence !== "low");
616
- }
617
- function isLocalHost(host) {
618
- const h = host.toLowerCase();
619
- return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "[::1]" || h === "0.0.0.0";
620
- }
621
- function hasScheme(raw) {
622
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) return true;
623
- return /^[a-zA-Z][a-zA-Z0-9+.-]*:(?!\d)/.test(raw);
604
+ var SECRET_REF_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
605
+ var isSecretRefName = (value) => SECRET_REF_PATTERN.test(value);
606
+ var ScrollPrerequisiteSchema = z3.object({
607
+ x: z3.number(),
608
+ y: z3.number(),
609
+ container: RecordedTargetSchema.optional(),
610
+ legacySelector: z3.string().optional()
611
+ }).strict();
612
+ var DialogAnswerSchema = z3.object({
613
+ type: z3.enum(["alert", "confirm", "prompt", "beforeunload"]),
614
+ message: z3.string(),
615
+ accept: z3.boolean(),
616
+ promptText: z3.string().optional()
617
+ }).strict();
618
+ var RecordedStepSchema = RecordedTargetSchema.extend({
619
+ id: z3.string().min(1),
620
+ type: z3.enum(["fill", "select", "click", "dblclick", "rightclick", "drag", "check", "press", "hover", "upload", "scroll", "back", "forward", "navigate", "closePage", "unsupported", "setRange", "assert"]),
621
+ scope: z3.string(),
622
+ value: z3.string(),
623
+ secret: z3.boolean(),
624
+ legacySelector: z3.string(),
625
+ proposals: z3.array(RecordedAssertionSchema),
626
+ values: z3.array(z3.string()).optional(),
627
+ to: RecordedTargetSchema.optional(),
628
+ manual: z3.boolean().optional(),
629
+ verified: z3.boolean().optional(),
630
+ /** Skip this step without deleting it.
631
+ *
632
+ * There is a real gap between "this step is wrong" and "this field is broken
633
+ * today". Deleting is the only way to express the second without this, which
634
+ * loses the step's identity, its assertions and its place in the order — and
635
+ * the person then has to re-record it once the field is fixed.
636
+ *
637
+ * Absent means enabled, so every flow recorded before this stays enabled
638
+ * without a migration. */
639
+ disabled: z3.boolean().optional(),
640
+ /** How long a single step may take to find its target and act. */
641
+ timeoutMs: z3.number().int().positive().max(3e5).optional(),
642
+ /** How long a page load or history traversal may take. Separate from the
643
+ * step timeout on purpose: "this page is slow to load" and "this element
644
+ * does not exist" are different problems, and one number for both made
645
+ * every real site fail during load rather than during the assertion. */
646
+ navigationTimeoutMs: z3.number().int().positive().max(3e5).optional(),
647
+ /** How long an assertion may wait for the state it expects. Usually shorter
648
+ * than a step — an outcome that has not appeared quickly usually is not
649
+ * coming — but slow back ends need it raised. */
650
+ assertionTimeoutMs: z3.number().int().positive().max(3e5).optional(),
651
+ // Popups are bound to the action that created them; subsequent steps use pageId.
652
+ opensPage: z3.string().optional(),
653
+ dialogs: z3.array(DialogAnswerSchema).optional(),
654
+ downloads: z3.array(z3.string()).optional(),
655
+ warning: z3.string().optional(),
656
+ scroll: ScrollPrerequisiteSchema.optional(),
657
+ /** The NAME of a secret held outside this document.
658
+ *
659
+ * Explicit, because `secret: true` with the reference living in `value` made
660
+ * a reference and a password the same shape. Nothing could then tell an
661
+ * unresolvable reference from a leaked literal, and the code that tried
662
+ * guessed from whether the local secret store happened to contain the key —
663
+ * so on a machine that had never held the secret, a perfectly good reference
664
+ * was "healed" into a password whose value was its own name.
665
+ *
666
+ * When this is set, `value` is empty and carries nothing: the secret is
667
+ * looked up by this name at run time, and a name that is not on this machine
668
+ * stays unresolved and is reported, never invented. */
669
+ secretRef: z3.string().regex(SECRET_REF_PATTERN).optional()
670
+ }).strict();
671
+ function slugify(name) {
672
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
624
673
  }
625
- function normalizeStartUrl(input) {
626
- const raw = (input ?? "").trim();
627
- if (!raw) throw new Error("enter a URL to record against");
628
- let candidate;
629
- if (hasScheme(raw)) {
630
- candidate = raw;
631
- } else {
632
- const bare = raw.replace(/^\/+/, "");
633
- const host = bare.split(/[/?#]/)[0] ?? "";
634
- candidate = `${isLocalHost(host.split(":")[0] ?? "") ? "http" : "https"}://${bare}`;
635
- }
636
- let parsed;
637
- try {
638
- parsed = new URL(candidate);
639
- } catch {
640
- throw new Error(`"${raw}" is not a valid URL`);
641
- }
642
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
643
- throw new Error(`only http:// and https:// URLs can be recorded (got ${parsed.protocol}//)`);
674
+ var CURRENT_SCHEMA_VERSION = 5;
675
+ var FlowDocumentSchema = z3.object({
676
+ schemaVersion: z3.literal(CURRENT_SCHEMA_VERSION),
677
+ id: z3.string().min(1),
678
+ featureId: z3.string().min(1),
679
+ name: z3.string().min(1),
680
+ slug: z3.string().regex(/^[a-z0-9][a-z0-9-]*$/),
681
+ /** Where this flow starts, when it is pinned to somewhere of its own.
682
+ *
683
+ * OPTIONAL, and normally absent. The origin belongs to the project, not to
684
+ * each flow: a flow that carries its own hard-codes an environment into a
685
+ * git-tracked file, and the POC proved how that ends — an ephemeral port,
686
+ * `http://127.0.0.1:65226/`, committed and unrunnable on any other machine.
687
+ * Absent means "the project's base URL", which is what a flow recorded today
688
+ * stores. Present is honoured, so every flow recorded before this keeps
689
+ * working exactly as it did. */
690
+ baseUrl: z3.string().url().optional(),
691
+ /** The route, resolved against whichever base URL applies. */
692
+ startPath: z3.string(),
693
+ recordedAt: z3.string(),
694
+ recordedBy: z3.string(),
695
+ updatedAt: z3.string(),
696
+ steps: z3.array(RecordedStepSchema),
697
+ environment: ExecutionEnvironmentSchema.optional(),
698
+ path: z3.string().optional(),
699
+ /** Bumped on every save. An edit carries the revision it was made against,
700
+ * so a save built on a stale copy is refused instead of overwriting work
701
+ * somebody else — or a debug session on the same machine — has since done.
702
+ * Documents written before this default to 0. */
703
+ revision: z3.number().int().nonnegative().default(0)
704
+ }).strict().superRefine((flow, ctx) => {
705
+ const ids = /* @__PURE__ */ new Set();
706
+ flow.steps.forEach((s2, i) => {
707
+ if (ids.has(s2.id)) ctx.addIssue({ code: "custom", path: ["steps", i, "id"], message: "Step IDs must be unique" });
708
+ ids.add(s2.id);
709
+ });
710
+ });
711
+ var surfaceOf = (s2) => JSON.stringify([s2["pageId"] ?? "", s2["frame"] ?? []]);
712
+ var containerOf = (s2) => JSON.stringify([targetOfStep(s2), s2["legacySelector"] ?? ""]);
713
+ function targetOfStep(s2) {
714
+ const t = { role: s2["role"] ?? "", name: s2["name"] ?? "", scope: s2["scope"] ?? "" };
715
+ for (const key of ["scopeRole", "frame", "pageId", "nameFrom", "locator", "nth"]) {
716
+ if (s2[key] !== void 0) t[key] = s2[key];
644
717
  }
645
- if (!parsed.hostname) throw new Error(`"${raw}" has no host`);
646
- return parsed.toString();
647
- }
648
- function resolveStartUrl(baseUrl, startPath) {
649
- const base = normalizeStartUrl(baseUrl);
650
- return new URL(startPath || "/", base).toString();
718
+ return t;
651
719
  }
652
- var INPUT_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "checkbox", "radio", "spinbutton", "searchbox"]);
653
- var NAVIGATIONS = /* @__PURE__ */ new Set(["back", "forward"]);
654
- function locatorPlan(step) {
655
- const o = step.locator;
656
- if (o && o.value.trim()) {
657
- const value = o.value.trim();
658
- switch (o.strategy) {
659
- // Playwright reads a leading `xpath=` prefix, and also bare paths that
660
- // start with `/` or `//`. Normalising here means the QA can paste either.
661
- case "xpath":
662
- return { method: "locator", arg: value.startsWith("xpath=") ? value : `xpath=${value}`, overridden: true };
663
- // Typed as a bare id, so nobody has to remember the `#`.
664
- case "id":
665
- return { method: "locator", arg: /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value.replace(/^#/, "")) ? `#${value.replace(/^#/, "")}` : `[id=${JSON.stringify(value.replace(/^#/, ""))}]`, overridden: true };
666
- case "css":
667
- return { method: "locator", arg: value, overridden: true };
668
- case "testId":
669
- return { method: "getByTestId", arg: value, overridden: true };
670
- case "text":
671
- return { method: "getByText", arg: value, exact: true, overridden: true };
672
- case "label":
673
- return { method: "getByLabel", arg: value, exact: true, overridden: true };
674
- case "placeholder":
675
- return { method: "getByPlaceholder", arg: value, exact: true, overridden: true };
676
- case "altText":
677
- return { method: "getByAltText", arg: value, exact: true, overridden: true };
678
- case "title":
679
- return { method: "getByTitle", arg: value, exact: true, overridden: true };
720
+ function migrateScrollPrerequisites(doc) {
721
+ const steps = Array.isArray(doc.steps) ? doc.steps : [];
722
+ const out = [];
723
+ const pending = [];
724
+ const isScroll = (s2) => s2["type"] === "scroll" && s2["scroll"] !== void 0 && s2["scroll"] !== null;
725
+ const hasChecks = (s2) => Array.isArray(s2["proposals"]) && s2["proposals"].length > 0;
726
+ const carry = (s2) => {
727
+ const pos = s2["scroll"] ?? {};
728
+ const prerequisite = { x: Number(pos.x ?? 0), y: Number(pos.y ?? 0), container: targetOfStep(s2) };
729
+ if (typeof s2["legacySelector"] === "string" && s2["legacySelector"]) prerequisite["legacySelector"] = s2["legacySelector"];
730
+ return prerequisite;
731
+ };
732
+ for (const step of steps) {
733
+ if (isScroll(step)) {
734
+ const previous = pending.at(-1);
735
+ const same = previous && !hasChecks(previous) && !hasChecks(step) && surfaceOf(previous) === surfaceOf(step) && containerOf(previous) === containerOf(step) && Boolean(previous["disabled"]) === Boolean(step["disabled"]);
736
+ if (same) pending[pending.length - 1] = step;
737
+ else pending.push(step);
738
+ continue;
680
739
  }
740
+ const last = pending.at(-1);
741
+ if (last && !hasChecks(last) && !step["scroll"] && surfaceOf(last) === surfaceOf(step) && Boolean(last["disabled"]) === Boolean(step["disabled"])) {
742
+ step["scroll"] = carry(pending.pop());
743
+ }
744
+ for (const p of pending.splice(0)) out.push(p);
745
+ out.push(step);
681
746
  }
682
- if (NAVIGATIONS.has(step.type)) {
683
- return { method: step.type === "back" ? "goBack" : "goForward", arg: "", overridden: false };
684
- }
685
- if (!step.name.trim()) return { method: "none", arg: "", overridden: false };
686
- if (step.nameFrom === "adjacent-label") {
687
- return {
688
- method: "getByText",
689
- arg: step.name,
690
- exact: true,
691
- overridden: false,
692
- viaAdjacentLabel: true
693
- };
694
- }
695
- if (step.nameFrom === "text") {
696
- return {
697
- method: "getByText",
698
- arg: step.name,
699
- exact: step.type !== "hover",
700
- overridden: false,
701
- visible: true
702
- };
703
- }
704
- return INPUT_ROLES.has(step.role) ? { method: "getByLabel", arg: step.name, exact: true, overridden: false, inputRole: step.role } : step.role ? { method: "getByRole", arg: step.role, name: step.name, exact: true, overridden: false } : { method: "getByText", arg: step.name, exact: step.type !== "hover", overridden: false, visible: true };
747
+ for (const p of pending.splice(0)) out.push(p);
748
+ doc.steps = out;
705
749
  }
706
- function frameSelector(name) {
707
- const positional = /^nth:(\d+)$/.exec(name);
708
- if (positional) return `iframe, frame >> nth=${positional[1]}`;
709
- const v = JSON.stringify(name);
710
- return `iframe[title=${v}], iframe[name=${v}], iframe[id=${v}], frame[title=${v}], frame[name=${v}], frame[id=${v}]`;
750
+ function migrateRadioCheckedState(doc) {
751
+ for (const step of Array.isArray(doc.steps) ? doc.steps : []) {
752
+ if (step["type"] === "check" && step["role"] === "radio" && step["value"] === "") step["value"] = "true";
753
+ }
711
754
  }
712
- function inputSelector(role) {
713
- return role === "textbox" ? 'input, textarea, [contenteditable="true"], [role="textbox"]' : `input, select, [role="${role}"]`;
755
+ function migrateExplicitSecretRefs(doc) {
756
+ for (const step of Array.isArray(doc.steps) ? doc.steps : []) {
757
+ if (step["secret"] !== true) continue;
758
+ if (typeof step["secretRef"] === "string" && step["secretRef"]) continue;
759
+ const value = typeof step["value"] === "string" ? step["value"] : "";
760
+ if (value && isSecretRefName(value)) {
761
+ step["secretRef"] = value;
762
+ step["value"] = "";
763
+ }
764
+ }
714
765
  }
715
- var SCOPE_ROLES = ["form", "group", "region", "dialog", "alertdialog"];
716
- var CHECKS = [
717
- { id: "visible", label: "is visible", kind: "visible", group: "state" },
718
- { id: "not-visible", label: "is not visible", kind: "visible", not: true, group: "state" },
719
- { id: "attached", label: "exists on the page", kind: "attached", group: "state" },
720
- { id: "not-attached", label: "does not exist", kind: "attached", not: true, group: "state" },
721
- { id: "enabled", label: "is enabled", kind: "enabled", group: "state" },
722
- { id: "disabled", label: "is disabled", kind: "enabled", not: true, group: "state" },
723
- { id: "editable", label: "is editable", kind: "editable", group: "state" },
724
- { id: "readonly", label: "is read-only", kind: "editable", not: true, group: "state" },
725
- { id: "focused", label: "is focused", kind: "focused", group: "state" },
726
- { id: "checked", label: "is checked", kind: "checked", value: "true", group: "state" },
727
- { id: "unchecked", label: "is not checked", kind: "checked", value: "false", group: "state" },
728
- { id: "text", label: "text is exactly", kind: "text", input: true, group: "text" },
729
- { id: "text-contains", label: "text contains", kind: "text", op: "contains", input: true, group: "text" },
730
- { id: "text-matches", label: "text matches pattern", kind: "text", op: "matches", input: true, group: "text" },
731
- { id: "text-not", label: "text is not", kind: "text", not: true, input: true, group: "text" },
732
- { id: "text-not-contains", label: "text does not contain", kind: "text", op: "contains", not: true, input: true, group: "text" },
733
- { id: "value", label: "value is exactly", kind: "value", input: true, group: "value" },
734
- { id: "value-contains", label: "value contains", kind: "value", op: "contains", input: true, group: "value" },
735
- { id: "value-not", label: "value is not", kind: "value", not: true, input: true, group: "value" },
736
- { id: "attr", label: "attribute is", kind: "attribute", input: true, attr: true, group: "attributes" },
737
- { id: "attr-contains", label: "attribute contains", kind: "attribute", op: "contains", input: true, attr: true, group: "attributes" },
738
- { id: "attr-not", label: "attribute is not", kind: "attribute", not: true, input: true, attr: true, group: "attributes" },
739
- { id: "class", label: "has class", kind: "class", input: true, group: "attributes" },
740
- { id: "not-class", label: "does not have class", kind: "class", not: true, input: true, group: "attributes" },
741
- { id: "count", label: "how many \xB7 is exactly", kind: "count", input: true, roleOnly: true, group: "count" },
742
- { id: "count-gte", label: "how many \xB7 is at least", kind: "count", op: "gte", input: true, roleOnly: true, group: "count" },
743
- { id: "count-lte", label: "how many \xB7 is at most", kind: "count", op: "lte", input: true, roleOnly: true, group: "count" },
744
- { id: "url", label: "page URL is", kind: "url", input: true, page: true, group: "page" },
745
- { id: "url-contains", label: "page URL contains", kind: "url", op: "contains", input: true, page: true, group: "page" },
746
- { id: "title", label: "page title is", kind: "title", input: true, page: true, group: "page" },
747
- { id: "title-contains", label: "page title contains", kind: "title", op: "contains", input: true, page: true, group: "page" }
748
- ];
749
- function matchCheck(a) {
750
- const op = a.op ?? "equals";
751
- const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
752
- return CHECKS.find(
753
- (c) => c.kind === a.kind && (c.op ?? "equals") === op && Boolean(c.not) === Boolean(a.not) && // A fixed-value entry (is checked / is not checked) only matches its own
754
- // value, or the two would be indistinguishable.
755
- (c.value === void 0 || c.value === value)
756
- );
766
+ function migrateBareModifierPresses(doc) {
767
+ const MODIFIERS = ["Meta", "Control", "Alt", "Shift"];
768
+ const invented = (step) => step["type"] === "press" && typeof step["value"] === "string" && MODIFIERS.some((m) => step["value"] === `${m}+${m}` || step["value"] === m);
769
+ doc.steps = (Array.isArray(doc.steps) ? doc.steps : []).filter((step) => !invented(step));
757
770
  }
758
- var PROPOSED_LABELS = {
759
- screen: (v) => `screen is \u201C${v}\u201D`,
760
- heading: (v) => `heading is \u201C${v}\u201D`,
761
- message: (v) => `message \u201C${v}\u201D is shown`,
762
- fields_visible: (v) => `fields visible: ${v}`
771
+ var MIGRATIONS = {
772
+ 2: (doc) => {
773
+ migrateScrollPrerequisites(doc);
774
+ migrateRadioCheckedState(doc);
775
+ },
776
+ 3: migrateExplicitSecretRefs,
777
+ 4: migrateBareModifierPresses
763
778
  };
764
- function describeAssertion(a, subject) {
765
- const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
766
- const proposed = PROPOSED_LABELS[a.kind];
767
- if (proposed && !a.target) return proposed(value);
768
- const check = matchCheck(a);
769
- const who = a.target?.name || subject || "";
770
- if (!check) {
771
- return `${a.kind}${a.op && a.op !== "equals" ? ` ${a.op}` : ""}${a.not ? " not" : ""}${value ? ` \u201C${value}\u201D` : ""}`;
779
+ function parseFlowDocument(input) {
780
+ const raw = structuredClone(input);
781
+ let version = typeof raw?.schemaVersion === "number" ? raw.schemaVersion : 2;
782
+ if (version > CURRENT_SCHEMA_VERSION) {
783
+ throw new Error(
784
+ `this flow was written by a newer version of Flowriki (schema ${version}, this build understands ${CURRENT_SCHEMA_VERSION}) \u2014 upgrade to open it`
785
+ );
772
786
  }
773
- const attr = check.attr && a.attr ? ` ${a.attr}` : "";
774
- const expects = check.input ? ` \u201C${value}\u201D` : "";
775
- if (check.page) return `${check.label}${expects}`;
776
- if (check.roleOnly) {
777
- const role = a.target?.role || "element";
778
- return `${role}s \xB7 ${check.label.replace("how many \xB7 ", "")}${expects}`;
787
+ while (version < CURRENT_SCHEMA_VERSION) {
788
+ const migrate = MIGRATIONS[version];
789
+ if (!migrate) throw new Error(`no migration path from schema version ${version}`);
790
+ migrate(raw);
791
+ version += 1;
792
+ raw.schemaVersion = version;
779
793
  }
780
- const label = check.label.replace("attribute", `attribute${attr}`);
781
- return who ? `\u201C${who}\u201D ${label}${expects}` : `${label}${expects}`;
794
+ return FlowDocumentSchema.parse(raw);
782
795
  }
783
796
 
784
- // ../orchestrator/dist/index.js
785
- import { execFileSync } from "child_process";
786
- import fs22 from "fs";
787
- import path23 from "path";
788
-
789
- // ../runner/dist/index.js
790
- import { chromium, firefox, webkit } from "playwright";
797
+ // ../test-model/dist/index.js
798
+ import { z as z4 } from "zod";
799
+ var EngineSchema = z4.enum(["chromium", "firefox", "webkit"]);
800
+ var CapabilitySchema = z4.object({
801
+ id: z4.string(),
802
+ provider: z4.string().default("local"),
803
+ engine: EngineSchema,
804
+ /** Branded local builds: "chrome", "msedge". */
805
+ channel: z4.string().optional(),
806
+ browserVersion: z4.string().optional(),
807
+ os: z4.string().optional(),
808
+ osVersion: z4.string().optional(),
809
+ device: z4.string().optional(),
810
+ viewport: z4.object({ width: z4.number().int(), height: z4.number().int() }).optional()
811
+ });
812
+ var ExecutionConfigSchema = z4.object({
813
+ concurrency: z4.number().int().positive().default(4),
814
+ defaultBrowser: z4.string().default("chromium"),
815
+ retries: z4.number().int().nonnegative().default(0),
816
+ browsers: z4.record(z4.string(), CapabilitySchema.omit({ id: true }).partial({ provider: true })),
817
+ suites: z4.record(
818
+ z4.string(),
819
+ z4.object({
820
+ flows: z4.array(z4.string()).default([]),
821
+ browser: z4.string().optional(),
822
+ /** Opt-in cross-browser fan-out for this suite only. */
823
+ matrix: z4.array(z4.string()).optional()
824
+ })
825
+ ).default({})
826
+ });
791
827
 
792
828
  // ../recorder/dist/index.js
793
829
  function openShadowRoots() {
@@ -882,8 +918,8 @@ function installRecorder() {
882
918
  return Boolean(el?.closest?.("[data-flowriki],[data-flowriki-debug]"));
883
919
  }
884
920
  function actualTarget(e) {
885
- const path8 = typeof e.composedPath === "function" ? e.composedPath() : [];
886
- const first = path8[0];
921
+ const path11 = typeof e.composedPath === "function" ? e.composedPath() : [];
922
+ const first = path11[0];
887
923
  return (first && first.tagName ? first : e.target) ?? null;
888
924
  }
889
925
  w.__flSignature = () => {
@@ -1048,6 +1084,15 @@ function installRecorder() {
1048
1084
  }
1049
1085
  return false;
1050
1086
  }
1087
+ function identityRank(el) {
1088
+ try {
1089
+ const identity = identify(el);
1090
+ if (identity.name || identity.locator) return 2;
1091
+ return roleOf(el) ? 1 : 0;
1092
+ } catch {
1093
+ return 0;
1094
+ }
1095
+ }
1051
1096
  let pendingHover = null;
1052
1097
  let pendingReveal = null;
1053
1098
  let lastClosedWarning = { el: null };
@@ -1080,11 +1125,13 @@ function installRecorder() {
1080
1125
  const hit = actualTarget(e);
1081
1126
  const trigger = (hit && hit.closest ? hit.closest(HOVER_TRIGGERS) : null) ?? hit;
1082
1127
  if (!trigger || !visible(trigger) || !couldReveal(trigger)) return;
1083
- if (pendingHover && Date.now() - pendingHover.at < HOVER_WINDOW_MS) {
1128
+ const rank = identityRank(trigger);
1129
+ if (pendingHover && Date.now() - pendingHover.at < HOVER_WINDOW_MS && pendingHover.rank > 0) {
1084
1130
  if (pendingHover.el === trigger) return;
1085
1131
  if (!pendingHover.seen.has(trigger)) return;
1132
+ if (rank < pendingHover.rank) return;
1086
1133
  }
1087
- pendingHover = { el: trigger, seen: visibleClickables(), at: Date.now() };
1134
+ pendingHover = { el: trigger, seen: visibleClickables(), at: Date.now(), rank };
1088
1135
  },
1089
1136
  true
1090
1137
  );
@@ -1299,12 +1346,12 @@ function installRecorder() {
1299
1346
  });
1300
1347
  return;
1301
1348
  }
1302
- if (hit && visible(hit)) pendingReveal = { el: hit, seen: visibleClickables(), at: Date.now() };
1349
+ if (hit && visible(hit)) pendingReveal = { el: hit, seen: visibleClickables(), at: Date.now(), rank: identityRank(hit) };
1303
1350
  return;
1304
1351
  }
1305
1352
  const hoverMattered = pendingHover && Date.now() - pendingHover.at <= HOVER_WINDOW_MS && (revealed(pendingHover, el) || insideFlyoutOf(pendingHover.el, el));
1306
1353
  if (revealed(pendingReveal, el)) emitTrigger("click", pendingReveal.el);
1307
- else if (hoverMattered) emitTrigger("hover", pendingHover.el);
1354
+ else if (hoverMattered && pendingHover.rank > 0) emitTrigger("hover", pendingHover.el);
1308
1355
  pendingReveal = null;
1309
1356
  pendingHover = null;
1310
1357
  const role = roleOf(el);
@@ -2110,43 +2157,246 @@ function installDebugPanel() {
2110
2157
  c.style.transform = shift ? vertical ? `translateY(${shift}px)` : `translateX(${shift}px)` : "";
2111
2158
  });
2112
2159
  };
2113
- const up = () => {
2114
- card.releasePointerCapture(down.pointerId);
2115
- window.removeEventListener("pointermove", move);
2116
- window.removeEventListener("pointerup", up);
2117
- window.removeEventListener("pointercancel", up);
2118
- dragging = null;
2119
- cards.forEach((c) => {
2120
- c.style.transform = "";
2121
- c.classList.remove("drag");
2122
- });
2123
- if (to !== from) cmd("moveStep", { stepId: card.dataset.id, toIndex: to });
2124
- else if (pendingRender) render();
2160
+ const up = () => {
2161
+ card.releasePointerCapture(down.pointerId);
2162
+ window.removeEventListener("pointermove", move);
2163
+ window.removeEventListener("pointerup", up);
2164
+ window.removeEventListener("pointercancel", up);
2165
+ dragging = null;
2166
+ cards.forEach((c) => {
2167
+ c.style.transform = "";
2168
+ c.classList.remove("drag");
2169
+ });
2170
+ if (to !== from) cmd("moveStep", { stepId: card.dataset.id, toIndex: to });
2171
+ else if (pendingRender) render();
2172
+ };
2173
+ window.addEventListener("pointermove", move);
2174
+ window.addEventListener("pointerup", up);
2175
+ window.addEventListener("pointercancel", up);
2176
+ };
2177
+ w.__flDebugRender = (next) => {
2178
+ state = next;
2179
+ render();
2180
+ };
2181
+ if (document.body) mount();
2182
+ else document.addEventListener("DOMContentLoaded", mount);
2183
+ }
2184
+
2185
+ // ../engine-playwright/dist/index.js
2186
+ import fs4 from "fs";
2187
+ import { createRequire as createRequire2 } from "module";
2188
+ import path3 from "path";
2189
+ import { fileURLToPath } from "url";
2190
+ import "playwright";
2191
+
2192
+ // ../core/dist/index.js
2193
+ function proposeAssertions(before, after) {
2194
+ const out = [];
2195
+ if (!before || !after) return out;
2196
+ if (before.url !== after.url)
2197
+ out.push({
2198
+ kind: "screen",
2199
+ value: after.heading || after.title,
2200
+ why: "navigated to a new screen"
2201
+ });
2202
+ for (const a of after.alerts.filter((x) => !before.alerts.includes(x)))
2203
+ out.push({ kind: "message", value: a, why: "a message appeared that was not there before" });
2204
+ if (before.url === after.url && before.heading !== after.heading && after.heading)
2205
+ out.push({ kind: "heading", value: after.heading, why: "the heading changed in place" });
2206
+ const newFields = after.fields.filter((f) => !before.fields.includes(f));
2207
+ if (newFields.length)
2208
+ out.push({ kind: "fields_visible", value: newFields, why: "new fields became visible" });
2209
+ if (before.url === after.url) {
2210
+ const seenKeys = new Set(before.texts.map((t) => t.key));
2211
+ const covered = /* @__PURE__ */ new Set([after.heading, ...after.alerts]);
2212
+ const appeared = after.texts.filter((t) => !seenKeys.has(t.key) && !covered.has(t.text));
2213
+ for (const t of appeared.slice(0, 2))
2214
+ out.push({
2215
+ kind: "message",
2216
+ value: t.text,
2217
+ why: "this text appeared after the action",
2218
+ confidence: "low"
2219
+ });
2220
+ }
2221
+ return out;
2222
+ }
2223
+ function hasObservableChange(before, after) {
2224
+ return proposeAssertions(before, after).some((p) => p.confidence !== "low");
2225
+ }
2226
+ function isLocalHost(host) {
2227
+ const h = host.toLowerCase();
2228
+ return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "[::1]" || h === "0.0.0.0";
2229
+ }
2230
+ function hasScheme(raw) {
2231
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) return true;
2232
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:(?!\d)/.test(raw);
2233
+ }
2234
+ function normalizeStartUrl(input) {
2235
+ const raw = (input ?? "").trim();
2236
+ if (!raw) throw new Error("enter a URL to record against");
2237
+ let candidate;
2238
+ if (hasScheme(raw)) {
2239
+ candidate = raw;
2240
+ } else {
2241
+ const bare = raw.replace(/^\/+/, "");
2242
+ const host = bare.split(/[/?#]/)[0] ?? "";
2243
+ candidate = `${isLocalHost(host.split(":")[0] ?? "") ? "http" : "https"}://${bare}`;
2244
+ }
2245
+ let parsed;
2246
+ try {
2247
+ parsed = new URL(candidate);
2248
+ } catch {
2249
+ throw new Error(`"${raw}" is not a valid URL`);
2250
+ }
2251
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2252
+ throw new Error(`only http:// and https:// URLs can be recorded (got ${parsed.protocol}//)`);
2253
+ }
2254
+ if (!parsed.hostname) throw new Error(`"${raw}" has no host`);
2255
+ return parsed.toString();
2256
+ }
2257
+ function resolveStartUrl(baseUrl, startPath) {
2258
+ const base = normalizeStartUrl(baseUrl);
2259
+ return new URL(startPath || "/", base).toString();
2260
+ }
2261
+ var INPUT_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "checkbox", "radio", "spinbutton", "searchbox"]);
2262
+ var NAVIGATIONS = /* @__PURE__ */ new Set(["back", "forward"]);
2263
+ function locatorPlan(step) {
2264
+ const o = step.locator;
2265
+ if (o && o.value.trim()) {
2266
+ const value = o.value.trim();
2267
+ switch (o.strategy) {
2268
+ // Playwright reads a leading `xpath=` prefix, and also bare paths that
2269
+ // start with `/` or `//`. Normalising here means the QA can paste either.
2270
+ case "xpath":
2271
+ return { method: "locator", arg: value.startsWith("xpath=") ? value : `xpath=${value}`, overridden: true };
2272
+ // Typed as a bare id, so nobody has to remember the `#`.
2273
+ case "id":
2274
+ return { method: "locator", arg: /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value.replace(/^#/, "")) ? `#${value.replace(/^#/, "")}` : `[id=${JSON.stringify(value.replace(/^#/, ""))}]`, overridden: true };
2275
+ case "css":
2276
+ return { method: "locator", arg: value, overridden: true };
2277
+ case "testId":
2278
+ return { method: "getByTestId", arg: value, overridden: true };
2279
+ case "text":
2280
+ return { method: "getByText", arg: value, exact: true, overridden: true };
2281
+ case "label":
2282
+ return { method: "getByLabel", arg: value, exact: true, overridden: true };
2283
+ case "placeholder":
2284
+ return { method: "getByPlaceholder", arg: value, exact: true, overridden: true };
2285
+ case "altText":
2286
+ return { method: "getByAltText", arg: value, exact: true, overridden: true };
2287
+ case "title":
2288
+ return { method: "getByTitle", arg: value, exact: true, overridden: true };
2289
+ }
2290
+ }
2291
+ if (NAVIGATIONS.has(step.type)) {
2292
+ return { method: step.type === "back" ? "goBack" : "goForward", arg: "", overridden: false };
2293
+ }
2294
+ if (!step.name.trim()) return { method: "none", arg: "", overridden: false };
2295
+ if (step.nameFrom === "adjacent-label") {
2296
+ return {
2297
+ method: "getByText",
2298
+ arg: step.name,
2299
+ exact: true,
2300
+ overridden: false,
2301
+ viaAdjacentLabel: true
2302
+ };
2303
+ }
2304
+ if (step.nameFrom === "text") {
2305
+ return {
2306
+ method: "getByText",
2307
+ arg: step.name,
2308
+ exact: step.type !== "hover",
2309
+ overridden: false,
2310
+ visible: true
2125
2311
  };
2126
- window.addEventListener("pointermove", move);
2127
- window.addEventListener("pointerup", up);
2128
- window.addEventListener("pointercancel", up);
2129
- };
2130
- w.__flDebugRender = (next) => {
2131
- state = next;
2132
- render();
2133
- };
2134
- if (document.body) mount();
2135
- else document.addEventListener("DOMContentLoaded", mount);
2312
+ }
2313
+ return INPUT_ROLES.has(step.role) ? { method: "getByLabel", arg: step.name, exact: true, overridden: false, inputRole: step.role } : step.role ? { method: "getByRole", arg: step.role, name: step.name, exact: true, overridden: false } : { method: "getByText", arg: step.name, exact: step.type !== "hover", overridden: false, visible: true };
2314
+ }
2315
+ function frameSelector(name) {
2316
+ const positional = /^nth:(\d+)$/.exec(name);
2317
+ if (positional) return `iframe, frame >> nth=${positional[1]}`;
2318
+ const v = JSON.stringify(name);
2319
+ return `iframe[title=${v}], iframe[name=${v}], iframe[id=${v}], frame[title=${v}], frame[name=${v}], frame[id=${v}]`;
2320
+ }
2321
+ function inputSelector(role) {
2322
+ return role === "textbox" ? 'input, textarea, [contenteditable="true"], [role="textbox"]' : `input, select, [role="${role}"]`;
2323
+ }
2324
+ var SCOPE_ROLES = ["form", "group", "region", "dialog", "alertdialog"];
2325
+ var CHECKS = [
2326
+ { id: "visible", label: "is visible", kind: "visible", group: "state" },
2327
+ { id: "not-visible", label: "is not visible", kind: "visible", not: true, group: "state" },
2328
+ { id: "attached", label: "exists on the page", kind: "attached", group: "state" },
2329
+ { id: "not-attached", label: "does not exist", kind: "attached", not: true, group: "state" },
2330
+ { id: "enabled", label: "is enabled", kind: "enabled", group: "state" },
2331
+ { id: "disabled", label: "is disabled", kind: "enabled", not: true, group: "state" },
2332
+ { id: "editable", label: "is editable", kind: "editable", group: "state" },
2333
+ { id: "readonly", label: "is read-only", kind: "editable", not: true, group: "state" },
2334
+ { id: "focused", label: "is focused", kind: "focused", group: "state" },
2335
+ { id: "checked", label: "is checked", kind: "checked", value: "true", group: "state" },
2336
+ { id: "unchecked", label: "is not checked", kind: "checked", value: "false", group: "state" },
2337
+ { id: "text", label: "text is exactly", kind: "text", input: true, group: "text" },
2338
+ { id: "text-contains", label: "text contains", kind: "text", op: "contains", input: true, group: "text" },
2339
+ { id: "text-matches", label: "text matches pattern", kind: "text", op: "matches", input: true, group: "text" },
2340
+ { id: "text-not", label: "text is not", kind: "text", not: true, input: true, group: "text" },
2341
+ { id: "text-not-contains", label: "text does not contain", kind: "text", op: "contains", not: true, input: true, group: "text" },
2342
+ { id: "value", label: "value is exactly", kind: "value", input: true, group: "value" },
2343
+ { id: "value-contains", label: "value contains", kind: "value", op: "contains", input: true, group: "value" },
2344
+ { id: "value-not", label: "value is not", kind: "value", not: true, input: true, group: "value" },
2345
+ { id: "attr", label: "attribute is", kind: "attribute", input: true, attr: true, group: "attributes" },
2346
+ { id: "attr-contains", label: "attribute contains", kind: "attribute", op: "contains", input: true, attr: true, group: "attributes" },
2347
+ { id: "attr-not", label: "attribute is not", kind: "attribute", not: true, input: true, attr: true, group: "attributes" },
2348
+ { id: "class", label: "has class", kind: "class", input: true, group: "attributes" },
2349
+ { id: "not-class", label: "does not have class", kind: "class", not: true, input: true, group: "attributes" },
2350
+ { id: "count", label: "how many \xB7 is exactly", kind: "count", input: true, roleOnly: true, group: "count" },
2351
+ { id: "count-gte", label: "how many \xB7 is at least", kind: "count", op: "gte", input: true, roleOnly: true, group: "count" },
2352
+ { id: "count-lte", label: "how many \xB7 is at most", kind: "count", op: "lte", input: true, roleOnly: true, group: "count" },
2353
+ { id: "url", label: "page URL is", kind: "url", input: true, page: true, group: "page" },
2354
+ { id: "url-contains", label: "page URL contains", kind: "url", op: "contains", input: true, page: true, group: "page" },
2355
+ { id: "title", label: "page title is", kind: "title", input: true, page: true, group: "page" },
2356
+ { id: "title-contains", label: "page title contains", kind: "title", op: "contains", input: true, page: true, group: "page" }
2357
+ ];
2358
+ function matchCheck(a) {
2359
+ const op = a.op ?? "equals";
2360
+ const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
2361
+ return CHECKS.find(
2362
+ (c) => c.kind === a.kind && (c.op ?? "equals") === op && Boolean(c.not) === Boolean(a.not) && // A fixed-value entry (is checked / is not checked) only matches its own
2363
+ // value, or the two would be indistinguishable.
2364
+ (c.value === void 0 || c.value === value)
2365
+ );
2366
+ }
2367
+ var PROPOSED_LABELS = {
2368
+ screen: (v) => `screen is \u201C${v}\u201D`,
2369
+ heading: (v) => `heading is \u201C${v}\u201D`,
2370
+ message: (v) => `message \u201C${v}\u201D is shown`,
2371
+ fields_visible: (v) => `fields visible: ${v}`
2372
+ };
2373
+ function describeAssertion(a, subject) {
2374
+ const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
2375
+ const proposed = PROPOSED_LABELS[a.kind];
2376
+ if (proposed && !a.target) return proposed(value);
2377
+ const check = matchCheck(a);
2378
+ const who = a.target?.name || subject || "";
2379
+ if (!check) {
2380
+ return `${a.kind}${a.op && a.op !== "equals" ? ` ${a.op}` : ""}${a.not ? " not" : ""}${value ? ` \u201C${value}\u201D` : ""}`;
2381
+ }
2382
+ const attr = check.attr && a.attr ? ` ${a.attr}` : "";
2383
+ const expects = check.input ? ` \u201C${value}\u201D` : "";
2384
+ if (check.page) return `${check.label}${expects}`;
2385
+ if (check.roleOnly) {
2386
+ const role = a.target?.role || "element";
2387
+ return `${role}s \xB7 ${check.label.replace("how many \xB7 ", "")}${expects}`;
2388
+ }
2389
+ const label = check.label.replace("attribute", `attribute${attr}`);
2390
+ return who ? `\u201C${who}\u201D ${label}${expects}` : `${label}${expects}`;
2136
2391
  }
2137
2392
 
2138
- // ../runner/dist/index.js
2139
- import fs from "fs";
2140
- import { createRequire } from "module";
2141
- import path from "path";
2142
- import { fileURLToPath } from "url";
2143
- import "playwright";
2393
+ // ../engine-playwright/dist/index.js
2144
2394
  import "playwright";
2145
2395
  import { errors } from "playwright";
2146
- import fs2 from "fs";
2147
- import path2 from "path";
2396
+ import fs22 from "fs";
2397
+ import path22 from "path";
2148
2398
  import "playwright";
2149
- import fs3 from "fs";
2399
+ import fs32 from "fs";
2150
2400
  import "playwright";
2151
2401
  var DEFAULT_ENVIRONMENT = {
2152
2402
  browser: "chromium",
@@ -2198,7 +2448,9 @@ function resolveCapture(options = {}) {
2198
2448
  blockedBySecrets: blocked && (policy.video !== "off" || policy.trace !== "off")
2199
2449
  };
2200
2450
  }
2201
- async function launchSession(environment = {}, headless = true, slowMo = 0, capture = {}) {
2451
+ var forcedHeadless = () => process.env["FLOWRIKI_FORCE_HEADLESS"] === "1";
2452
+ async function launchSession(environment = {}, headlessRequested = true, slowMo = 0, capture = {}) {
2453
+ const headless = headlessRequested || forcedHeadless();
2202
2454
  const config = { ...DEFAULT_ENVIRONMENT, ...ExecutionEnvironmentSchema.parse(environment) };
2203
2455
  const fullWindow = config.fullWindow === true && !headless;
2204
2456
  const engine = { chromium, firefox, webkit }[config.browser];
@@ -2413,16 +2665,16 @@ var cachedAccNameScript = null;
2413
2665
  var ACCNAME_ASSET = "accname.global.js";
2414
2666
  function accNameScript() {
2415
2667
  if (cachedAccNameScript === null) {
2416
- cachedAccNameScript = fs.readFileSync(accNameScriptPath(), "utf8");
2668
+ cachedAccNameScript = fs4.readFileSync(accNameScriptPath(), "utf8");
2417
2669
  }
2418
2670
  return cachedAccNameScript;
2419
2671
  }
2420
2672
  function accNameScriptPath() {
2421
2673
  try {
2422
- return createRequire(import.meta.url).resolve("@flowriki/recorder/accname.global.js");
2674
+ return createRequire2(import.meta.url).resolve("@flowriki/recorder/accname.global.js");
2423
2675
  } catch {
2424
- const beside = path.join(path.dirname(fileURLToPath(import.meta.url)), ACCNAME_ASSET);
2425
- if (fs.existsSync(beside)) return beside;
2676
+ const beside = path3.join(path3.dirname(fileURLToPath(import.meta.url)), ACCNAME_ASSET);
2677
+ if (fs4.existsSync(beside)) return beside;
2426
2678
  throw new Error(
2427
2679
  `this build is missing ${ACCNAME_ASSET}, which recording injects into the page \u2014 reinstall the agent`
2428
2680
  );
@@ -2882,9 +3134,9 @@ async function captureFailure(opts) {
2882
3134
  let screenshot = null;
2883
3135
  try {
2884
3136
  const file = `step-${String(ordinal).padStart(2, "0")}.png`;
2885
- fs2.mkdirSync(dir, { recursive: true });
3137
+ fs22.mkdirSync(dir, { recursive: true });
2886
3138
  await page.screenshot({
2887
- path: path2.join(dir, file),
3139
+ path: path22.join(dir, file),
2888
3140
  // The viewport, not the full page: a failure is about what was on screen,
2889
3141
  // and a full-page capture of an infinite-scroll app is a 20MB image.
2890
3142
  fullPage: false,
@@ -2935,7 +3187,7 @@ async function finaliseArtifacts(opts) {
2935
3187
  const out = [];
2936
3188
  const size = (file) => {
2937
3189
  try {
2938
- return fs2.statSync(path2.join(dir, file)).size;
3190
+ return fs22.statSync(path22.join(dir, file)).size;
2939
3191
  } catch {
2940
3192
  return null;
2941
3193
  }
@@ -2944,8 +3196,8 @@ async function finaliseArtifacts(opts) {
2944
3196
  const file = "trace.zip";
2945
3197
  try {
2946
3198
  if (keep(artifacts.trace)) {
2947
- fs2.mkdirSync(dir, { recursive: true });
2948
- await context.tracing.stop({ path: path2.join(dir, file) });
3199
+ fs22.mkdirSync(dir, { recursive: true });
3200
+ await context.tracing.stop({ path: path22.join(dir, file) });
2949
3201
  out.push({ kind: "trace", relPath: file, bytes: size(file) });
2950
3202
  } else {
2951
3203
  await context.tracing.stop();
@@ -2956,8 +3208,8 @@ async function finaliseArtifacts(opts) {
2956
3208
  if (artifacts.console && consoleLines.length) {
2957
3209
  const file = "console.log";
2958
3210
  try {
2959
- fs2.mkdirSync(dir, { recursive: true });
2960
- fs2.writeFileSync(path2.join(dir, file), consoleLines.join("\n") + "\n");
3211
+ fs22.mkdirSync(dir, { recursive: true });
3212
+ fs22.writeFileSync(path22.join(dir, file), consoleLines.join("\n") + "\n");
2961
3213
  out.push({ kind: "console", relPath: file, bytes: size(file) });
2962
3214
  } catch {
2963
3215
  }
@@ -2967,18 +3219,18 @@ async function finaliseArtifacts(opts) {
2967
3219
  function collectVideos(dir, keep) {
2968
3220
  let files;
2969
3221
  try {
2970
- files = fs2.readdirSync(dir).filter((f) => f.endsWith(".webm"));
3222
+ files = fs22.readdirSync(dir).filter((f) => f.endsWith(".webm"));
2971
3223
  } catch {
2972
3224
  return [];
2973
3225
  }
2974
3226
  if (!keep) {
2975
- for (const f of files) fs2.rmSync(path2.join(dir, f), { force: true });
3227
+ for (const f of files) fs22.rmSync(path22.join(dir, f), { force: true });
2976
3228
  return [];
2977
3229
  }
2978
3230
  return files.map((f) => {
2979
3231
  let bytes = null;
2980
3232
  try {
2981
- bytes = fs2.statSync(path2.join(dir, f)).size;
3233
+ bytes = fs22.statSync(path22.join(dir, f)).size;
2982
3234
  } catch {
2983
3235
  bytes = null;
2984
3236
  }
@@ -3622,7 +3874,7 @@ var cachedAccName = null;
3622
3874
  function accNameScript2() {
3623
3875
  if (cachedAccName === null) {
3624
3876
  const path33 = accNameScriptPath();
3625
- cachedAccName = fs3.readFileSync(path33, "utf8");
3877
+ cachedAccName = fs32.readFileSync(path33, "utf8");
3626
3878
  }
3627
3879
  return cachedAccName;
3628
3880
  }
@@ -4002,12 +4254,183 @@ async function startDebugSession(opts) {
4002
4254
  return session;
4003
4255
  }
4004
4256
 
4005
- // ../rundb/dist/index.js
4006
- import fs4 from "fs";
4007
- import { createRequire as createRequire2 } from "module";
4008
- import path22 from "path";
4009
- import os from "os";
4010
- import path3 from "path";
4257
+ // ../results/dist/chunk-ZYUMNEFU.js
4258
+ import { z as z5 } from "zod";
4259
+ var RunStatusSchema = z5.enum([
4260
+ "queued",
4261
+ "running",
4262
+ "passed",
4263
+ "failed",
4264
+ "cancelled",
4265
+ "errored"
4266
+ ]);
4267
+ var TaskStatusSchema = z5.enum([
4268
+ "queued",
4269
+ "running",
4270
+ "passed",
4271
+ "failed",
4272
+ "skipped",
4273
+ "cancelled",
4274
+ "errored"
4275
+ ]);
4276
+ var FailureKindSchema = z5.enum([
4277
+ "not_found",
4278
+ "ambiguous",
4279
+ "timeout",
4280
+ "assertion",
4281
+ "navigation",
4282
+ "crash",
4283
+ "unknown"
4284
+ ]);
4285
+ var RunCheckSchema = z5.object({
4286
+ id: z5.string(),
4287
+ assertionId: z5.string().nullable(),
4288
+ kind: z5.string(),
4289
+ expected: z5.string().nullable(),
4290
+ actual: z5.string().nullable(),
4291
+ ok: z5.boolean(),
4292
+ /** A failed soft check is reported but does not fail its step, so a run can
4293
+ * be green and still carry findings. Stored per check rather than derived,
4294
+ * because the flow it came from can be edited afterwards and history has to
4295
+ * keep saying what actually happened. */
4296
+ soft: z5.boolean().default(false),
4297
+ detail: z5.string().nullable()
4298
+ });
4299
+ var RunStepSchema = z5.object({
4300
+ id: z5.string(),
4301
+ ordinal: z5.number().int().nonnegative(),
4302
+ /** The STABLE document step id — this is what joins runs across time and
4303
+ * makes flake-rate a view over existing columns rather than a migration. */
4304
+ stepId: z5.string(),
4305
+ action: z5.string(),
4306
+ targetRole: z5.string().nullable(),
4307
+ targetName: z5.string().nullable(),
4308
+ targetScope: z5.string().nullable(),
4309
+ /** What the step typed, chose or pressed.
4310
+ *
4311
+ * A report that says `fill "Password"` and nothing else cannot tell you
4312
+ * whether the run used the right credentials, which is most of what anyone
4313
+ * reads a report to find out.
4314
+ *
4315
+ * NEVER set for a secret — see `secret` below. A run report is the thing
4316
+ * people paste into a ticket. */
4317
+ value: z5.string().nullable().default(null),
4318
+ /** The step typed a secret, so `value` is deliberately absent rather than
4319
+ * merely unknown. Stored, because "no value" and "a value withheld" render
4320
+ * differently and history has to keep saying which it was. */
4321
+ secret: z5.boolean().default(false),
4322
+ status: z5.enum(["passed", "failed", "skipped"]),
4323
+ failureKind: FailureKindSchema.nullable(),
4324
+ detail: z5.string().nullable(),
4325
+ durationMs: z5.number().int().nonnegative().nullable(),
4326
+ /** Where the browser was when this step ran. Recorded on failure, and
4327
+ * frequently the whole answer — the element is missing because a step
4328
+ * before it navigated somewhere unexpected, not because it was renamed. */
4329
+ pageUrl: z5.string().nullable().default(null),
4330
+ pageTitle: z5.string().nullable().default(null),
4331
+ checks: z5.array(RunCheckSchema).default([])
4332
+ });
4333
+ var RunTaskSchema = z5.object({
4334
+ id: z5.string(),
4335
+ ordinal: z5.number().int().nonnegative(),
4336
+ caseName: z5.string(),
4337
+ rowId: z5.string().nullable(),
4338
+ capability: CapabilitySchema,
4339
+ provider: z5.string(),
4340
+ attempt: z5.number().int().positive().default(1),
4341
+ maxAttempts: z5.number().int().positive().default(1),
4342
+ status: TaskStatusSchema,
4343
+ workerId: z5.string().nullable(),
4344
+ /** Cloud provider's own session, so a failure is one click from their video. */
4345
+ sessionId: z5.string().nullable(),
4346
+ sessionUrl: z5.string().nullable(),
4347
+ detail: z5.string().nullable(),
4348
+ stepsRun: z5.number().int().nonnegative().default(0),
4349
+ stepsTotal: z5.number().int().nonnegative().default(0),
4350
+ durationMs: z5.number().int().nonnegative().nullable(),
4351
+ steps: z5.array(RunStepSchema).default([])
4352
+ });
4353
+ var ArtifactKindSchema = z5.enum(["screenshot", "video", "trace", "console", "har"]);
4354
+ var ArtifactSchema = z5.object({
4355
+ id: z5.string(),
4356
+ runId: z5.string(),
4357
+ runStepId: z5.string().nullable(),
4358
+ kind: ArtifactKindSchema,
4359
+ relPath: z5.string(),
4360
+ bytes: z5.number().int().nonnegative().nullable(),
4361
+ masked: z5.boolean().default(false),
4362
+ createdAt: z5.string()
4363
+ });
4364
+ var RunSummarySchema = z5.object({
4365
+ id: z5.string(),
4366
+ projectId: z5.string(),
4367
+ /** What the run was FOR.
4368
+ *
4369
+ * `flake` is a deliberate repetition of one flow to measure how often it
4370
+ * passes. It is kept out of pass-rate statistics: a 98/100 flakiness check is
4371
+ * a useful finding, not a failing suite, and counting it as one would make
4372
+ * measuring reliability look like losing it. */
4373
+ kind: z5.enum(["smoke", "test", "adhoc", "flake"]),
4374
+ trigger: z5.enum(["ui", "cli", "ci"]),
4375
+ status: RunStatusSchema,
4376
+ flowId: z5.string().nullable(),
4377
+ flowName: z5.string().nullable(),
4378
+ gitSha: z5.string().nullable(),
4379
+ gitBranch: z5.string().nullable(),
4380
+ gitDirty: z5.boolean().default(false),
4381
+ env: z5.string().nullable(),
4382
+ concurrency: z5.number().int().positive().nullable(),
4383
+ tasksTotal: z5.number().int().nonnegative().default(0),
4384
+ tasksPassed: z5.number().int().nonnegative().default(0),
4385
+ tasksFailed: z5.number().int().nonnegative().default(0),
4386
+ /** Rolled up from the run's tasks rather than stored on the run, so they
4387
+ * cannot drift from the step rows they describe. "9 of 12" in a list is
4388
+ * what tells a reader where a run died; "1 of 1 tasks" tells them nothing
4389
+ * until there is more than one browser in play. */
4390
+ stepsRun: z5.number().int().nonnegative().default(0),
4391
+ stepsTotal: z5.number().int().nonnegative().default(0),
4392
+ queuedAt: z5.string(),
4393
+ startedAt: z5.string().nullable(),
4394
+ finishedAt: z5.string().nullable(),
4395
+ durationMs: z5.number().int().nonnegative().nullable()
4396
+ });
4397
+ var RunDetailSchema = RunSummarySchema.extend({
4398
+ errorMessage: z5.string().nullable(),
4399
+ artifactsDir: z5.string().nullable(),
4400
+ tasks: z5.array(RunTaskSchema).default([]),
4401
+ /** Screenshots and the like, addressed by `runStepId`. Kept flat rather than
4402
+ * nested inside each step: an artifact can belong to the run as a whole. */
4403
+ artifacts: z5.array(ArtifactSchema).default([])
4404
+ });
4405
+
4406
+ // ../results/dist/chunk-IW3PJWP6.js
4407
+ import os4 from "os";
4408
+ import path4 from "path";
4409
+ function flowrikiHome() {
4410
+ return process.env.FLOWRIKI_HOME || path4.join(os4.homedir(), ".flowriki");
4411
+ }
4412
+ function defaultDbPath() {
4413
+ return path4.join(flowrikiHome(), "flowriki.db");
4414
+ }
4415
+ function runArtifactsDir(runId, projectId = "default") {
4416
+ return path4.join(flowrikiHome(), "projects", safe(projectId), "runs", safe(runId));
4417
+ }
4418
+ function safe(segment) {
4419
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment) || segment.includes("..")) {
4420
+ throw new Error(`unsafe path segment: ${segment}`);
4421
+ }
4422
+ return segment;
4423
+ }
4424
+ function isInside(root, candidate) {
4425
+ const base = path4.resolve(root);
4426
+ const target = path4.resolve(candidate);
4427
+ return target === base || target.startsWith(base + path4.sep);
4428
+ }
4429
+
4430
+ // ../results/dist/node.js
4431
+ import fs5 from "fs";
4432
+ import { createRequire as createRequire3 } from "module";
4433
+ import path5 from "path";
4011
4434
  var MIGRATIONS2 = [
4012
4435
  // 1 — initial
4013
4436
  `
@@ -4187,26 +4610,6 @@ function ulid(now = Date.now()) {
4187
4610
  function rowId(prefix) {
4188
4611
  return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
4189
4612
  }
4190
- function flowrikiHome() {
4191
- return process.env.FLOWRIKI_HOME || path3.join(os.homedir(), ".flowriki");
4192
- }
4193
- function defaultDbPath() {
4194
- return path3.join(flowrikiHome(), "flowriki.db");
4195
- }
4196
- function runArtifactsDir(runId, projectId = "default") {
4197
- return path3.join(flowrikiHome(), "projects", safe(projectId), "runs", safe(runId));
4198
- }
4199
- function safe(segment) {
4200
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment) || segment.includes("..")) {
4201
- throw new Error(`unsafe path segment: ${segment}`);
4202
- }
4203
- return segment;
4204
- }
4205
- function isInside(root, candidate) {
4206
- const base = path3.resolve(root);
4207
- const target = path3.resolve(candidate);
4208
- return target === base || target.startsWith(base + path3.sep);
4209
- }
4210
4613
  var b = (v) => v ? 1 : 0;
4211
4614
  var s = (v) => v === void 0 || v === null ? null : String(v);
4212
4615
  var n = (v) => v === void 0 || v === null ? null : Number(v);
@@ -4223,13 +4626,13 @@ var SqliteStorage = class {
4223
4626
  if (this.db) return;
4224
4627
  let DatabaseSync2;
4225
4628
  try {
4226
- ({ DatabaseSync: DatabaseSync2 } = createRequire2(import.meta.url)("node:sqlite"));
4629
+ ({ DatabaseSync: DatabaseSync2 } = createRequire3(import.meta.url)("node:sqlite"));
4227
4630
  } catch (err) {
4228
4631
  throw new Error(
4229
4632
  `run history needs node:sqlite, which this Node (${process.version}) does not provide \u2014 Node 22.13 or newer is required (${err.message})`
4230
4633
  );
4231
4634
  }
4232
- if (this.file !== ":memory:") fs4.mkdirSync(path22.dirname(this.file), { recursive: true });
4635
+ if (this.file !== ":memory:") fs5.mkdirSync(path5.dirname(this.file), { recursive: true });
4233
4636
  const db = new DatabaseSync2(this.file);
4234
4637
  if (this.file !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
4235
4638
  db.exec("PRAGMA busy_timeout = 5000");
@@ -4671,11 +5074,48 @@ function toSummary(row) {
4671
5074
  };
4672
5075
  }
4673
5076
 
4674
- // ../orchestrator/dist/index.js
4675
- import crypto2 from "crypto";
4676
- import { execFileSync as execFileSync2 } from "child_process";
4677
- import fs32 from "fs";
4678
- import path32 from "path";
5077
+ // ../repository-store/dist/chunk-W6R7EC2D.js
5078
+ var FLOWRIKI_DIR = ".flowriki";
5079
+ var FEATURES_DIR = `${FLOWRIKI_DIR}/features`;
5080
+ var CONFIG_PATH = `${FLOWRIKI_DIR}/config.json`;
5081
+ var PROJECT_MARKER_PATH = `${FLOWRIKI_DIR}/project.json`;
5082
+ var FEATURE_FILE = "feature.json";
5083
+ var FLOW_SUFFIX = ".flow.json";
5084
+ function assertSlug(slug) {
5085
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`invalid slug: ${slug}`);
5086
+ return slug;
5087
+ }
5088
+ var featureIdFor = (slug) => `f_${assertSlug(slug)}`;
5089
+ var featureSlugOf = (featureId) => assertSlug(featureId.replace(/^f_/, ""));
5090
+ var featureDir = (slug) => `${FEATURES_DIR}/${assertSlug(slug)}`;
5091
+ function flowPath(doc) {
5092
+ return `${featureDir(featureSlugOf(doc.featureId))}/${assertSlug(doc.slug)}${FLOW_SUFFIX}`;
5093
+ }
5094
+ var isFlowFile = (name) => name.endsWith(FLOW_SUFFIX);
5095
+ var DEFAULT_FLAKINESS_REPEAT = 10;
5096
+ var MAX_FLAKINESS_REPEAT = 500;
5097
+ function clampRepeat(value) {
5098
+ const n2 = Math.floor(Number(value));
5099
+ if (!Number.isFinite(n2) || n2 < 2) return DEFAULT_FLAKINESS_REPEAT;
5100
+ return Math.min(n2, MAX_FLAKINESS_REPEAT);
5101
+ }
5102
+ var defaultConfig = () => ({
5103
+ baseUrl: "",
5104
+ headless: false,
5105
+ environment: {},
5106
+ artifacts: {},
5107
+ flakinessRepeat: DEFAULT_FLAKINESS_REPEAT
5108
+ });
5109
+ function parseConfig(raw) {
5110
+ const source = raw && typeof raw === "object" ? raw : {};
5111
+ return {
5112
+ baseUrl: typeof source["baseUrl"] === "string" ? source["baseUrl"] : "",
5113
+ headless: source["headless"] === true,
5114
+ environment: ExecutionEnvironmentSchema.parse(source["environment"] ?? {}),
5115
+ artifacts: ArtifactPolicySchema.parse(source["artifacts"] ?? {}),
5116
+ flakinessRepeat: clampRepeat(source["flakinessRepeat"])
5117
+ };
5118
+ }
4679
5119
  var FlowNotFound = class extends Error {
4680
5120
  constructor(message) {
4681
5121
  super(message);
@@ -4690,36 +5130,24 @@ var FlowConflict = class extends Error {
4690
5130
  }
4691
5131
  currentRevision;
4692
5132
  };
4693
- function writeFileAtomic(file, contents) {
4694
- const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
4695
- const handle = fs5.openSync(tmp, "w");
4696
- try {
4697
- fs5.writeFileSync(handle, contents);
4698
- fs5.fsyncSync(handle);
4699
- } finally {
4700
- fs5.closeSync(handle);
4701
- }
4702
- try {
4703
- fs5.renameSync(tmp, file);
4704
- } catch (err) {
4705
- fs5.rmSync(tmp, { force: true });
4706
- throw err;
4707
- }
4708
- }
4709
5133
  var FlowNotRunnable = class extends Error {
4710
5134
  constructor(message, reason) {
4711
5135
  super(message);
4712
5136
  this.reason = reason;
5137
+ this.name = "FlowNotRunnable";
4713
5138
  }
4714
5139
  reason;
4715
5140
  };
4716
- var DEFAULT_FLAKINESS_REPEAT = 10;
4717
- var MAX_FLAKINESS_REPEAT = 500;
4718
- function clampRepeat(value) {
4719
- const n2 = Math.floor(Number(value));
4720
- if (!Number.isFinite(n2) || n2 < 2) return DEFAULT_FLAKINESS_REPEAT;
4721
- return Math.min(n2, MAX_FLAKINESS_REPEAT);
4722
- }
5141
+
5142
+ // ../repository-store/dist/local.js
5143
+ import fs23 from "fs";
5144
+ import path23 from "path";
5145
+ import fs6 from "fs";
5146
+ import path6 from "path";
5147
+ import { execFileSync } from "child_process";
5148
+ import { execFileSync as execFileSync2 } from "child_process";
5149
+ import fs33 from "fs";
5150
+ import path32 from "path";
4723
5151
  function uniqueRef(flowSlug, name, taken) {
4724
5152
  const base = `${flowSlug}_${slugify(name) || "secret"}`;
4725
5153
  if (!taken.has(base)) {
@@ -4734,32 +5162,207 @@ function uniqueRef(flowSlug, name, taken) {
4734
5162
  }
4735
5163
  }
4736
5164
  }
5165
+ var LocalSecrets = class {
5166
+ constructor(root) {
5167
+ this.root = root;
5168
+ }
5169
+ root;
5170
+ get secretsPath() {
5171
+ return path6.join(this.root, ".flowriki", "secrets.local.json");
5172
+ }
5173
+ readSecrets() {
5174
+ try {
5175
+ return JSON.parse(fs6.readFileSync(this.secretsPath, "utf8"));
5176
+ } catch {
5177
+ return {};
5178
+ }
5179
+ }
5180
+ /** Move every captured secret VALUE out of the flow and into the local
5181
+ * store, leaving a reference behind.
5182
+ *
5183
+ * The recorder can only capture what was typed, so the raw password does
5184
+ * reach this process — but it must never reach the flow file, which is
5185
+ * meant to be committed. This is the one place that separation happens, so
5186
+ * it happens before the flow is ever written to disk.
5187
+ *
5188
+ * Returns the steps with values replaced by references. */
5189
+ extractSecrets(flowSlug, steps) {
5190
+ const secrets = this.readSecrets();
5191
+ const taken = /* @__PURE__ */ new Set();
5192
+ const byValue = /* @__PURE__ */ new Map();
5193
+ let changed = false;
5194
+ for (const step of steps) {
5195
+ if (step.secretRef) taken.add(step.secretRef);
5196
+ else if (step.secret && step.value && secrets[step.value] !== void 0) taken.add(step.value);
5197
+ }
5198
+ for (const step of steps) {
5199
+ if (!step.secret) continue;
5200
+ if (step.secretRef) continue;
5201
+ if (!step.value) continue;
5202
+ if (secrets[step.value] !== void 0) {
5203
+ step.secretRef = step.value;
5204
+ step.value = "";
5205
+ changed = true;
5206
+ continue;
5207
+ }
5208
+ const key = `${slugify(step.name)} ${step.value}`;
5209
+ let ref = byValue.get(key);
5210
+ if (!ref) {
5211
+ ref = uniqueRef(flowSlug, step.name, taken);
5212
+ byValue.set(key, ref);
5213
+ taken.add(ref);
5214
+ secrets[ref] = step.value;
5215
+ }
5216
+ step.secretRef = ref;
5217
+ step.value = "";
5218
+ changed = true;
5219
+ }
5220
+ if (changed) {
5221
+ fs6.mkdirSync(path6.dirname(this.secretsPath), { recursive: true });
5222
+ fs6.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2) + "\n", { mode: 384 });
5223
+ this.ensureSecretsIgnored();
5224
+ }
5225
+ return steps;
5226
+ }
5227
+ /** The secret references a flow needs that this machine does not hold.
5228
+ *
5229
+ * Reported rather than resolved: a reference with no value is a setup step
5230
+ * the person has not done yet, and inventing one — which is what reading the
5231
+ * reference name as the password amounted to — turns a clear prompt into a
5232
+ * mysterious authentication failure. */
5233
+ unresolvedSecrets(flow) {
5234
+ const secrets = this.readSecrets();
5235
+ const missing = [];
5236
+ const seen = /* @__PURE__ */ new Set();
5237
+ for (const step of flow.steps) {
5238
+ if (!step.secret || step.disabled) continue;
5239
+ const ref = step.secretRef ?? "";
5240
+ if (ref && secrets[ref] !== void 0) continue;
5241
+ const key = ref || `step:${step.id}`;
5242
+ if (seen.has(key)) continue;
5243
+ seen.add(key);
5244
+ missing.push({ ref, field: step.name });
5245
+ }
5246
+ return missing;
5247
+ }
5248
+ writeSecret(ref, value) {
5249
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(ref)) throw new Error(`invalid secret name: ${ref}`);
5250
+ const secrets = this.readSecrets();
5251
+ secrets[ref] = value;
5252
+ fs6.mkdirSync(path6.dirname(this.secretsPath), { recursive: true });
5253
+ fs6.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2) + "\n", { mode: 384 });
5254
+ this.ensureSecretsIgnored();
5255
+ }
5256
+ /** Move plaintext passwords out of flow documents recorded before secrets
5257
+ * were extracted at save time.
5258
+ *
5259
+ * Only ever touches a value that is PROVABLY a literal — one that does not
5260
+ * match the reference grammar, and so could never have been written by
5261
+ * `writeSecret`. A reference is by then an explicit `secretRef`, put there by
5262
+ * the schema migration without needing to consult this store.
5263
+ *
5264
+ * This is the whole of the fix for the failure this used to cause: it decided
5265
+ * "reference or password?" by asking whether the local store held the key,
5266
+ * which answers "no" on a fresh clone, a colleague's machine or CI — so a
5267
+ * working reference was rewritten into a password equal to its own name, and
5268
+ * the real credential was lost. It no longer asks that question.
5269
+ *
5270
+ * Returns the flows it changed. */
5271
+ migrateLeakedSecrets(flows, save) {
5272
+ const secrets = this.readSecrets();
5273
+ const healed = [];
5274
+ for (const flow of flows) {
5275
+ let changed = false;
5276
+ const taken = new Set(flow.steps.flatMap((s2) => s2.secretRef ? [s2.secretRef] : []));
5277
+ for (const step of flow.steps) {
5278
+ if (!step.secret || step.secretRef || !step.value) continue;
5279
+ if (isSecretRefName(step.value)) continue;
5280
+ const ref = uniqueRef(flow.slug, step.name, taken);
5281
+ taken.add(ref);
5282
+ secrets[ref] = step.value;
5283
+ step.secretRef = ref;
5284
+ step.value = "";
5285
+ changed = true;
5286
+ }
5287
+ if (changed) {
5288
+ save(flow);
5289
+ healed.push(flow.name);
5290
+ }
5291
+ }
5292
+ if (healed.length) {
5293
+ fs6.mkdirSync(path6.dirname(this.secretsPath), { recursive: true });
5294
+ fs6.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2) + "\n", { mode: 384 });
5295
+ this.ensureSecretsIgnored();
5296
+ }
5297
+ return healed;
5298
+ }
5299
+ /** Recovery copies are machine-local and must not be committed. */
5300
+ ensureRecoveryIgnored() {
5301
+ this.ensureIgnored(".recovery/", "Recovery copies are local scratch, not part of the project.");
5302
+ }
5303
+ /** The secret store sits inside .flowriki/, which IS committed — so the one
5304
+ * file that must not be has to say so itself. */
5305
+ ensureSecretsIgnored() {
5306
+ this.ensureIgnored("secrets.local.json", "Secret values never belong in version control.");
5307
+ }
5308
+ /** Add one entry to .flowriki/.gitignore if it is not already there. */
5309
+ ensureIgnored(line, why) {
5310
+ const ignore = path6.join(this.root, ".flowriki", ".gitignore");
5311
+ const existing = fs6.existsSync(ignore) ? fs6.readFileSync(ignore, "utf8") : "";
5312
+ if (existing.split("\n").includes(line)) return;
5313
+ fs6.mkdirSync(path6.dirname(ignore), { recursive: true });
5314
+ fs6.writeFileSync(
5315
+ ignore,
5316
+ `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}# ${why}
5317
+ ${line}
5318
+ `
5319
+ );
5320
+ }
5321
+ };
5322
+ function writeFileAtomic(file, contents) {
5323
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
5324
+ const handle = fs23.openSync(tmp, "w");
5325
+ try {
5326
+ fs23.writeFileSync(handle, contents);
5327
+ fs23.fsyncSync(handle);
5328
+ } finally {
5329
+ fs23.closeSync(handle);
5330
+ }
5331
+ try {
5332
+ fs23.renameSync(tmp, file);
5333
+ } catch (err) {
5334
+ fs23.rmSync(tmp, { force: true });
5335
+ throw err;
5336
+ }
5337
+ }
4737
5338
  var Store = class {
4738
5339
  constructor(root) {
4739
5340
  this.root = root;
5341
+ this.secrets = new LocalSecrets(root);
4740
5342
  }
4741
5343
  root;
5344
+ secrets;
5345
+ /** The repository layout, rooted at this checkout. The segments come from
5346
+ * the shared layout rules, so a flow written here lands in the same file the
5347
+ * hosted API would have written it to. */
4742
5348
  get featuresDir() {
4743
- return path4.join(this.root, ".flowriki", "features");
5349
+ return path23.join(this.root, ...FEATURES_DIR.split("/"));
4744
5350
  }
4745
- /** Every path that comes from a request goes through this. A slug is a
4746
- * single path segment and nothing else — no traversal, no absolute paths. */
4747
5351
  assertSlug(slug) {
4748
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`invalid slug: ${slug}`);
4749
- return slug;
5352
+ return assertSlug(slug);
4750
5353
  }
4751
5354
  listFeatures() {
4752
- if (!fs5.existsSync(this.featuresDir)) return [];
5355
+ if (!fs23.existsSync(this.featuresDir)) return [];
4753
5356
  const out = [];
4754
- for (const entry of fs5.readdirSync(this.featuresDir, { withFileTypes: true })) {
5357
+ for (const entry of fs23.readdirSync(this.featuresDir, { withFileTypes: true })) {
4755
5358
  if (!entry.isDirectory()) continue;
4756
- const file = path4.join(this.featuresDir, entry.name, "feature.json");
4757
- if (!fs5.existsSync(file)) continue;
5359
+ const file = path23.join(this.featuresDir, entry.name, FEATURE_FILE);
5360
+ if (!fs23.existsSync(file)) continue;
4758
5361
  try {
4759
- out.push(FeatureSchema.parse(JSON.parse(fs5.readFileSync(file, "utf8"))));
5362
+ out.push(FeatureSchema.parse(JSON.parse(fs23.readFileSync(file, "utf8"))));
4760
5363
  } catch (err) {
4761
5364
  this.featureProblems.push({
4762
- path: path4.relative(this.root, file),
5365
+ path: path23.relative(this.root, file),
4763
5366
  error: String(err instanceof Error ? err.message : err).split("\n")[0].trim(),
4764
5367
  recoverable: false
4765
5368
  });
@@ -4770,22 +5373,22 @@ var Store = class {
4770
5373
  featureProblems = [];
4771
5374
  createFeature(name, description = "") {
4772
5375
  const slug = this.assertSlug(slugify(name));
4773
- const dir = path4.join(this.featuresDir, slug);
4774
- if (fs5.existsSync(dir)) throw new Error(`a feature named "${name}" already exists`);
4775
- fs5.mkdirSync(dir, { recursive: true });
5376
+ const dir = path23.join(this.featuresDir, slug);
5377
+ if (fs23.existsSync(dir)) throw new Error(`a feature named "${name}" already exists`);
5378
+ fs23.mkdirSync(dir, { recursive: true });
4776
5379
  const doc = {
4777
5380
  schemaVersion: 1,
4778
- id: `f_${slug}`,
5381
+ id: featureIdFor(slug),
4779
5382
  slug,
4780
5383
  name: name.trim(),
4781
5384
  description: description.trim(),
4782
5385
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4783
5386
  };
4784
- fs5.writeFileSync(path4.join(dir, "feature.json"), JSON.stringify(doc, null, 2) + "\n");
5387
+ fs23.writeFileSync(path23.join(dir, FEATURE_FILE), JSON.stringify(doc, null, 2) + "\n");
4785
5388
  return doc;
4786
5389
  }
4787
5390
  deleteFeature(slug) {
4788
- fs5.rmSync(path4.join(this.featuresDir, this.assertSlug(slug)), { recursive: true, force: true });
5391
+ fs23.rmSync(path23.join(this.featuresDir, this.assertSlug(slug)), { recursive: true, force: true });
4789
5392
  }
4790
5393
  /** Every flow that could be read.
4791
5394
  *
@@ -4799,18 +5402,18 @@ var Store = class {
4799
5402
  const out = [];
4800
5403
  this.problemsFound = [];
4801
5404
  for (const slug of features) {
4802
- const dir = path4.join(this.featuresDir, slug);
4803
- if (!fs5.existsSync(dir)) continue;
4804
- for (const file of fs5.readdirSync(dir)) {
4805
- if (!file.endsWith(".flow.json")) continue;
4806
- const full = path4.join(dir, file);
5405
+ const dir = path23.join(this.featuresDir, slug);
5406
+ if (!fs23.existsSync(dir)) continue;
5407
+ for (const file of fs23.readdirSync(dir)) {
5408
+ if (!isFlowFile(file)) continue;
5409
+ const full = path23.join(dir, file);
4807
5410
  try {
4808
- out.push(parseFlowDocument(JSON.parse(fs5.readFileSync(full, "utf8"))));
5411
+ out.push(parseFlowDocument(JSON.parse(fs23.readFileSync(full, "utf8"))));
4809
5412
  } catch (err) {
4810
5413
  this.problemsFound.push({
4811
- path: path4.relative(this.root, full),
5414
+ path: path23.relative(this.root, full),
4812
5415
  error: String(err instanceof Error ? err.message : err).split("\n")[0].trim(),
4813
- recoverable: fs5.existsSync(this.recoveryPathFor(full))
5416
+ recoverable: fs23.existsSync(this.recoveryPathFor(full))
4814
5417
  });
4815
5418
  }
4816
5419
  }
@@ -4833,15 +5436,15 @@ var Store = class {
4833
5436
  * Outside `features/`, because that directory is the product and is
4834
5437
  * committed; a recovery copy is machine-local noise. */
4835
5438
  recoveryPathFor(file) {
4836
- return path4.join(this.root, ".flowriki", ".recovery", path4.basename(file) + ".bak");
5439
+ return path23.join(this.root, ".flowriki", ".recovery", path23.basename(file) + ".bak");
4837
5440
  }
4838
5441
  /** Restore a flow file from its recovery copy. Returns false when there is
4839
5442
  * none, which is the honest answer rather than an invented document. */
4840
5443
  recoverFlowFile(relativePath) {
4841
- const file = path4.join(this.root, relativePath);
5444
+ const file = path23.join(this.root, relativePath);
4842
5445
  const backup = this.recoveryPathFor(file);
4843
- if (!fs5.existsSync(backup)) return false;
4844
- fs5.copyFileSync(backup, file);
5446
+ if (!fs23.existsSync(backup)) return false;
5447
+ fs23.copyFileSync(backup, file);
4845
5448
  return true;
4846
5449
  }
4847
5450
  /** Write a flow, atomically, keeping the previous contents recoverable.
@@ -4858,11 +5461,11 @@ var Store = class {
4858
5461
  saveFlow(input, opts = {}) {
4859
5462
  const doc = parseFlowDocument(input);
4860
5463
  const featureSlug = this.assertSlug(doc.featureId.replace(/^f_/, ""));
4861
- const dir = path4.join(this.featuresDir, featureSlug);
4862
- fs5.mkdirSync(dir, { recursive: true });
4863
- const file = path4.join(dir, `${this.assertSlug(doc.slug)}.flow.json`);
5464
+ const dir = path23.join(this.featuresDir, featureSlug);
5465
+ fs23.mkdirSync(dir, { recursive: true });
5466
+ const file = path23.join(dir, `${this.assertSlug(doc.slug)}${FLOW_SUFFIX}`);
4864
5467
  const destination = this.readFlowFile(file);
4865
- if (fs5.existsSync(file) && (!destination || destination.id !== doc.id)) {
5468
+ if (fs23.existsSync(file) && (!destination || destination.id !== doc.id)) {
4866
5469
  throw new FlowConflict("the destination contains another flow or an unreadable document \u2014 choose another name or recover it first", destination?.revision ?? 0);
4867
5470
  }
4868
5471
  const current = this.getFlow(doc.id) ?? destination;
@@ -4879,21 +5482,21 @@ var Store = class {
4879
5482
  }
4880
5483
  }
4881
5484
  doc.revision = (current?.revision ?? doc.revision ?? 0) + 1;
4882
- const previousFile = current ? path4.join(this.root, this.relPath(current)) : file;
4883
- if (fs5.existsSync(previousFile)) {
5485
+ const previousFile = current ? path23.join(this.root, this.relPath(current)) : file;
5486
+ if (fs23.existsSync(previousFile)) {
4884
5487
  const backup = this.recoveryPathFor(previousFile);
4885
- fs5.mkdirSync(path4.dirname(backup), { recursive: true });
4886
- fs5.copyFileSync(previousFile, backup);
5488
+ fs23.mkdirSync(path23.dirname(backup), { recursive: true });
5489
+ fs23.copyFileSync(previousFile, backup);
4887
5490
  this.ensureRecoveryIgnored();
4888
5491
  }
4889
5492
  writeFileAtomic(file, JSON.stringify(doc, null, 2) + "\n");
4890
- if (previousFile !== file) fs5.rmSync(previousFile);
4891
- return path4.relative(this.root, file);
5493
+ if (previousFile !== file) fs23.rmSync(previousFile);
5494
+ return path23.relative(this.root, file);
4892
5495
  }
4893
5496
  /** Read one flow file, or null when it is missing or unreadable. */
4894
5497
  readFlowFile(file) {
4895
5498
  try {
4896
- return parseFlowDocument(JSON.parse(fs5.readFileSync(file, "utf8")));
5499
+ return parseFlowDocument(JSON.parse(fs23.readFileSync(file, "utf8")));
4897
5500
  } catch {
4898
5501
  return null;
4899
5502
  }
@@ -4930,10 +5533,10 @@ var Store = class {
4930
5533
  deleteFlowDocument(doc) {
4931
5534
  const featureSlug = this.assertSlug(doc.featureId.replace(/^f_/, ""));
4932
5535
  this.assertSlug(doc.slug);
4933
- fs5.rmSync(path4.join(this.featuresDir, featureSlug, `${doc.slug}.flow.json`), { force: true });
5536
+ fs23.rmSync(path23.join(this.featuresDir, featureSlug, `${doc.slug}${FLOW_SUFFIX}`), { force: true });
4934
5537
  }
4935
5538
  relPath(doc) {
4936
- return path4.join(".flowriki", "features", doc.featureId.replace(/^f_/, ""), `${doc.slug}.flow.json`);
5539
+ return path23.join(...flowPath(doc).split("/"));
4937
5540
  }
4938
5541
  /** The environment a flow actually executes in.
4939
5542
  *
@@ -4959,23 +5562,13 @@ var Store = class {
4959
5562
  * per flow, and it travels with the repo like everything else in
4960
5563
  * `.flowriki/`. */
4961
5564
  get configPath() {
4962
- return path4.join(this.root, ".flowriki", "config.json");
5565
+ return path23.join(this.root, ...CONFIG_PATH.split("/"));
4963
5566
  }
4964
5567
  readConfig() {
4965
5568
  try {
4966
- const raw = JSON.parse(fs5.readFileSync(this.configPath, "utf8"));
4967
- return {
4968
- // Normalised on write, so anything here is already a URL. Read back
4969
- // leniently anyway: this file is git-tracked and routinely arrives
4970
- // from a merge or somebody's editor.
4971
- baseUrl: typeof raw.baseUrl === "string" ? raw.baseUrl : "",
4972
- headless: raw.headless === true,
4973
- environment: ExecutionEnvironmentSchema.parse(raw.environment ?? {}),
4974
- artifacts: ArtifactPolicySchema.parse(raw.artifacts ?? {}),
4975
- flakinessRepeat: clampRepeat(raw.flakinessRepeat)
4976
- };
5569
+ return parseConfig(JSON.parse(fs23.readFileSync(this.configPath, "utf8")));
4977
5570
  } catch {
4978
- return { baseUrl: "", headless: false, environment: {}, artifacts: {}, flakinessRepeat: DEFAULT_FLAKINESS_REPEAT };
5571
+ return defaultConfig();
4979
5572
  }
4980
5573
  }
4981
5574
  writeConfig(input) {
@@ -4992,8 +5585,8 @@ var Store = class {
4992
5585
  artifacts: ArtifactPolicySchema.parse(input.artifacts ?? existing.artifacts),
4993
5586
  flakinessRepeat: clampRepeat(input.flakinessRepeat ?? existing.flakinessRepeat)
4994
5587
  };
4995
- fs5.mkdirSync(path4.dirname(this.configPath), { recursive: true });
4996
- fs5.writeFileSync(this.configPath, JSON.stringify(config, null, 2) + "\n");
5588
+ fs23.mkdirSync(path23.dirname(this.configPath), { recursive: true });
5589
+ fs23.writeFileSync(this.configPath, JSON.stringify(config, null, 2) + "\n");
4997
5590
  return config;
4998
5591
  }
4999
5592
  /** Where a flow actually starts.
@@ -5016,63 +5609,6 @@ var Store = class {
5016
5609
  startUrlFor(flow) {
5017
5610
  return resolveStartUrl(this.baseUrlFor(flow), flow.startPath);
5018
5611
  }
5019
- get secretsPath() {
5020
- return path4.join(this.root, ".flowriki", "secrets.local.json");
5021
- }
5022
- readSecrets() {
5023
- try {
5024
- return JSON.parse(fs5.readFileSync(this.secretsPath, "utf8"));
5025
- } catch {
5026
- return {};
5027
- }
5028
- }
5029
- /** Move every captured secret VALUE out of the flow and into the local
5030
- * store, leaving a reference behind.
5031
- *
5032
- * The recorder can only capture what was typed, so the raw password does
5033
- * reach this process — but it must never reach the flow file, which is
5034
- * meant to be committed. This is the one place that separation happens, so
5035
- * it happens before the flow is ever written to disk.
5036
- *
5037
- * Returns the steps with values replaced by references. */
5038
- extractSecrets(flowSlug, steps) {
5039
- const secrets = this.readSecrets();
5040
- const taken = /* @__PURE__ */ new Set();
5041
- const byValue = /* @__PURE__ */ new Map();
5042
- let changed = false;
5043
- for (const step of steps) {
5044
- if (step.secretRef) taken.add(step.secretRef);
5045
- else if (step.secret && step.value && secrets[step.value] !== void 0) taken.add(step.value);
5046
- }
5047
- for (const step of steps) {
5048
- if (!step.secret) continue;
5049
- if (step.secretRef) continue;
5050
- if (!step.value) continue;
5051
- if (secrets[step.value] !== void 0) {
5052
- step.secretRef = step.value;
5053
- step.value = "";
5054
- changed = true;
5055
- continue;
5056
- }
5057
- const key = `${slugify(step.name)} ${step.value}`;
5058
- let ref = byValue.get(key);
5059
- if (!ref) {
5060
- ref = uniqueRef(flowSlug, step.name, taken);
5061
- byValue.set(key, ref);
5062
- taken.add(ref);
5063
- secrets[ref] = step.value;
5064
- }
5065
- step.secretRef = ref;
5066
- step.value = "";
5067
- changed = true;
5068
- }
5069
- if (changed) {
5070
- fs5.mkdirSync(path4.dirname(this.secretsPath), { recursive: true });
5071
- fs5.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2) + "\n", { mode: 384 });
5072
- this.ensureSecretsIgnored();
5073
- }
5074
- return steps;
5075
- }
5076
5612
  /** Where a file an upload step needs is looked for.
5077
5613
  *
5078
5614
  * The browser never gives a page the file's PATH, only its name — so a
@@ -5080,109 +5616,41 @@ var Store = class {
5080
5616
  * file. Committed alongside the flows, because a test that needs a 2KB
5081
5617
  * sample CSV should not need a side-channel to share it. */
5082
5618
  get filesDir() {
5083
- return path4.join(this.root, ".flowriki", "files");
5619
+ return path23.join(this.root, ".flowriki", "files");
5084
5620
  }
5085
5621
  /** Resolve an upload step's file name to a path, or undefined when it is
5086
5622
  * not on this machine. */
5087
5623
  resolveFile(name) {
5088
5624
  const first = name;
5089
5625
  if (!first || first.includes("/") || first.includes("\\") || first.includes("..")) return void 0;
5090
- const file = path4.join(this.filesDir, first);
5091
- return fs5.existsSync(file) ? file : void 0;
5626
+ const file = path23.join(this.filesDir, first);
5627
+ return fs23.existsSync(file) ? file : void 0;
5092
5628
  }
5093
- /** The secret references a flow needs that this machine does not hold.
5094
- *
5095
- * Reported rather than resolved: a reference with no value is a setup step
5096
- * the person has not done yet, and inventing one — which is what reading the
5097
- * reference name as the password amounted to — turns a clear prompt into a
5098
- * mysterious authentication failure. */
5099
- unresolvedSecrets(flow) {
5100
- const secrets = this.readSecrets();
5101
- const missing = [];
5102
- const seen = /* @__PURE__ */ new Set();
5103
- for (const step of flow.steps) {
5104
- if (!step.secret || step.disabled) continue;
5105
- const ref = step.secretRef ?? "";
5106
- if (ref && secrets[ref] !== void 0) continue;
5107
- const key = ref || `step:${step.id}`;
5108
- if (seen.has(key)) continue;
5109
- seen.add(key);
5110
- missing.push({ ref, field: step.name });
5111
- }
5112
- return missing;
5629
+ // ── secret values, held by `LocalSecrets` ───────────────────────────────
5630
+ //
5631
+ // Delegated rather than re-implemented, and delegated rather than exposed:
5632
+ // every existing caller of `store.readSecrets()` keeps working, and the file
5633
+ // handling lives in one place that a reader can take in whole.
5634
+ readSecrets() {
5635
+ return this.secrets.readSecrets();
5636
+ }
5637
+ extractSecrets(flowSlug, steps) {
5638
+ return this.secrets.extractSecrets(flowSlug, steps);
5639
+ }
5640
+ unresolvedSecrets(flow) {
5641
+ return this.secrets.unresolvedSecrets(flow);
5113
5642
  }
5114
5643
  writeSecret(ref, value) {
5115
- if (!/^[a-z0-9][a-z0-9_-]*$/.test(ref)) throw new Error(`invalid secret name: ${ref}`);
5116
- const secrets = this.readSecrets();
5117
- secrets[ref] = value;
5118
- fs5.mkdirSync(path4.dirname(this.secretsPath), { recursive: true });
5119
- fs5.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2) + "\n", { mode: 384 });
5120
- this.ensureSecretsIgnored();
5644
+ this.secrets.writeSecret(ref, value);
5121
5645
  }
5122
- /** Move plaintext passwords out of flow documents recorded before secrets
5123
- * were extracted at save time.
5124
- *
5125
- * Only ever touches a value that is PROVABLY a literal — one that does not
5126
- * match the reference grammar, and so could never have been written by
5127
- * `writeSecret`. A reference is by then an explicit `secretRef`, put there by
5128
- * the schema migration without needing to consult this store.
5129
- *
5130
- * This is the whole of the fix for the failure this used to cause: it decided
5131
- * "reference or password?" by asking whether the local store held the key,
5132
- * which answers "no" on a fresh clone, a colleague's machine or CI — so a
5133
- * working reference was rewritten into a password equal to its own name, and
5134
- * the real credential was lost. It no longer asks that question.
5135
- *
5136
- * Returns the flows it changed. */
5137
5646
  migrateLeakedSecrets() {
5138
- const secrets = this.readSecrets();
5139
- const healed = [];
5140
- for (const flow of this.listFlows()) {
5141
- let changed = false;
5142
- const taken = new Set(flow.steps.flatMap((s2) => s2.secretRef ? [s2.secretRef] : []));
5143
- for (const step of flow.steps) {
5144
- if (!step.secret || step.secretRef || !step.value) continue;
5145
- if (isSecretRefName(step.value)) continue;
5146
- const ref = uniqueRef(flow.slug, step.name, taken);
5147
- taken.add(ref);
5148
- secrets[ref] = step.value;
5149
- step.secretRef = ref;
5150
- step.value = "";
5151
- changed = true;
5152
- }
5153
- if (changed) {
5154
- this.saveFlow(flow);
5155
- healed.push(flow.name);
5156
- }
5157
- }
5158
- if (healed.length) {
5159
- fs5.mkdirSync(path4.dirname(this.secretsPath), { recursive: true });
5160
- fs5.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2) + "\n", { mode: 384 });
5161
- this.ensureSecretsIgnored();
5162
- }
5163
- return healed;
5647
+ return this.secrets.migrateLeakedSecrets(this.listFlows(), (doc) => {
5648
+ this.saveFlow(doc);
5649
+ });
5164
5650
  }
5165
5651
  /** Recovery copies are machine-local and must not be committed. */
5166
5652
  ensureRecoveryIgnored() {
5167
- this.ensureIgnored(".recovery/", "Recovery copies are local scratch, not part of the project.");
5168
- }
5169
- /** The secret store sits inside .flowriki/, which IS committed — so the one
5170
- * file that must not be has to say so itself. */
5171
- ensureSecretsIgnored() {
5172
- this.ensureIgnored("secrets.local.json", "Secret values never belong in version control.");
5173
- }
5174
- /** Add one entry to .flowriki/.gitignore if it is not already there. */
5175
- ensureIgnored(line, why) {
5176
- const ignore = path4.join(this.root, ".flowriki", ".gitignore");
5177
- const existing = fs5.existsSync(ignore) ? fs5.readFileSync(ignore, "utf8") : "";
5178
- if (existing.split("\n").includes(line)) return;
5179
- fs5.mkdirSync(path4.dirname(ignore), { recursive: true });
5180
- fs5.writeFileSync(
5181
- ignore,
5182
- `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}# ${why}
5183
- ${line}
5184
- `
5185
- );
5653
+ this.secrets.ensureIgnored(".recovery/", "Recovery copies are local scratch, not part of the project.");
5186
5654
  }
5187
5655
  };
5188
5656
  function git(cwd, args) {
@@ -5212,6 +5680,100 @@ function readGitInfo(cwd) {
5212
5680
  cache.set(cwd, info);
5213
5681
  return info;
5214
5682
  }
5683
+ function git2(root, args) {
5684
+ return execFileSync2("git", args, {
5685
+ cwd: root,
5686
+ encoding: "utf8",
5687
+ stdio: ["ignore", "pipe", "pipe"]
5688
+ }).trim();
5689
+ }
5690
+ function tryGit(root, args) {
5691
+ try {
5692
+ return git2(root, args);
5693
+ } catch {
5694
+ return null;
5695
+ }
5696
+ }
5697
+ function readRepo(root) {
5698
+ const isRepo = tryGit(root, ["rev-parse", "--is-inside-work-tree"]) === "true";
5699
+ const markerFile = path32.join(root, MARKER_PATH);
5700
+ let marker = null;
5701
+ let markerUnreadable = false;
5702
+ if (fs33.existsSync(markerFile)) {
5703
+ try {
5704
+ marker = ProjectMarkerSchema.parse(JSON.parse(fs33.readFileSync(markerFile, "utf8")));
5705
+ } catch {
5706
+ markerUnreadable = true;
5707
+ }
5708
+ }
5709
+ if (!isRepo) {
5710
+ return {
5711
+ isRepo: false,
5712
+ rootCommit: null,
5713
+ branch: null,
5714
+ hasRemote: false,
5715
+ clean: true,
5716
+ marker,
5717
+ markerUnreadable,
5718
+ markerIgnored: false
5719
+ };
5720
+ }
5721
+ const roots = tryGit(root, ["rev-list", "--max-parents=0", "HEAD"]);
5722
+ const rootCommit = roots ? roots.split("\n").pop() || null : null;
5723
+ return {
5724
+ isRepo: true,
5725
+ rootCommit,
5726
+ // An empty repository has no branch until the first commit creates one, so
5727
+ // this being null is a state to handle rather than a failure.
5728
+ branch: tryGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]),
5729
+ hasRemote: Boolean(tryGit(root, ["remote"])),
5730
+ clean: (tryGit(root, ["status", "--porcelain"]) ?? "") === "",
5731
+ marker,
5732
+ markerUnreadable,
5733
+ // `check-ignore` exits non-zero when the path is NOT ignored, so a null
5734
+ // here is the good answer.
5735
+ markerIgnored: tryGit(root, ["check-ignore", MARKER_PATH]) !== null
5736
+ };
5737
+ }
5738
+ var ClaimRefused = class extends Error {
5739
+ };
5740
+ function writeMarker(root, marker, opts = {}) {
5741
+ const facts = readRepo(root);
5742
+ if (!facts.isRepo) throw new ClaimRefused("that folder is not a git repository");
5743
+ if (facts.markerIgnored) {
5744
+ throw new ClaimRefused(
5745
+ `${MARKER_PATH} is covered by a .gitignore rule, so committing it would do nothing \u2014 remove the rule first`
5746
+ );
5747
+ }
5748
+ if (facts.marker) {
5749
+ throw new ClaimRefused(`this folder already carries a marker for ${facts.marker.projectId}`);
5750
+ }
5751
+ if (!facts.clean) {
5752
+ throw new ClaimRefused("this repository has uncommitted changes \u2014 commit or stash them first");
5753
+ }
5754
+ const file = path32.join(root, MARKER_PATH);
5755
+ fs33.mkdirSync(path32.dirname(file), { recursive: true });
5756
+ fs33.writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
5757
+ git2(root, ["add", "--", MARKER_PATH]);
5758
+ git2(root, ["commit", "-m", `Bind this repository to Flowriki project ${marker.projectId}`]);
5759
+ const commit = git2(root, ["rev-parse", "HEAD"]);
5760
+ const branch = tryGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
5761
+ let pushed = false;
5762
+ if (opts.push !== false && facts.hasRemote && branch) {
5763
+ pushed = tryGit(root, ["push", "-u", "origin", branch]) !== null;
5764
+ }
5765
+ return { commit, pushed, branch };
5766
+ }
5767
+ function ensureRootCommit(root) {
5768
+ const facts = readRepo(root);
5769
+ if (!facts.isRepo) throw new ClaimRefused("that folder is not a git repository");
5770
+ if (facts.rootCommit) return facts.rootCommit;
5771
+ git2(root, ["commit", "--allow-empty", "-m", "Initialise repository"]);
5772
+ return git2(root, ["rev-list", "--max-parents=0", "HEAD"]).split("\n").pop();
5773
+ }
5774
+
5775
+ // ../execution/dist/index.js
5776
+ import crypto3 from "crypto";
5215
5777
  var LOCAL_CHROMIUM = { id: "chromium", provider: "local", engine: "chromium" };
5216
5778
  async function executeFlow(opts) {
5217
5779
  const { store, storage = null, flowId, trigger, watch = false, onEvent } = opts;
@@ -5260,7 +5822,7 @@ async function executeFlow(opts) {
5260
5822
  const perStep = /* @__PURE__ */ new Map();
5261
5823
  for (let attempt = 1; attempt <= repeat; attempt++) {
5262
5824
  if (opts.signal?.aborted) break;
5263
- const evidenceDir = runDir && repeat > 1 ? path23.join(runDir, `attempt-${String(attempt).padStart(3, "0")}`) : runDir;
5825
+ const evidenceDir = runDir && repeat > 1 ? path7.join(runDir, `attempt-${String(attempt).padStart(3, "0")}`) : runDir;
5264
5826
  const relativeTo = (file) => repeat > 1 ? `attempt-${String(attempt).padStart(3, "0")}/${file}` : file;
5265
5827
  const taskId = storage && run ? await storage.createTask(run.id, {
5266
5828
  caseName: repeat > 1 ? `${flow.name} \xB7 attempt ${attempt}` : flow.name,
@@ -5403,7 +5965,7 @@ async function executeFlow(opts) {
5403
5965
  errorMessage: last.error,
5404
5966
  // Recorded only when something was actually written, so a passing run
5405
5967
  // does not claim a directory that does not exist.
5406
- artifactsDir: runDir && fs22.existsSync(runDir) ? runDir : null
5968
+ artifactsDir: runDir && fs7.existsSync(runDir) ? runDir : null
5407
5969
  });
5408
5970
  }
5409
5971
  await emit({ type: "run.finished", runId: run?.id ?? "", flowId: flow.id, outcome: last });
@@ -5442,7 +6004,7 @@ function report(attempts, perStep) {
5442
6004
  function sizeOf(dir, file) {
5443
6005
  if (!dir) return null;
5444
6006
  try {
5445
- return fs22.statSync(path23.join(dir, file)).size;
6007
+ return fs7.statSync(path7.join(dir, file)).size;
5446
6008
  } catch {
5447
6009
  return null;
5448
6010
  }
@@ -5667,7 +6229,7 @@ var RecordingCoordinator = class {
5667
6229
  this.deps.store.extractSecrets(slugify(draft.name), steps);
5668
6230
  const doc = {
5669
6231
  schemaVersion: CURRENT_SCHEMA_VERSION,
5670
- id: `flw_${crypto2.randomUUID().slice(0, 8)}`,
6232
+ id: `flw_${crypto3.randomUUID().slice(0, 8)}`,
5671
6233
  featureId: feature.id,
5672
6234
  name: draft.name,
5673
6235
  slug: slugify(draft.name),
@@ -5717,6 +6279,10 @@ var RecordingCoordinator = class {
5717
6279
  }
5718
6280
  };
5719
6281
  var BrowserBusy = class extends Error {
6282
+ constructor(message) {
6283
+ super(message);
6284
+ this.name = "BrowserBusy";
6285
+ }
5720
6286
  };
5721
6287
  var COMMANDS = /* @__PURE__ */ new Set([
5722
6288
  "play",
@@ -5838,11 +6404,25 @@ var DebugCoordinator = class {
5838
6404
  const steps = await session.finish();
5839
6405
  const flow = this.deps.store.getFlow(flowId);
5840
6406
  if (!flow) return { ok: false, error: "flow not found" };
5841
- this.deps.store.extractSecrets(flow.slug, steps);
6407
+ if (this.deps.commit.to === "caller") {
6408
+ const { scratch } = this.deps.commit;
6409
+ const before = scratch.readSecrets();
6410
+ scratch.extractSecrets(flow.slug, steps);
6411
+ const after = scratch.readSecrets();
6412
+ const captured = {};
6413
+ for (const [ref, value] of Object.entries(after)) {
6414
+ if (before[ref] !== value) captured[ref] = value;
6415
+ }
6416
+ const updated2 = { ...flow, steps, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
6417
+ this.deps.emit({ type: "debug.ended", flowId, committed: true });
6418
+ return { ok: true, committed: true, flow: updated2, secrets: captured };
6419
+ }
6420
+ const { store } = this.deps.commit;
6421
+ store.extractSecrets(flow.slug, steps);
5842
6422
  const updated = { ...flow, steps, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
5843
- this.deps.store.saveFlow(updated);
6423
+ store.saveFlow(updated);
5844
6424
  this.deps.emit({ type: "debug.ended", flowId, committed: true });
5845
- return { ok: true, committed: true, flow: { ...updated, path: this.deps.store.relPath(updated) } };
6425
+ return { ok: true, committed: true, flow: { ...updated, path: store.relPath(updated) } };
5846
6426
  } finally {
5847
6427
  this.deps.lease?.release("debug");
5848
6428
  }
@@ -5867,8 +6447,8 @@ async function verifyFlowSteps(deps, opts) {
5867
6447
  throw new BrowserBusy(`the browser on this machine is in use (${claim.heldBy}, ${claim.phase})`);
5868
6448
  }
5869
6449
  deps.lease?.activate("run");
5870
- const secrets = store.readSecrets();
5871
6450
  try {
6451
+ const secrets = store.readSecrets();
5872
6452
  return await verifySteps({
5873
6453
  environment: store.environmentFor(flow),
5874
6454
  resolveFile: (name) => store.resolveFile(name),
@@ -5890,252 +6470,194 @@ async function verifyFlowSteps(deps, opts) {
5890
6470
  deps.lease?.release("run");
5891
6471
  }
5892
6472
  }
5893
- function git2(root, args) {
5894
- return execFileSync2("git", args, {
5895
- cwd: root,
5896
- encoding: "utf8",
5897
- stdio: ["ignore", "pipe", "pipe"]
5898
- }).trim();
5899
- }
5900
- function tryGit(root, args) {
5901
- try {
5902
- return git2(root, args);
5903
- } catch {
5904
- return null;
5905
- }
5906
- }
5907
- function readRepo(root) {
5908
- const isRepo = tryGit(root, ["rev-parse", "--is-inside-work-tree"]) === "true";
5909
- const markerFile = path32.join(root, MARKER_PATH);
5910
- let marker = null;
5911
- let markerUnreadable = false;
5912
- if (fs32.existsSync(markerFile)) {
5913
- try {
5914
- marker = ProjectMarkerSchema.parse(JSON.parse(fs32.readFileSync(markerFile, "utf8")));
5915
- } catch {
5916
- markerUnreadable = true;
5917
- }
5918
- }
5919
- if (!isRepo) {
5920
- return {
5921
- isRepo: false,
5922
- rootCommit: null,
5923
- branch: null,
5924
- hasRemote: false,
5925
- clean: true,
5926
- marker,
5927
- markerUnreadable,
5928
- markerIgnored: false
5929
- };
5930
- }
5931
- const roots = tryGit(root, ["rev-list", "--max-parents=0", "HEAD"]);
5932
- const rootCommit = roots ? roots.split("\n").pop() || null : null;
6473
+ function deliveredRun(delivered) {
6474
+ const { flow, secrets, config } = delivered;
5933
6475
  return {
5934
- isRepo: true,
5935
- rootCommit,
5936
- // An empty repository has no branch until the first commit creates one, so
5937
- // this being null is a state to handle rather than a failure.
5938
- branch: tryGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]),
5939
- hasRemote: Boolean(tryGit(root, ["remote"])),
5940
- clean: (tryGit(root, ["status", "--porcelain"]) ?? "") === "",
5941
- marker,
5942
- markerUnreadable,
5943
- // `check-ignore` exits non-zero when the path is NOT ignored, so a null
5944
- // here is the good answer.
5945
- markerIgnored: tryGit(root, ["check-ignore", MARKER_PATH]) !== null
6476
+ readConfig: () => config,
6477
+ // By id, and only the one that was sent. A machine serving no checkout has
6478
+ // no other flow to confuse it with.
6479
+ getFlow: (id) => id === flow.id ? flow : null,
6480
+ readSecrets: () => secrets,
6481
+ baseUrlFor: (f) => f.baseUrl ?? config.baseUrl ?? "",
6482
+ environmentFor: (f) => ({ ...config.environment, ...f.environment ?? {} }),
6483
+ // See RunSource.resolveFile: there is no project directory here.
6484
+ resolveFile: () => void 0
5946
6485
  };
5947
6486
  }
5948
- var ClaimRefused = class extends Error {
5949
- };
5950
- function writeMarker(root, marker, opts = {}) {
5951
- const facts = readRepo(root);
5952
- if (!facts.isRepo) throw new ClaimRefused("that folder is not a git repository");
5953
- if (facts.markerIgnored) {
5954
- throw new ClaimRefused(
5955
- `${MARKER_PATH} is covered by a .gitignore rule, so committing it would do nothing \u2014 remove the rule first`
5956
- );
6487
+ var BrowserLease = class {
6488
+ owner = null;
6489
+ phase = "idle";
6490
+ since = null;
6491
+ /** Take the lease, or report who has it. Never awaits — that is the point. */
6492
+ acquire(owner) {
6493
+ if (this.owner !== null) {
6494
+ return { ok: false, heldBy: this.owner, phase: this.phase };
6495
+ }
6496
+ this.owner = owner;
6497
+ this.phase = "starting";
6498
+ this.since = Date.now();
6499
+ return { ok: true };
5957
6500
  }
5958
- if (facts.marker) {
5959
- throw new ClaimRefused(`this folder already carries a marker for ${facts.marker.projectId}`);
6501
+ /** The holder finished starting and is now doing the thing. */
6502
+ activate(owner) {
6503
+ if (this.owner === owner) this.phase = "active";
5960
6504
  }
5961
- if (!facts.clean) {
5962
- throw new ClaimRefused("this repository has uncommitted changes \u2014 commit or stash them first");
6505
+ /** The holder has begun shutting down. Still not idle: a second stop must
6506
+ * join the first rather than starting its own. */
6507
+ beginRelease(owner) {
6508
+ if (this.owner !== owner || this.phase === "stopping") return false;
6509
+ this.phase = "stopping";
6510
+ return true;
5963
6511
  }
5964
- const file = path32.join(root, MARKER_PATH);
5965
- fs32.mkdirSync(path32.dirname(file), { recursive: true });
5966
- fs32.writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
5967
- git2(root, ["add", "--", MARKER_PATH]);
5968
- git2(root, ["commit", "-m", `Bind this repository to Flowriki project ${marker.projectId}`]);
5969
- const commit = git2(root, ["rev-parse", "HEAD"]);
5970
- const branch = tryGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
5971
- let pushed = false;
5972
- if (opts.push !== false && facts.hasRemote && branch) {
5973
- pushed = tryGit(root, ["push", "-u", "origin", branch]) !== null;
6512
+ release(owner) {
6513
+ if (this.owner !== owner) return;
6514
+ this.owner = null;
6515
+ this.phase = "idle";
6516
+ this.since = null;
6517
+ }
6518
+ /** Drop the lease whoever holds it — shutdown, and nothing else. */
6519
+ forceRelease() {
6520
+ this.owner = null;
6521
+ this.phase = "idle";
6522
+ this.since = null;
6523
+ }
6524
+ state() {
6525
+ return { owner: this.owner, phase: this.phase, since: this.since };
6526
+ }
6527
+ /** The sentence a refused caller is shown. Names what is happening, because
6528
+ * "busy" leaves someone hunting for a window they cannot find. */
6529
+ static refusal(heldBy, phase) {
6530
+ const what = heldBy === "recording" ? "a recording" : heldBy === "debug" ? "a debug session" : "a run";
6531
+ if (phase === "starting") return `${what} is starting \u2014 wait for it to open, or stop it first`;
6532
+ if (phase === "stopping") return `${what} is still closing \u2014 try again in a moment`;
6533
+ return `${what} is already using the browser \u2014 stop it before starting another`;
6534
+ }
6535
+ };
6536
+
6537
+ // src/engine/checkouts.ts
6538
+ function resolveCheckouts(dirs) {
6539
+ const projects = [];
6540
+ const skipped = [];
6541
+ for (const dir of dirs) {
6542
+ const root = path8.resolve(dir);
6543
+ if (!fs8.existsSync(root)) {
6544
+ skipped.push({ root, why: "no such folder" });
6545
+ continue;
6546
+ }
6547
+ const facts = readRepo(root);
6548
+ if (!facts.isRepo) {
6549
+ skipped.push({ root, why: "not a git repository" });
6550
+ continue;
6551
+ }
6552
+ if (facts.markerUnreadable) {
6553
+ skipped.push({ root, why: `${MARKER_PATH} is there but cannot be read` });
6554
+ continue;
6555
+ }
6556
+ if (!facts.marker) {
6557
+ skipped.push({ root, why: "not connected to a Flowriki project \u2014 connect its repository from the dashboard" });
6558
+ continue;
6559
+ }
6560
+ if (projects.some((p) => p.id === facts.marker.projectId)) {
6561
+ skipped.push({ root, why: "another folder here is already that project" });
6562
+ continue;
6563
+ }
6564
+ projects.push({ id: facts.marker.projectId, name: facts.marker.name, root });
5974
6565
  }
5975
- return { commit, pushed, branch };
5976
- }
5977
- function ensureRootCommit(root) {
5978
- const facts = readRepo(root);
5979
- if (!facts.isRepo) throw new ClaimRefused("that folder is not a git repository");
5980
- if (facts.rootCommit) return facts.rootCommit;
5981
- git2(root, ["commit", "--allow-empty", "-m", "Initialise repository"]);
5982
- return git2(root, ["rev-list", "--max-parents=0", "HEAD"]).split("\n").pop();
6566
+ return { projects, skipped };
5983
6567
  }
5984
- function deliveredRun(delivered) {
5985
- const { flow, secrets, config } = delivered;
6568
+
6569
+ // ../agent-core/dist/chunk-PLNYCQPO.js
6570
+ import { WebSocket, WebSocketServer } from "ws";
6571
+ var HEARTBEAT_MS = 3e4;
6572
+ function adapt(socket) {
6573
+ let closed = false;
6574
+ const closeHandlers = [];
6575
+ const fire = () => {
6576
+ if (closed) return;
6577
+ closed = true;
6578
+ clearInterval(heartbeat);
6579
+ for (const handler of closeHandlers) handler();
6580
+ };
6581
+ socket.on("close", fire);
6582
+ socket.on("error", fire);
6583
+ let answered = true;
6584
+ socket.on("pong", () => {
6585
+ answered = true;
6586
+ });
6587
+ const heartbeat = setInterval(() => {
6588
+ if (!answered) {
6589
+ try {
6590
+ socket.terminate();
6591
+ } catch {
6592
+ }
6593
+ fire();
6594
+ return;
6595
+ }
6596
+ answered = false;
6597
+ try {
6598
+ socket.ping();
6599
+ } catch {
6600
+ fire();
6601
+ }
6602
+ }, HEARTBEAT_MS);
6603
+ heartbeat.unref?.();
5986
6604
  return {
5987
- readConfig: () => config,
5988
- // By id, and only the one that was sent. A machine serving no checkout has
5989
- // no other flow to confuse it with.
5990
- getFlow: (id) => id === flow.id ? flow : null,
5991
- readSecrets: () => secrets,
5992
- baseUrlFor: (f) => f.baseUrl ?? config.baseUrl ?? "",
5993
- environmentFor: (f) => ({ ...config.environment, ...f.environment ?? {} }),
5994
- // See RunSource.resolveFile: there is no project directory here.
5995
- resolveFile: () => void 0
6605
+ send: (data) => {
6606
+ if (socket.readyState !== WebSocket.OPEN) return;
6607
+ try {
6608
+ socket.send(data);
6609
+ } catch {
6610
+ fire();
6611
+ }
6612
+ },
6613
+ close: () => {
6614
+ try {
6615
+ socket.close();
6616
+ } catch {
6617
+ }
6618
+ fire();
6619
+ },
6620
+ onMessage: (handler) => {
6621
+ socket.on("message", (raw, isBinary) => {
6622
+ if (isBinary) return;
6623
+ handler(raw.toString());
6624
+ });
6625
+ },
6626
+ onClose: (handler) => {
6627
+ if (closed) {
6628
+ handler();
6629
+ return;
6630
+ }
6631
+ closeHandlers.push(handler);
6632
+ }
5996
6633
  };
5997
6634
  }
6635
+ function dialWebSocket(opts) {
6636
+ return () => new Promise((resolve, reject) => {
6637
+ const socket = new WebSocket(opts.url, {
6638
+ handshakeTimeout: opts.handshakeTimeoutMs ?? 1e4,
6639
+ ...opts.headers ? { headers: opts.headers } : {}
6640
+ });
6641
+ const failed = (err) => {
6642
+ try {
6643
+ socket.terminate();
6644
+ } catch {
6645
+ }
6646
+ reject(err instanceof Error ? err : new Error(String(err)));
6647
+ };
6648
+ socket.once("open", () => {
6649
+ socket.off("error", failed);
6650
+ resolve(adapt(socket));
6651
+ });
6652
+ socket.once("error", failed);
6653
+ });
6654
+ }
5998
6655
 
5999
6656
  // ../agent-core/dist/index.js
6000
- var PROTOCOL_VERSION = 1;
6001
- var PAIRING_CODE_TTL_MS = 5 * 60 * 1e3;
6002
- var JOURNAL_LIMIT = 1e3;
6003
- var CommandNameSchema = z4.enum([
6004
- "record.start",
6005
- "record.stop",
6006
- "record.discard",
6007
- "record.status",
6008
- "record.dialog",
6009
- "flow.run",
6010
- "flow.save",
6011
- "flow.update",
6012
- "flow.delete",
6013
- "flow.verify",
6014
- "flow.get",
6015
- "debug.start",
6016
- "debug.status",
6017
- "debug.command",
6018
- "debug.end",
6019
- "config.save",
6020
- "run.cancel",
6021
- "feature.create",
6022
- "secret.set",
6023
- "project.list",
6024
- "project.status",
6025
- "project.claim",
6026
- "runs.list",
6027
- "runs.stats",
6028
- "run.get",
6029
- "artifact.fetch"
6030
- ]);
6031
- var EFFECTFUL = /* @__PURE__ */ new Set([
6032
- "record.start",
6033
- "record.stop",
6034
- "record.discard",
6035
- // Answering a dialog is a click on the page under test. Re-delivering it
6036
- // after a dropped socket would answer the NEXT dialog with the previous
6037
- // reply — `record.status` stays out of this set because reading is safe to
6038
- // repeat, and that is the whole distinction.
6039
- "record.dialog",
6040
- "flow.run",
6041
- "flow.save",
6042
- "flow.update",
6043
- "flow.delete",
6044
- // Verification and a debug session both open a browser on somebody's machine
6045
- // and both can be told to record steps into a draft. Neither is a read.
6046
- "flow.verify",
6047
- "debug.start",
6048
- "debug.command",
6049
- "debug.end",
6050
- "config.save",
6051
- // Setting a secret writes a file. Idempotent — the same value written twice
6052
- // is the same value — but it is a write, and the deduplication path is the
6053
- // right default for one.
6054
- "secret.set",
6055
- // Creating a feature makes a directory and a file. A blind retry after a
6056
- // dropped socket would find it already there and report a failure for
6057
- // something that had in fact succeeded.
6058
- "feature.create",
6059
- // Claiming writes a file, makes a commit and pushes. Blind retry after a
6060
- // dropped socket would commit twice.
6061
- "project.claim",
6062
- "run.cancel"
6063
- ]);
6064
- var CommandSchema = z4.object({
6065
- /** Unique per command. The agent stores it, so a redelivery after a
6066
- * reconnect is recognised rather than performed twice. */
6067
- id: z4.string().min(1).max(128),
6068
- v: z4.number().int().positive(),
6069
- name: CommandNameSchema,
6070
- /** Which project on the agent's machine. Never a path — a name the user has
6071
- * explicitly mapped, so the service cannot reach arbitrary files. */
6072
- projectId: z4.string().min(1),
6073
- /** The revision the caller believes it is acting on, where that matters. A
6074
- * save built on a stale copy is refused rather than applied. */
6075
- flowRevision: z4.number().int().nonnegative().optional(),
6076
- /** Wall-clock expiry. A command that surfaced from a queue an hour late must
6077
- * not start a browser on somebody's machine. */
6078
- expiresAt: z4.number().int().positive(),
6079
- /** When the service stamped this, by the service's clock.
6080
- *
6081
- * Present so the agent never has to compare a timestamp minted on another
6082
- * machine against its own clock. `expiresAt` alone made the deadline
6083
- * meaningless the moment two clocks disagreed: a machine running five
6084
- * minutes fast rejected every command the instant it arrived, saying it had
6085
- * expired before it got there — which is true only if you believe both
6086
- * clocks. Optional, so an older service that does not send it still works;
6087
- * the agent falls back to the absolute deadline it has always used. */
6088
- issuedAt: z4.number().int().positive().optional(),
6089
- args: z4.record(z4.unknown()).default({})
6090
- // UNKNOWN FIELDS ARE IGNORED, not refused.
6091
- //
6092
- // Strictness here looked like caution and was a compatibility trap: the day
6093
- // the service added `issuedAt`, every agent older than that release began
6094
- // refusing every command as unrecognised — over a field it had no need to
6095
- // read. The allowlist on command NAMES is the security boundary and is
6096
- // unchanged; which extra keys ride along is not, because nothing acts on a
6097
- // field it does not know about.
6098
- });
6099
- var AckSchema = z4.object({
6100
- type: z4.literal("ack"),
6101
- commandId: z4.string().min(1),
6102
- /** `accepted` means the agent has taken the work on, not that it finished.
6103
- * `duplicate` means it has done this exact command before and is returning
6104
- * the first outcome. */
6105
- status: z4.enum(["accepted", "duplicate", "refused"]),
6106
- reason: z4.string().optional(),
6107
- result: z4.record(z4.unknown()).optional()
6108
- }).strict();
6109
- var AgentEventSchema = z4.object({
6110
- type: z4.literal("event"),
6111
- /** Monotonic per agent connection-independent stream. A client that
6112
- * reconnects says which sequence it last saw and is sent the rest. */
6113
- seq: z4.number().int().nonnegative(),
6114
- at: z4.number().int().nonnegative(),
6115
- name: z4.string().min(1),
6116
- commandId: z4.string().optional(),
6117
- sessionId: z4.string().optional(),
6118
- runId: z4.string().optional(),
6119
- data: z4.record(z4.unknown()).default({})
6120
- }).strict();
6121
- var GapSchema = z4.object({
6122
- type: z4.literal("gap"),
6123
- from: z4.number().int().nonnegative(),
6124
- to: z4.number().int().nonnegative()
6125
- }).strict();
6126
- var HelloSchema = z4.object({
6127
- type: z4.literal("hello"),
6128
- protocolVersion: z4.number().int().positive(),
6129
- agentVersion: z4.string(),
6130
- deviceId: z4.string().min(1),
6131
- projects: z4.array(z4.object({ id: z4.string(), name: z4.string() })).default([])
6132
- }).strict();
6133
- var AgentMessageSchema = z4.discriminatedUnion("type", [
6134
- AckSchema,
6135
- AgentEventSchema,
6136
- GapSchema,
6137
- HelloSchema
6138
- ]);
6657
+ import fs9 from "fs";
6658
+ import path9 from "path";
6659
+ import crypto4 from "crypto";
6660
+ import { createRequire as createRequire4 } from "module";
6139
6661
  var EventJournal = class {
6140
6662
  constructor(limit = JOURNAL_LIMIT, state) {
6141
6663
  this.limit = limit;
@@ -6220,16 +6742,16 @@ var EventJournal = class {
6220
6742
  return this.state ? this.state.size() : this.events.length;
6221
6743
  }
6222
6744
  };
6223
- var { DatabaseSync } = createRequire3(import.meta.url)("node:sqlite");
6745
+ var { DatabaseSync } = createRequire4(import.meta.url)("node:sqlite");
6224
6746
  var AgentState = class {
6225
6747
  constructor(file, deviceId, commandLimit = 1e5) {
6226
6748
  this.commandLimit = commandLimit;
6227
6749
  if (!file || file === ":memory:") throw new Error("a persistent agent state file is required");
6228
6750
  if (!Number.isInteger(commandLimit) || commandLimit < 1) throw new Error("invalid command capacity");
6229
- fs6.mkdirSync(path5.dirname(file), { recursive: true, mode: 448 });
6230
- const fd = fs6.openSync(file, "a", 384);
6231
- fs6.closeSync(fd);
6232
- fs6.chmodSync(file, 384);
6751
+ fs9.mkdirSync(path9.dirname(file), { recursive: true, mode: 448 });
6752
+ const fd = fs9.openSync(file, "a", 384);
6753
+ fs9.closeSync(fd);
6754
+ fs9.chmodSync(file, 384);
6233
6755
  this.db = new DatabaseSync(file);
6234
6756
  try {
6235
6757
  this.db.exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;");
@@ -6279,7 +6801,7 @@ var AgentState = class {
6279
6801
  const { expiresAt: _expiry, issuedAt: _issued, ...work } = command;
6280
6802
  const text = JSON.stringify(canonical(work));
6281
6803
  if (Buffer.byteLength(text) > 256 * 1024) throw new Error("command exceeds local size limit");
6282
- return crypto22.createHash("sha256").update(text).digest("hex");
6804
+ return crypto4.createHash("sha256").update(text).digest("hex");
6283
6805
  }
6284
6806
  get(id) {
6285
6807
  const row = this.db.prepare("SELECT fingerprint, state, ack FROM commands WHERE id=?").get(id);
@@ -6618,120 +7140,39 @@ var AgentLink = class {
6618
7140
  if (message["type"] === "command") {
6619
7141
  const ack = await this.opts.agent.handle(message["command"]);
6620
7142
  this.send(ack);
6621
- this.flush();
6622
- return;
6623
- }
6624
- if (message["type"] === "acked") {
6625
- const seq = Number(message["seq"] ?? -1);
6626
- if (!Number.isInteger(seq)) return;
6627
- this.ackedSeq = Math.max(this.ackedSeq, seq);
6628
- this.flush();
6629
- }
6630
- }
6631
- };
6632
- var HEARTBEAT_MS = 3e4;
6633
- function adapt(socket) {
6634
- let closed = false;
6635
- const closeHandlers = [];
6636
- const fire = () => {
6637
- if (closed) return;
6638
- closed = true;
6639
- clearInterval(heartbeat);
6640
- for (const handler of closeHandlers) handler();
6641
- };
6642
- socket.on("close", fire);
6643
- socket.on("error", fire);
6644
- let answered = true;
6645
- socket.on("pong", () => {
6646
- answered = true;
6647
- });
6648
- const heartbeat = setInterval(() => {
6649
- if (!answered) {
6650
- try {
6651
- socket.terminate();
6652
- } catch {
6653
- }
6654
- fire();
7143
+ this.flush();
6655
7144
  return;
6656
7145
  }
6657
- answered = false;
6658
- try {
6659
- socket.ping();
6660
- } catch {
6661
- fire();
6662
- }
6663
- }, HEARTBEAT_MS);
6664
- heartbeat.unref?.();
6665
- return {
6666
- send: (data) => {
6667
- if (socket.readyState !== WebSocket.OPEN) return;
6668
- try {
6669
- socket.send(data);
6670
- } catch {
6671
- fire();
6672
- }
6673
- },
6674
- close: () => {
6675
- try {
6676
- socket.close();
6677
- } catch {
6678
- }
6679
- fire();
6680
- },
6681
- onMessage: (handler) => {
6682
- socket.on("message", (raw, isBinary) => {
6683
- if (isBinary) return;
6684
- handler(raw.toString());
6685
- });
6686
- },
6687
- onClose: (handler) => {
6688
- if (closed) {
6689
- handler();
6690
- return;
6691
- }
6692
- closeHandlers.push(handler);
7146
+ if (message["type"] === "acked") {
7147
+ const seq = Number(message["seq"] ?? -1);
7148
+ if (!Number.isInteger(seq)) return;
7149
+ this.ackedSeq = Math.max(this.ackedSeq, seq);
7150
+ this.flush();
6693
7151
  }
6694
- };
6695
- }
6696
- function dialWebSocket(opts) {
6697
- return () => new Promise((resolve, reject) => {
6698
- const socket = new WebSocket(opts.url, {
6699
- handshakeTimeout: opts.handshakeTimeoutMs ?? 1e4,
6700
- ...opts.headers ? { headers: opts.headers } : {}
6701
- });
6702
- const failed = (err) => {
6703
- try {
6704
- socket.terminate();
6705
- } catch {
6706
- }
6707
- reject(err instanceof Error ? err : new Error(String(err)));
6708
- };
6709
- socket.once("open", () => {
6710
- socket.off("error", failed);
6711
- resolve(adapt(socket));
6712
- });
6713
- socket.once("error", failed);
6714
- });
6715
- }
7152
+ }
7153
+ };
7154
+
7155
+ // ../provider-local/dist/index.js
7156
+ import fs10 from "fs";
7157
+ import os5 from "os";
7158
+ import path10 from "path";
7159
+ import fs24 from "fs";
7160
+ import path24 from "path";
7161
+ var NO_CHECKOUT = {
7162
+ ok: false,
7163
+ reason: "failed",
7164
+ detail: "this machine has no checkout of that project \u2014 clone it and start the agent with that folder"
7165
+ };
6716
7166
  var scratchRoots = /* @__PURE__ */ new Map();
6717
7167
  function scratchRoot(projectId) {
6718
7168
  let root = scratchRoots.get(projectId);
6719
7169
  if (!root) {
6720
- root = fs23.mkdtempSync(path24.join(os2.tmpdir(), `flowriki-record-${projectId.replace(/[^\w-]/g, "")}-`));
7170
+ root = fs10.mkdtempSync(path10.join(os5.tmpdir(), `flowriki-record-${projectId.replace(/[^\w-]/g, "")}-`));
6721
7171
  scratchRoots.set(projectId, root);
6722
7172
  }
6723
7173
  return root;
6724
7174
  }
6725
- var NO_CHECKOUT = {
6726
- ok: false,
6727
- reason: "failed",
6728
- detail: "this machine has no checkout of that project \u2014 clone it and start the agent with that folder"
6729
- };
6730
- function buildHandlers(deps) {
6731
- const { storeFor, storage = null, onActivity, lease, recorder } = deps;
6732
- const debugging = deps.debugger;
6733
- const artifactLimit = deps.artifactLimitBytes ?? 25 * 1024 * 1024;
6734
- const cancels = /* @__PURE__ */ new Map();
7175
+ function repositoryHandlers({ storeFor }) {
6735
7176
  return {
6736
7177
  "project.list": async (command, project) => {
6737
7178
  if (!project.root) return NO_CHECKOUT;
@@ -6775,24 +7216,6 @@ function buildHandlers(deps) {
6775
7216
  }
6776
7217
  };
6777
7218
  },
6778
- "runs.list": async (command) => {
6779
- if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
6780
- const page = await storage.listRuns({
6781
- limit: Math.min(100, Number(command.args["limit"] ?? 20)),
6782
- ...command.args["flowId"] ? { flowId: String(command.args["flowId"]) } : {}
6783
- });
6784
- return { ok: true, result: { runs: page.runs, nextCursor: page.nextCursor ?? null } };
6785
- },
6786
- "run.get": async (command) => {
6787
- if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
6788
- const run = await storage.getRun(String(command.args["runId"] ?? ""));
6789
- if (!run) return { ok: false, reason: "failed", detail: "run not found" };
6790
- return { ok: true, result: { run } };
6791
- },
6792
- "runs.stats": async (command) => {
6793
- if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
6794
- return { ok: true, result: { ...await storage.runStats(Number(command.args["days"] ?? 7)) } };
6795
- },
6796
7219
  "flow.save": async (command, project) => {
6797
7220
  if (!project.root) return NO_CHECKOUT;
6798
7221
  if (command.flowRevision === void 0) {
@@ -6861,26 +7284,17 @@ function buildHandlers(deps) {
6861
7284
  // nowhere on the way: the command ledger stores a SHA-256 of the command
6862
7285
  // rather than the command, the service persists no command at all, and
6863
7286
  // neither the result below nor any event carries the value back.
6864
- "secret.set": async (command, project) => {
7287
+ "flow.delete": async (command, project) => {
6865
7288
  if (!project.root) return NO_CHECKOUT;
6866
7289
  const store = storeFor(project.root);
6867
- const ref = String(command.args["ref"] ?? "");
6868
- const value = command.args["value"];
6869
- if (typeof value !== "string") {
6870
- return { ok: false, reason: "failed", detail: "a secret needs a value" };
6871
- }
6872
- try {
6873
- store.writeSecret(ref, value);
6874
- } catch (err) {
6875
- return { ok: false, reason: "failed", detail: String(err.message) };
6876
- }
6877
- return { ok: true, result: { ref, set: true } };
7290
+ const flowId = String(command.args["flowId"] ?? "");
7291
+ if (!store.getFlow(flowId)) return { ok: true, result: { deleted: false } };
7292
+ store.deleteFlow(flowId);
7293
+ return { ok: true, result: { deleted: true } };
6878
7294
  },
6879
- // A new feature: one directory and one feature.json, in the tester's own
6880
- // repository. The hosted dashboard had no way to make one at all, so a
6881
- // hosted project could only ever record into features somebody had created
6882
- // from the CLI or the local app — which, for anybody who had only ever
6883
- // used the hosted service, was none.
7295
+ // Checking unsaved steps against a real page. The steps travel in the
7296
+ // command because the whole point is to check an edit nobody has saved —
7297
+ // saving is gated on this passing.
6884
7298
  "feature.create": async (command, project) => {
6885
7299
  if (!project.root) return NO_CHECKOUT;
6886
7300
  const store = storeFor(project.root);
@@ -6894,95 +7308,26 @@ function buildHandlers(deps) {
6894
7308
  return { ok: false, reason: /already exists/.test(detail) ? "name_taken" : "failed", detail };
6895
7309
  }
6896
7310
  },
6897
- "flow.delete": async (command, project) => {
7311
+ "secret.set": async (command, project) => {
6898
7312
  if (!project.root) return NO_CHECKOUT;
6899
7313
  const store = storeFor(project.root);
6900
- const flowId = String(command.args["flowId"] ?? "");
6901
- if (!store.getFlow(flowId)) return { ok: true, result: { deleted: false } };
6902
- store.deleteFlow(flowId);
6903
- return { ok: true, result: { deleted: true } };
6904
- },
6905
- // Checking unsaved steps against a real page. The steps travel in the
6906
- // command because the whole point is to check an edit nobody has saved —
6907
- // saving is gated on this passing.
6908
- "flow.verify": async (command, project, emit) => {
6909
- if (!project.root) return NO_CHECKOUT;
6910
- if (!deps.verify) {
6911
- return { ok: false, reason: "failed", detail: "this agent cannot verify steps \u2014 no browser is configured" };
6912
- }
6913
- const flowId = String(command.args["flowId"] ?? "");
6914
- try {
6915
- const outcome = await deps.verify({
6916
- root: project.root,
6917
- flowId,
6918
- ...command.args["steps"] ? { steps: command.args["steps"] } : {},
6919
- verifyIds: command.args["verifyIds"] ?? [],
6920
- ...command.args["watch"] === void 0 ? {} : { watch: Boolean(command.args["watch"]) },
6921
- // Streamed, so a hosted editor fills in as the pass proceeds instead
6922
- // of sitting blank for the twenty-odd seconds a replay takes.
6923
- onStepResult: (result) => emit("verify.step", { flowId, result })
6924
- });
6925
- emit("verify.finished", { flowId, outcome });
6926
- return { ok: true, result: outcome };
6927
- } catch (err) {
6928
- const name = err.name;
6929
- if (name === "BrowserBusy") return { ok: false, reason: "busy", detail: String(err.message) };
6930
- if (name === "FlowNotFound") return { ok: false, reason: "failed", detail: "flow not found" };
6931
- return { ok: false, reason: "failed", detail: String(err.message) };
6932
- }
6933
- },
6934
- // ── stepping through a flow ─────────────────────────────────────────
6935
- // The browser opens on this machine and the panel lives inside that window,
6936
- // so whoever is at the keyboard sees the same state the dashboard does.
6937
- "debug.start": async (command, project) => {
6938
- if (!project.root) return NO_CHECKOUT;
6939
- if (!debugging) {
6940
- return { ok: false, reason: "failed", detail: "this agent cannot debug \u2014 no browser is configured" };
6941
- }
6942
- try {
6943
- const started = await debugging.start(String(command.args["flowId"] ?? ""), {
6944
- ...command.args["breakpoints"] ? { breakpoints: command.args["breakpoints"] } : {},
6945
- ...command.args["watch"] === void 0 ? {} : { watch: Boolean(command.args["watch"]) }
6946
- });
6947
- onActivity?.({ kind: "debugging", projectId: project.id, detail: started.flowId });
6948
- return { ok: true, result: started };
6949
- } catch (err) {
6950
- const name = err.name;
6951
- if (name === "BrowserBusy") return { ok: false, reason: "busy", detail: String(err.message) };
6952
- if (name === "FlowNotFound") return { ok: false, reason: "failed", detail: "flow not found" };
6953
- return { ok: false, reason: "failed", detail: String(err.message) };
7314
+ const ref = String(command.args["ref"] ?? "");
7315
+ const value = command.args["value"];
7316
+ if (typeof value !== "string") {
7317
+ return { ok: false, reason: "failed", detail: "a secret needs a value" };
6954
7318
  }
6955
- },
6956
- // A read, so it stays out of EFFECTFUL: it is how a dashboard opened
6957
- // mid-session catches up rather than showing a stale half-run.
6958
- "debug.status": async () => ({
6959
- ok: true,
6960
- result: debugging?.status() ?? { flowId: null, state: null }
6961
- }),
6962
- "debug.command": async (command) => {
6963
- if (!debugging) return { ok: false, reason: "failed", detail: "no debug session is running" };
6964
7319
  try {
6965
- const state = await debugging.command(String(command.args["command"] ?? ""), {
6966
- ...command.args["stepId"] === void 0 ? {} : { stepId: command.args["stepId"] },
6967
- ...command.args["patch"] === void 0 ? {} : { patch: command.args["patch"] },
6968
- ...command.args["toIndex"] === void 0 ? {} : { toIndex: command.args["toIndex"] },
6969
- ...command.args["on"] === void 0 ? {} : { on: command.args["on"] }
6970
- });
6971
- return { ok: true, result: { state } };
7320
+ store.writeSecret(ref, value);
6972
7321
  } catch (err) {
6973
7322
  return { ok: false, reason: "failed", detail: String(err.message) };
6974
7323
  }
7324
+ return { ok: true, result: { ref, set: true } };
6975
7325
  },
6976
- "debug.end": async (command) => {
6977
- if (!debugging) return { ok: false, reason: "failed", detail: "no debug session is running" };
6978
- try {
6979
- const res = await debugging.end(command.args["commit"] !== false);
6980
- if (!res.ok) return { ok: false, reason: "failed", detail: res.error ?? "no debug session is running" };
6981
- return { ok: true, result: res };
6982
- } finally {
6983
- onActivity?.(null);
6984
- }
6985
- },
7326
+ // A new feature: one directory and one feature.json, in the tester's own
7327
+ // repository. The hosted dashboard had no way to make one at all, so a
7328
+ // hosted project could only ever record into features somebody had created
7329
+ // from the CLI or the local app — which, for anybody who had only ever
7330
+ // used the hosted service, was none.
6986
7331
  "config.save": async (command, project) => {
6987
7332
  if (!project.root) return NO_CHECKOUT;
6988
7333
  const store = storeFor(project.root);
@@ -6992,10 +7337,15 @@ function buildHandlers(deps) {
6992
7337
  } catch (err) {
6993
7338
  return { ok: false, reason: "failed", detail: String(err.message) };
6994
7339
  }
6995
- },
7340
+ }
6996
7341
  // What this folder actually is. A read: nothing is written, nothing is
6997
7342
  // committed, and the answer is what lets the service tell "not set up yet"
6998
7343
  // apart from "set up, but you are in the wrong clone".
7344
+ };
7345
+ }
7346
+ function bindingHandlers(ctx) {
7347
+ const { deps } = ctx;
7348
+ return {
6999
7349
  "project.status": async (_command, project) => {
7000
7350
  if (!project.root) return NO_CHECKOUT;
7001
7351
  if (!deps.binding) {
@@ -7019,7 +7369,62 @@ function buildHandlers(deps) {
7019
7369
  } catch (err) {
7020
7370
  return { ok: false, reason: "failed", detail: String(err.message) };
7021
7371
  }
7022
- },
7372
+ }
7373
+ };
7374
+ }
7375
+ function historyHandlers({ storage, artifactLimit }) {
7376
+ return {
7377
+ "runs.list": async (command) => {
7378
+ if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
7379
+ const page = await storage.listRuns({
7380
+ limit: Math.min(100, Number(command.args["limit"] ?? 20)),
7381
+ ...command.args["flowId"] ? { flowId: String(command.args["flowId"]) } : {}
7382
+ });
7383
+ return { ok: true, result: { runs: page.runs, nextCursor: page.nextCursor ?? null } };
7384
+ },
7385
+ "run.get": async (command) => {
7386
+ if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
7387
+ const run = await storage.getRun(String(command.args["runId"] ?? ""));
7388
+ if (!run) return { ok: false, reason: "failed", detail: "run not found" };
7389
+ return { ok: true, result: { run } };
7390
+ },
7391
+ "runs.stats": async (command) => {
7392
+ if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
7393
+ return { ok: true, result: { ...await storage.runStats(Number(command.args["days"] ?? 7)) } };
7394
+ },
7395
+ "artifact.fetch": async (command, project) => {
7396
+ if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
7397
+ const runId = String(command.args["runId"] ?? "");
7398
+ const artifactId = String(command.args["artifactId"] ?? "");
7399
+ const run = await storage.getRun(runId);
7400
+ if (!run) return { ok: false, reason: "failed", detail: `no run ${runId} on this machine` };
7401
+ const artifact = run.artifacts.find((a) => a.id === artifactId);
7402
+ if (!artifact) return { ok: false, reason: "failed", detail: "no such artifact on that run" };
7403
+ const dir = run.artifactsDir ?? runArtifactsDir(runId, run.projectId);
7404
+ const file = path24.join(dir, artifact.relPath);
7405
+ if (!isInside(dir, file) || !fs24.existsSync(file) || !isInside(fs24.realpathSync(dir), fs24.realpathSync(file))) {
7406
+ return { ok: false, reason: "failed", detail: "the artifact file is not on this machine" };
7407
+ }
7408
+ const { size } = fs24.statSync(file);
7409
+ if (size > artifactLimit) {
7410
+ return { ok: false, reason: "failed", detail: `this artifact is ${size} bytes, over the ${artifactLimit}-byte transfer limit` };
7411
+ }
7412
+ return {
7413
+ ok: true,
7414
+ result: {
7415
+ projectId: project.id,
7416
+ kind: artifact.kind,
7417
+ masked: artifact.masked,
7418
+ bytes: size,
7419
+ base64: fs24.readFileSync(file).toString("base64")
7420
+ }
7421
+ };
7422
+ }
7423
+ };
7424
+ }
7425
+ function executionHandlers(ctx) {
7426
+ const { storeFor, storage, onActivity, lease, cancels } = ctx;
7427
+ return {
7023
7428
  "flow.run": async (command, project, emit) => {
7024
7429
  if (command.flowRevision === void 0) {
7025
7430
  return { ok: false, reason: "stale_revision", detail: "reload the flow and supply its revision before running" };
@@ -7105,15 +7510,17 @@ function buildHandlers(deps) {
7105
7510
  if (!cancelled || cancelled.projectId !== project.id) return { ok: false, reason: "failed", detail: `no run ${runId} is in progress on this machine` };
7106
7511
  cancelled.cancel();
7107
7512
  return { ok: true, result: { runId, projectId: project.id, stopping: true } };
7108
- },
7513
+ }
7514
+ };
7515
+ }
7516
+ function recordingHandlers({ recorder, onActivity }) {
7517
+ return {
7109
7518
  "record.start": async (command, project) => {
7110
7519
  if (!recorder) return { ok: false, reason: "failed", detail: "this agent cannot record \u2014 no browser is configured" };
7111
7520
  if (recorder.active()) return { ok: false, reason: "busy", detail: "a recording is already in progress on this machine" };
7112
7521
  const featureId = String(command.args["featureId"] ?? "");
7113
7522
  const name = String(command.args["name"] ?? "").trim();
7114
7523
  if (!featureId || !name) return { ok: false, reason: "failed", detail: "featureId and name are both required" };
7115
- const claim = lease?.acquire("run");
7116
- if (claim && !claim.ok) return { ok: false, reason: "busy", detail: `the browser on this machine is in use (${claim.heldBy})` };
7117
7524
  const optional = (key) => command.args[key] === void 0 ? void 0 : String(command.args[key]);
7118
7525
  try {
7119
7526
  const url = optional("url");
@@ -7142,11 +7549,9 @@ function buildHandlers(deps) {
7142
7549
  ...command.args["environment"] ? { environment: command.args["environment"] } : {},
7143
7550
  ...headless === void 0 ? {} : { headless }
7144
7551
  });
7145
- lease?.activate("run");
7146
7552
  onActivity?.({ kind: "recording", projectId: project.id, detail: name });
7147
7553
  return { ok: true, result: { url: started.url, projectId: project.id } };
7148
7554
  } catch (err) {
7149
- lease?.release("run");
7150
7555
  const detail = String(err.message);
7151
7556
  const busy = err.name === "BrowserBusy";
7152
7557
  return { ok: false, reason: busy ? "busy" : "failed", detail };
@@ -7160,7 +7565,6 @@ function buildHandlers(deps) {
7160
7565
  return { ok: true, result: { flow: saved.flow, projectId: project.id } };
7161
7566
  } finally {
7162
7567
  onActivity?.(null);
7163
- lease?.release("run");
7164
7568
  }
7165
7569
  },
7166
7570
  "record.discard": async () => {
@@ -7171,7 +7575,6 @@ function buildHandlers(deps) {
7171
7575
  return { ok: true, result: { discarded: true } };
7172
7576
  } finally {
7173
7577
  onActivity?.(null);
7174
- lease?.release("run");
7175
7578
  }
7176
7579
  },
7177
7580
  // Reading, so it is not in EFFECTFUL and is safe to redeliver. It is how a
@@ -7197,38 +7600,133 @@ function buildHandlers(deps) {
7197
7600
  } catch (err) {
7198
7601
  return { ok: false, reason: "failed", detail: String(err.message) };
7199
7602
  }
7603
+ }
7604
+ };
7605
+ }
7606
+ function debuggingHandlers(ctx) {
7607
+ const { debugging, onActivity, deps } = ctx;
7608
+ return {
7609
+ "flow.verify": async (command, project, emit) => {
7610
+ const carried = command.args["flow"];
7611
+ if (!carried && !project.root) return NO_CHECKOUT;
7612
+ if (!deps.verify) {
7613
+ return { ok: false, reason: "failed", detail: "this agent cannot verify steps \u2014 no browser is configured" };
7614
+ }
7615
+ const flowId = String(command.args["flowId"] ?? "");
7616
+ try {
7617
+ const outcome = await deps.verify({
7618
+ root: project.root,
7619
+ ...carried ? { delivered: {
7620
+ flow: carried,
7621
+ secrets: command.args["secrets"] ?? {},
7622
+ config: command.args["config"] ?? {}
7623
+ } } : {},
7624
+ flowId,
7625
+ ...command.args["steps"] ? { steps: command.args["steps"] } : {},
7626
+ verifyIds: command.args["verifyIds"] ?? [],
7627
+ ...command.args["watch"] === void 0 ? {} : { watch: Boolean(command.args["watch"]) },
7628
+ // Streamed, so a hosted editor fills in as the pass proceeds instead
7629
+ // of sitting blank for the twenty-odd seconds a replay takes.
7630
+ onStepResult: (result) => emit("verify.step", { flowId, result })
7631
+ });
7632
+ emit("verify.finished", { flowId, outcome });
7633
+ return { ok: true, result: outcome };
7634
+ } catch (err) {
7635
+ const name = err.name;
7636
+ if (name === "BrowserBusy") return { ok: false, reason: "busy", detail: String(err.message) };
7637
+ if (name === "FlowNotFound") return { ok: false, reason: "failed", detail: "flow not found" };
7638
+ return { ok: false, reason: "failed", detail: String(err.message) };
7639
+ }
7200
7640
  },
7201
- "artifact.fetch": async (command, project) => {
7202
- if (!storage) return { ok: false, reason: "failed", detail: "run history is not available on this machine" };
7203
- const runId = String(command.args["runId"] ?? "");
7204
- const artifactId = String(command.args["artifactId"] ?? "");
7205
- const run = await storage.getRun(runId);
7206
- if (!run) return { ok: false, reason: "failed", detail: `no run ${runId} on this machine` };
7207
- const artifact = run.artifacts.find((a) => a.id === artifactId);
7208
- if (!artifact) return { ok: false, reason: "failed", detail: "no such artifact on that run" };
7209
- const dir = run.artifactsDir ?? runArtifactsDir(runId, run.projectId);
7210
- const file = path24.join(dir, artifact.relPath);
7211
- if (!isInside(dir, file) || !fs23.existsSync(file) || !isInside(fs23.realpathSync(dir), fs23.realpathSync(file))) {
7212
- return { ok: false, reason: "failed", detail: "the artifact file is not on this machine" };
7641
+ // ── stepping through a flow ─────────────────────────────────────────
7642
+ // The browser opens on this machine and the panel lives inside that window,
7643
+ // so whoever is at the keyboard sees the same state the dashboard does.
7644
+ "debug.start": async (command, project) => {
7645
+ const carried = command.args["flow"];
7646
+ if (!carried && !project.root) return NO_CHECKOUT;
7647
+ if (!debugging) {
7648
+ return { ok: false, reason: "failed", detail: "this agent cannot debug \u2014 no browser is configured" };
7213
7649
  }
7214
- const { size } = fs23.statSync(file);
7215
- if (size > artifactLimit) {
7216
- return { ok: false, reason: "failed", detail: `this artifact is ${size} bytes, over the ${artifactLimit}-byte transfer limit` };
7650
+ try {
7651
+ const started = await debugging.start(String(command.args["flowId"] ?? ""), {
7652
+ root: project.root,
7653
+ ...command.args["breakpoints"] ? { breakpoints: command.args["breakpoints"] } : {},
7654
+ ...command.args["watch"] === void 0 ? {} : { watch: Boolean(command.args["watch"]) },
7655
+ ...carried ? {
7656
+ delivered: {
7657
+ flow: carried,
7658
+ secrets: command.args["secrets"] ?? {},
7659
+ config: command.args["config"] ?? {},
7660
+ scratchRoot: scratchRoot(project.id)
7661
+ }
7662
+ } : {}
7663
+ });
7664
+ onActivity?.({ kind: "debugging", projectId: project.id, detail: started.flowId });
7665
+ return { ok: true, result: started };
7666
+ } catch (err) {
7667
+ const name = err.name;
7668
+ if (name === "BrowserBusy") return { ok: false, reason: "busy", detail: String(err.message) };
7669
+ if (name === "FlowNotFound") return { ok: false, reason: "failed", detail: "flow not found" };
7670
+ return { ok: false, reason: "failed", detail: String(err.message) };
7671
+ }
7672
+ },
7673
+ // A read, so it stays out of EFFECTFUL: it is how a dashboard opened
7674
+ // mid-session catches up rather than showing a stale half-run.
7675
+ "debug.status": async () => ({
7676
+ ok: true,
7677
+ result: debugging?.status() ?? { flowId: null, state: null }
7678
+ }),
7679
+ "debug.command": async (command) => {
7680
+ if (!debugging) return { ok: false, reason: "failed", detail: "no debug session is running" };
7681
+ try {
7682
+ const state = await debugging.command(String(command.args["command"] ?? ""), {
7683
+ ...command.args["stepId"] === void 0 ? {} : { stepId: command.args["stepId"] },
7684
+ ...command.args["patch"] === void 0 ? {} : { patch: command.args["patch"] },
7685
+ ...command.args["toIndex"] === void 0 ? {} : { toIndex: command.args["toIndex"] },
7686
+ ...command.args["on"] === void 0 ? {} : { on: command.args["on"] }
7687
+ });
7688
+ return { ok: true, result: { state } };
7689
+ } catch (err) {
7690
+ return { ok: false, reason: "failed", detail: String(err.message) };
7691
+ }
7692
+ },
7693
+ "debug.end": async (command) => {
7694
+ if (!debugging) return { ok: false, reason: "failed", detail: "no debug session is running" };
7695
+ try {
7696
+ const res = await debugging.end(command.args["commit"] !== false);
7697
+ if (!res.ok) return { ok: false, reason: "failed", detail: res.error ?? "no debug session is running" };
7698
+ return { ok: true, result: res };
7699
+ } finally {
7700
+ onActivity?.(null);
7217
7701
  }
7218
- return {
7219
- ok: true,
7220
- result: {
7221
- projectId: project.id,
7222
- kind: artifact.kind,
7223
- masked: artifact.masked,
7224
- bytes: size,
7225
- base64: fs23.readFileSync(file).toString("base64")
7226
- }
7227
- };
7228
7702
  }
7229
7703
  };
7230
7704
  }
7705
+ function buildHandlers(deps) {
7706
+ const context = {
7707
+ deps,
7708
+ storeFor: deps.storeFor,
7709
+ storage: deps.storage ?? null,
7710
+ onActivity: deps.onActivity,
7711
+ lease: deps.lease,
7712
+ recorder: deps.recorder,
7713
+ debugging: deps.debugger,
7714
+ // A video is easily hundreds of megabytes, and nobody wants one pushed down
7715
+ // a control channel by accident.
7716
+ artifactLimit: deps.artifactLimitBytes ?? 25 * 1024 * 1024,
7717
+ cancels: /* @__PURE__ */ new Map()
7718
+ };
7719
+ return {
7720
+ ...repositoryHandlers(context),
7721
+ ...bindingHandlers(context),
7722
+ ...historyHandlers(context),
7723
+ ...executionHandlers(context),
7724
+ ...recordingHandlers(context),
7725
+ ...debuggingHandlers(context)
7726
+ };
7727
+ }
7231
7728
  function createAgentRuntime(options) {
7729
+ const lease = new BrowserLease();
7232
7730
  let journal = null;
7233
7731
  const coordinators = /* @__PURE__ */ new Map();
7234
7732
  let recordingIn = null;
@@ -7238,6 +7736,7 @@ function createAgentRuntime(options) {
7238
7736
  let existing = coordinators.get(projectRoot);
7239
7737
  if (!existing) {
7240
7738
  existing = new RecordingCoordinator({
7739
+ lease,
7241
7740
  store: new Store(projectRoot),
7242
7741
  emit: (event) => {
7243
7742
  if (event.type === "recording.stopped") lastRecording = { ok: true, flow: event.flow };
@@ -7253,8 +7752,11 @@ function createAgentRuntime(options) {
7253
7752
  const debuggerFor = (projectRoot) => {
7254
7753
  let existing = debuggers.get(projectRoot);
7255
7754
  if (!existing) {
7755
+ const store = new Store(projectRoot);
7256
7756
  existing = new DebugCoordinator({
7257
- store: new Store(projectRoot),
7757
+ lease,
7758
+ store,
7759
+ commit: { to: "disk", store },
7258
7760
  emit: (event) => {
7259
7761
  journal?.append({ name: `debug.${event.type.replace(/^debug\./, "")}`, data: event });
7260
7762
  }
@@ -7263,10 +7765,25 @@ function createAgentRuntime(options) {
7263
7765
  }
7264
7766
  return existing;
7265
7767
  };
7768
+ const deliveredDebugger = (delivered) => new DebugCoordinator({
7769
+ lease,
7770
+ store: deliveredRun({
7771
+ flow: delivered.flow,
7772
+ secrets: delivered.secrets,
7773
+ config: delivered.config
7774
+ }),
7775
+ // Handed back rather than written, and redacted on the way out through
7776
+ // a scratch store — somewhere for extraction to put a password typed
7777
+ // during the session, so it does not travel inside the document.
7778
+ commit: { to: "caller", scratch: new Store(delivered.scratchRoot) },
7779
+ emit: (event) => {
7780
+ journal?.append({ name: `debug.${event.type.replace(/^debug\./, "")}`, data: event });
7781
+ }
7782
+ });
7266
7783
  const debugControl = {
7267
7784
  start: async (flowId, opts) => {
7268
7785
  if (debuggingIn?.active()) throw new Error("a debug session is already running on this machine");
7269
- const coordinator = debuggerFor(options.projects[0].root);
7786
+ const coordinator = opts.delivered ? deliveredDebugger(opts.delivered) : debuggerFor(opts.root);
7270
7787
  const started = await coordinator.start(flowId, opts);
7271
7788
  debuggingIn = coordinator;
7272
7789
  return started;
@@ -7285,7 +7802,11 @@ function createAgentRuntime(options) {
7285
7802
  active: () => Boolean(debuggingIn?.active())
7286
7803
  };
7287
7804
  const verify = (opts) => verifyFlowSteps(
7288
- { store: new Store(opts.root), onStepResult: opts.onStepResult },
7805
+ {
7806
+ store: opts.delivered ? deliveredRun(opts.delivered) : new Store(opts.root),
7807
+ lease,
7808
+ onStepResult: opts.onStepResult
7809
+ },
7289
7810
  {
7290
7811
  flowId: opts.flowId,
7291
7812
  ...opts.steps ? { steps: opts.steps } : {},
@@ -7307,197 +7828,88 @@ function createAgentRuntime(options) {
7307
7828
  stop: async (projectId) => {
7308
7829
  if (projectId && recordingProject && projectId !== recordingProject) return { ok: false, error: "This recording belongs to another project" };
7309
7830
  if (lastRecording?.ok) return lastRecording;
7310
- if (!recordingIn) return lastRecording ?? { ok: false, error: "no recording is in progress on this machine" };
7311
- const result = await recordingIn.stop();
7312
- lastRecording = result;
7313
- return result;
7314
- },
7315
- discard: async () => {
7316
- if (!recordingIn) return { ok: false, error: "no recording is in progress on this machine" };
7317
- const result = await recordingIn.discard();
7318
- recordingIn = null;
7319
- return result;
7320
- },
7321
- status: () => recordingIn?.status() ?? { active: false, featureId: null, name: null, steps: [], dialog: null },
7322
- answerDialog: async (accept, promptText) => {
7323
- if (!recordingIn) throw new Error("No recording is active");
7324
- await recordingIn.answerDialog(accept, promptText);
7325
- },
7326
- active: () => recordingIn?.active() ?? null
7327
- };
7328
- function buildAgent() {
7329
- return new Agent({
7330
- stateFile: options.stateFile,
7331
- deviceId: options.deviceId,
7332
- agentVersion: options.agentVersion,
7333
- // EVERY checkout, each named by its own marker. One agent run serves
7334
- // all of them; the service routes each command to the folder that is
7335
- // the project it names.
7336
- projects: options.projects,
7337
- handlers: buildHandlers({
7338
- storeFor: (r) => new Store(r),
7339
- ...options.storage ? { storage: options.storage } : {},
7340
- recorder,
7341
- // Reading and writing the repository marker. All of it runs here, with
7342
- // this person's git credentials; the service only ever asks.
7343
- binding: {
7344
- read: (r) => readRepo(r),
7345
- claim: (r, marker) => writeMarker(r, marker),
7346
- ensureRoot: (r) => ensureRootCommit(r)
7347
- },
7348
- debugger: debugControl,
7349
- verify,
7350
- // Somebody at a keyboard should be able to see that a remote page just
7351
- // opened a browser on their machine, and what for.
7352
- // Somebody at a keyboard should be able to see that a remote page just
7353
- // opened a browser on their machine, and what for — but a library does
7354
- // not decide how that is shown. It is reported; the caller presents it.
7355
- onActivity: (activity) => {
7356
- options.onEvent?.(
7357
- activity ? { name: "activity", data: { kind: activity.kind, detail: activity.detail } } : { name: "activity", data: {} }
7358
- );
7359
- }
7360
- })
7361
- });
7362
- }
7363
- const agent = buildAgent();
7364
- journal = agent.journal;
7365
- return agent;
7366
- }
7367
-
7368
- // src/engine.ts
7369
- import fs10 from "fs";
7370
- import path7 from "path";
7371
-
7372
- // src/credentials.ts
7373
- import crypto3 from "crypto";
7374
- import fs8 from "fs";
7375
-
7376
- // src/paths.ts
7377
- import fs7 from "fs";
7378
- import os3 from "os";
7379
- import path6 from "path";
7380
- function dataDir(env = process.env) {
7381
- const override = env["FLOWRIKI_HOME"]?.trim();
7382
- if (override) return path6.resolve(override);
7383
- if (process.platform === "win32") {
7384
- const appData = env["APPDATA"]?.trim();
7385
- return appData ? path6.join(appData, "Flowriki") : path6.join(os3.homedir(), "AppData", "Roaming", "Flowriki");
7386
- }
7387
- if (process.platform === "darwin") {
7388
- return path6.join(os3.homedir(), "Library", "Application Support", "Flowriki");
7389
- }
7390
- const state = env["XDG_STATE_HOME"]?.trim();
7391
- return state ? path6.join(state, "flowriki") : path6.join(os3.homedir(), ".local", "state", "flowriki");
7392
- }
7393
- function ensureDataDir(env = process.env) {
7394
- const dir = dataDir(env);
7395
- fs7.mkdirSync(dir, { recursive: true, mode: 448 });
7396
- if (process.platform !== "win32") {
7397
- try {
7398
- fs7.chmodSync(dir, 448);
7399
- } catch {
7400
- }
7401
- }
7402
- return dir;
7403
- }
7404
- var credentialsPath = (env = process.env) => path6.join(dataDir(env), "credentials.json");
7405
- var lockPath = (env = process.env) => path6.join(dataDir(env), "agent.lock");
7406
- var ledgerPath = (deviceId, env = process.env) => path6.join(dataDir(env), `ledger-${deviceId}.sqlite`);
7407
-
7408
- // src/credentials.ts
7409
- function readFile(env = process.env) {
7410
- try {
7411
- const raw = JSON.parse(fs8.readFileSync(credentialsPath(env), "utf8"));
7412
- if (Array.isArray(raw.connections)) return { version: 3, connections: raw.connections };
7413
- const older = Array.isArray(raw.devices) ? raw.devices : raw.deviceId ? [raw] : [];
7414
- const connections = older.filter((d) => d.deviceId && d.secret).map((d) => ({
7415
- service: (d.url ?? "").replace(/^ws/, "http").replace(/\/agent$/, ""),
7416
- deviceId: d.deviceId,
7417
- secret: d.secret,
7418
- machineName: "this machine",
7419
- connectedAt: d.pairedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
7420
- ...d.checkouts ? { checkouts: d.checkouts } : {}
7421
- })).filter((c) => c.service);
7422
- return { version: 3, connections };
7423
- } catch {
7424
- return { version: 3, connections: [] };
7425
- }
7426
- }
7427
- function connectionFor(service, env = process.env) {
7428
- return readFile(env).connections.find((c) => c.service === service) ?? null;
7429
- }
7430
- function allConnections(env = process.env) {
7431
- return readFile(env).connections;
7432
- }
7433
- function saveConnection(connection, env = process.env) {
7434
- ensureDataDir(env);
7435
- const file = credentialsPath(env);
7436
- const next = {
7437
- version: 3,
7438
- connections: [...readFile(env).connections.filter((c) => c.service !== connection.service), connection]
7831
+ if (!recordingIn) return lastRecording ?? { ok: false, error: "no recording is in progress on this machine" };
7832
+ const result = await recordingIn.stop();
7833
+ lastRecording = result;
7834
+ return result;
7835
+ },
7836
+ discard: async () => {
7837
+ if (!recordingIn) return { ok: false, error: "no recording is in progress on this machine" };
7838
+ const result = await recordingIn.discard();
7839
+ recordingIn = null;
7840
+ return result;
7841
+ },
7842
+ status: () => recordingIn?.status() ?? { active: false, featureId: null, name: null, steps: [], dialog: null },
7843
+ answerDialog: async (accept, promptText) => {
7844
+ if (!recordingIn) throw new Error("No recording is active");
7845
+ await recordingIn.answerDialog(accept, promptText);
7846
+ },
7847
+ active: () => recordingIn?.active() ?? null
7439
7848
  };
7440
- const temporary = `${file}.${process.pid}.tmp`;
7441
- fs8.writeFileSync(temporary, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
7442
- fs8.renameSync(temporary, file);
7443
- }
7444
- function forgetConnection(service, env = process.env) {
7445
- const before = readFile(env).connections;
7446
- const after = before.filter((c) => c.service !== service);
7447
- if (after.length === before.length) return false;
7448
- ensureDataDir(env);
7449
- const file = credentialsPath(env);
7450
- const temporary = `${file}.${process.pid}.tmp`;
7451
- fs8.writeFileSync(temporary, JSON.stringify({ version: 3, connections: after }, null, 2) + "\n", { mode: 384 });
7452
- fs8.renameSync(temporary, file);
7453
- return true;
7454
- }
7455
- var fingerprint = (secret) => crypto3.createHash("sha256").update(secret).digest("hex").slice(0, 12);
7456
-
7457
- // src/service.ts
7458
- var BadServiceUrl = class extends Error {
7459
- };
7460
- function resolveService(explicit, env = process.env) {
7461
- const raw = explicit?.trim() || env["FLOWRIKI_SERVICE_URL"]?.trim() || PRODUCTION_SERVICE;
7462
- let url;
7463
- try {
7464
- url = new URL(raw);
7465
- } catch {
7466
- throw new BadServiceUrl(`"${raw}" is not a URL \u2014 expected something like https://flowriki.example`);
7467
- }
7468
- if (url.protocol !== "https:" && url.protocol !== "http:") {
7469
- throw new BadServiceUrl(`"${raw}" is not http or https`);
7470
- }
7471
- const loopback = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname);
7472
- if (url.protocol === "http:" && !loopback) {
7473
- throw new BadServiceUrl(`refusing to use plain http for ${url.hostname} \u2014 a device credential would cross the network in the clear`);
7849
+ function buildAgent() {
7850
+ return new Agent({
7851
+ stateFile: options.stateFile,
7852
+ deviceId: options.deviceId,
7853
+ agentVersion: options.agentVersion,
7854
+ // EVERY checkout, each named by its own marker. One agent run serves
7855
+ // all of them; the service routes each command to the folder that is
7856
+ // the project it names.
7857
+ projects: options.projects,
7858
+ handlers: buildHandlers({
7859
+ lease,
7860
+ storeFor: (r) => new Store(r),
7861
+ ...options.storage ? { storage: options.storage } : {},
7862
+ recorder,
7863
+ // Reading and writing the repository marker. All of it runs here, with
7864
+ // this person's git credentials; the service only ever asks.
7865
+ binding: {
7866
+ read: (r) => readRepo(r),
7867
+ claim: (r, marker) => writeMarker(r, marker),
7868
+ ensureRoot: (r) => ensureRootCommit(r)
7869
+ },
7870
+ debugger: debugControl,
7871
+ verify,
7872
+ // Somebody at a keyboard should be able to see that a remote page just
7873
+ // opened a browser on their machine, and what for.
7874
+ // Somebody at a keyboard should be able to see that a remote page just
7875
+ // opened a browser on their machine, and what for — but a library does
7876
+ // not decide how that is shown. It is reported; the caller presents it.
7877
+ onActivity: (activity) => {
7878
+ options.onEvent?.(
7879
+ activity ? { name: "activity", data: { kind: activity.kind, detail: activity.detail } } : { name: "activity", data: {} }
7880
+ );
7881
+ }
7882
+ })
7883
+ });
7474
7884
  }
7475
- return url.origin + (url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""));
7885
+ const agent = buildAgent();
7886
+ journal = agent.journal;
7887
+ return agent;
7476
7888
  }
7477
7889
 
7478
7890
  // src/lock.ts
7479
- import fs9 from "fs";
7480
- import os4 from "os";
7891
+ import fs11 from "fs";
7892
+ import os6 from "os";
7481
7893
  function takeLock(env = process.env) {
7482
7894
  ensureDataDir(env);
7483
7895
  const file = lockPath(env);
7484
- const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os4.hostname() };
7896
+ const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os6.hostname() };
7485
7897
  for (let attempt = 0; attempt < 2; attempt += 1) {
7486
7898
  try {
7487
- fs9.writeFileSync(file, JSON.stringify(mine), { flag: "wx", mode: 384 });
7899
+ fs11.writeFileSync(file, JSON.stringify(mine), { flag: "wx", mode: 384 });
7488
7900
  return { ok: true, lock: { release: () => releaseLock(file, mine.pid) } };
7489
7901
  } catch {
7490
7902
  const held = readLock(file);
7491
7903
  if (!held) {
7492
7904
  try {
7493
- fs9.rmSync(file, { force: true });
7905
+ fs11.rmSync(file, { force: true });
7494
7906
  } catch {
7495
7907
  }
7496
7908
  continue;
7497
7909
  }
7498
7910
  if (held.host === mine.host && !alive(held.pid)) {
7499
7911
  try {
7500
- fs9.rmSync(file, { force: true });
7912
+ fs11.rmSync(file, { force: true });
7501
7913
  } catch {
7502
7914
  }
7503
7915
  continue;
@@ -7509,7 +7921,7 @@ function takeLock(env = process.env) {
7509
7921
  }
7510
7922
  function readLock(file) {
7511
7923
  try {
7512
- const held = JSON.parse(fs9.readFileSync(file, "utf8"));
7924
+ const held = JSON.parse(fs11.readFileSync(file, "utf8"));
7513
7925
  return typeof held.pid === "number" ? { pid: held.pid, since: held.since ?? "", host: held.host ?? "" } : null;
7514
7926
  } catch {
7515
7927
  return null;
@@ -7528,144 +7940,13 @@ function releaseLock(file, pid) {
7528
7940
  const held = readLock(file);
7529
7941
  if (held?.pid === pid) {
7530
7942
  try {
7531
- fs9.rmSync(file, { force: true });
7943
+ fs11.rmSync(file, { force: true });
7532
7944
  } catch {
7533
7945
  }
7534
7946
  }
7535
7947
  }
7536
7948
 
7537
- // src/open-browser.ts
7538
- import { spawn } from "child_process";
7539
- function canOpenBrowser(env = process.env) {
7540
- if (process.platform === "win32" || process.platform === "darwin") return true;
7541
- return Boolean(env["DISPLAY"] || env["WAYLAND_DISPLAY"]);
7542
- }
7543
- function openBrowser(url, env = process.env) {
7544
- if (!canOpenBrowser(env)) return "no-display";
7545
- try {
7546
- if (process.platform === "win32") {
7547
- spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true, windowsHide: true }).unref();
7548
- } else if (process.platform === "darwin") {
7549
- spawn("open", [url], { stdio: "ignore", detached: true }).unref();
7550
- } else {
7551
- spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
7552
- }
7553
- return "opened";
7554
- } catch {
7555
- return "failed";
7556
- }
7557
- }
7558
-
7559
- // src/engine.ts
7560
- var AGENT_VERSION = "0.1.0-beta.7";
7561
- async function connect(options = {}) {
7562
- let service;
7563
- try {
7564
- service = resolveService(options.service);
7565
- } catch (error) {
7566
- return { ok: false, reason: "refused", message: error.message };
7567
- }
7568
- const existing = connectionFor(service);
7569
- if (existing) {
7570
- const still = await verifyConnection(existing).catch(() => false);
7571
- if (still) return { ok: true, connection: existing, reused: true };
7572
- options.onProgress?.({
7573
- kind: "stale",
7574
- message: "the stored connection is no longer accepted \u2014 asking for a new one"
7575
- });
7576
- }
7577
- const machineName = options.machineName?.trim() || `${os5.userInfo().username}@${os5.hostname()}`;
7578
- let asked;
7579
- try {
7580
- const res = await fetch(`${service}/api/pairing-requests`, {
7581
- method: "POST",
7582
- headers: { "content-type": "application/json" },
7583
- body: JSON.stringify({ machineName, agentVersion: AGENT_VERSION }),
7584
- signal: options.signal ?? null
7585
- });
7586
- if (!res.ok) {
7587
- return { ok: false, reason: "refused", message: `${service} refused the request (${res.status})` };
7588
- }
7589
- asked = await res.json();
7590
- } catch (error) {
7591
- return { ok: false, reason: "unreachable", message: `could not reach ${service} \u2014 ${error.message}` };
7592
- }
7593
- const approve = `${service}${asked.approveUrl}`;
7594
- options.onProgress?.({
7595
- kind: "approve",
7596
- message: "waiting for approval in your browser",
7597
- detail: { url: approve, machineName, canOpen: canOpenBrowser() }
7598
- });
7599
- openBrowser(approve);
7600
- const pollMs = options.pollMs ?? 1500;
7601
- const deadline = Date.now() + (options.timeoutMs ?? 10 * 60 * 1e3);
7602
- while (Date.now() < deadline) {
7603
- if (options.signal?.aborted) return { ok: false, reason: "aborted", message: "cancelled" };
7604
- const res = await fetch(`${service}/api/pairing-requests/${encodeURIComponent(asked.requestId)}/claim`, {
7605
- method: "POST",
7606
- headers: { "content-type": "application/json" },
7607
- body: JSON.stringify({ claim: asked.claim })
7608
- }).catch(() => null);
7609
- if (res?.status === 200) {
7610
- const granted = await res.json();
7611
- const connection = {
7612
- service,
7613
- deviceId: granted.deviceId,
7614
- secret: granted.secret,
7615
- machineName,
7616
- connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
7617
- ...existing?.checkouts ? { checkouts: existing.checkouts } : {}
7618
- };
7619
- saveConnection(connection);
7620
- return { ok: true, connection, reused: false };
7621
- }
7622
- if (res && res.status !== 202) {
7623
- const body = await res.json().catch(() => ({}));
7624
- return { ok: false, reason: "refused", message: body.error ?? `refused (${res.status})` };
7625
- }
7626
- await new Promise((r) => setTimeout(r, pollMs));
7627
- }
7628
- return { ok: false, reason: "expired", message: "nobody approved this in time" };
7629
- }
7630
- async function verifyConnection(connection) {
7631
- const res = await fetch(`${connection.service}/api/agent-check`, {
7632
- method: "POST",
7633
- headers: { "content-type": "application/json" },
7634
- body: JSON.stringify({ deviceId: connection.deviceId, secret: connection.secret })
7635
- });
7636
- if (res.status === 404) return true;
7637
- return res.ok;
7638
- }
7639
- function resolveCheckouts(dirs) {
7640
- const projects = [];
7641
- const skipped = [];
7642
- for (const dir of dirs) {
7643
- const root = path7.resolve(dir);
7644
- if (!fs10.existsSync(root)) {
7645
- skipped.push({ root, why: "no such folder" });
7646
- continue;
7647
- }
7648
- const facts = readRepo(root);
7649
- if (!facts.isRepo) {
7650
- skipped.push({ root, why: "not a git repository" });
7651
- continue;
7652
- }
7653
- if (facts.markerUnreadable) {
7654
- skipped.push({ root, why: `${MARKER_PATH} is there but cannot be read` });
7655
- continue;
7656
- }
7657
- if (!facts.marker) {
7658
- skipped.push({ root, why: "not connected to a Flowriki project \u2014 connect its repository from the dashboard" });
7659
- continue;
7660
- }
7661
- if (projects.some((p) => p.id === facts.marker.projectId)) {
7662
- skipped.push({ root, why: "another folder here is already that project" });
7663
- continue;
7664
- }
7665
- projects.push({ id: facts.marker.projectId, name: facts.marker.name, root });
7666
- }
7667
- return { projects, skipped };
7668
- }
7949
+ // src/engine/session.ts
7669
7950
  async function start(options = {}) {
7670
7951
  let service;
7671
7952
  try {
@@ -7744,36 +8025,8 @@ async function start(options = {}) {
7744
8025
  }
7745
8026
  };
7746
8027
  }
7747
- async function prepareBrowsers() {
7748
- if (browsersReady()) {
7749
- return { ok: true, already: true, message: "chromium is already installed" };
7750
- }
7751
- let cli;
7752
- try {
7753
- cli = createRequire4(import.meta.url).resolve("playwright/cli.js");
7754
- } catch {
7755
- return {
7756
- ok: false,
7757
- already: false,
7758
- message: "playwright is not installed beside this agent \u2014 reinstall it"
7759
- };
7760
- }
7761
- const run = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
7762
- if (run.status !== 0) {
7763
- return {
7764
- ok: false,
7765
- already: false,
7766
- message: `could not install chromium (exit ${run.status ?? "unknown"}) \u2014 try \`npx playwright@${playwrightVersion() ?? "latest"} install chromium\` yourself`
7767
- };
7768
- }
7769
- return { ok: browsersReady(), already: false, message: "chromium installed" };
7770
- }
7771
- function browsersReady() {
7772
- return inspectPlaywright().chromiumInstalled === true;
7773
- }
7774
- function playwrightVersion() {
7775
- return inspectPlaywright().version;
7776
- }
8028
+
8029
+ // src/engine/state.ts
7777
8030
  function status(serviceArg) {
7778
8031
  const service = resolveService(serviceArg);
7779
8032
  const connection = connectionFor(service);
@@ -7808,10 +8061,18 @@ async function doctor(serviceArg) {
7808
8061
  const connection = connectionFor(service);
7809
8062
  let reachable = null;
7810
8063
  let serviceError;
8064
+ let environment = null;
7811
8065
  try {
7812
8066
  const res = await fetch(`${service}/healthz`, { signal: AbortSignal.timeout(1e4) });
7813
8067
  reachable = res.ok;
7814
8068
  if (!res.ok) serviceError = `healthz returned ${res.status}`;
8069
+ else {
8070
+ try {
8071
+ const body = await res.json();
8072
+ environment = typeof body.environment === "string" ? body.environment : null;
8073
+ } catch {
8074
+ }
8075
+ }
7815
8076
  } catch (error) {
7816
8077
  reachable = false;
7817
8078
  serviceError = error.message;
@@ -7826,6 +8087,7 @@ async function doctor(serviceArg) {
7826
8087
  arch: process.arch,
7827
8088
  dataDir: dataDir(),
7828
8089
  service,
8090
+ environment,
7829
8091
  credentialFingerprint: connection ? fingerprint(connection.secret) : null,
7830
8092
  reachable,
7831
8093
  ...serviceError ? { serviceError } : {},
@@ -7833,15 +8095,6 @@ async function doctor(serviceArg) {
7833
8095
  problems
7834
8096
  };
7835
8097
  }
7836
- function inspectPlaywright() {
7837
- try {
7838
- const browsersPath = process.env["PLAYWRIGHT_BROWSERS_PATH"] ?? (process.platform === "win32" ? path7.join(os5.homedir(), "AppData", "Local", "ms-playwright") : process.platform === "darwin" ? path7.join(os5.homedir(), "Library", "Caches", "ms-playwright") : path7.join(os5.homedir(), ".cache", "ms-playwright"));
7839
- const chromiumInstalled = fs10.existsSync(browsersPath) ? fs10.readdirSync(browsersPath).some((d) => d.startsWith("chromium")) : false;
7840
- return { version: null, browsersPath, chromiumInstalled };
7841
- } catch {
7842
- return { version: null, browsersPath: null, chromiumInstalled: null };
7843
- }
7844
- }
7845
8098
  function disconnect(serviceArg) {
7846
8099
  const service = resolveService(serviceArg);
7847
8100
  const removed = forgetConnection(service);
@@ -7850,11 +8103,11 @@ function disconnect(serviceArg) {
7850
8103
 
7851
8104
  export {
7852
8105
  AGENT_VERSION,
8106
+ prepareBrowsers,
8107
+ browsersReady,
7853
8108
  connect,
7854
8109
  resolveCheckouts,
7855
8110
  start,
7856
- prepareBrowsers,
7857
- browsersReady,
7858
8111
  status,
7859
8112
  doctor,
7860
8113
  disconnect