@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.
- package/README.md +30 -7
- package/dist/{chunk-RNHUFS2Q.js → chunk-QGSEWMJE.js} +2103 -1850
- package/dist/cli.js +10 -3
- package/dist/engine.js +1 -1
- package/package.json +2 -2
|
@@ -1,33 +1,449 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/engine.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
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
|
-
//
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
// ../
|
|
20
|
-
import
|
|
21
|
-
import path4 from "path";
|
|
436
|
+
// ../engine-playwright/dist/index.js
|
|
437
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
22
438
|
|
|
23
|
-
// ../
|
|
24
|
-
import { z } from "zod";
|
|
25
|
-
var LocatorOverrideSchema =
|
|
26
|
-
strategy:
|
|
27
|
-
value:
|
|
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 =
|
|
30
|
-
var ArtifactPolicySchema =
|
|
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:
|
|
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:
|
|
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:
|
|
464
|
+
allowUnmaskedArtifacts: z3.boolean().optional()
|
|
49
465
|
}).strict();
|
|
50
|
-
var ExecutionEnvironmentSchema =
|
|
51
|
-
browser:
|
|
52
|
-
viewport:
|
|
53
|
-
locale:
|
|
54
|
-
timezoneId:
|
|
55
|
-
colorScheme:
|
|
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:
|
|
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:
|
|
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:
|
|
67
|
-
openClosedShadowRoots:
|
|
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:
|
|
495
|
+
fullWindow: z3.boolean().optional()
|
|
80
496
|
}).strict();
|
|
81
|
-
var RecordedTargetSchema =
|
|
82
|
-
role:
|
|
83
|
-
name:
|
|
84
|
-
scope:
|
|
85
|
-
scopeRole:
|
|
86
|
-
frame:
|
|
87
|
-
pageId:
|
|
88
|
-
nameFrom:
|
|
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:
|
|
506
|
+
nth: z3.number().int().nonnegative().optional()
|
|
91
507
|
});
|
|
92
|
-
var RecordedAssertionKindSchema =
|
|
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 =
|
|
119
|
-
var RecordedAssertionSchema =
|
|
534
|
+
var AssertionOpSchema = z3.enum(["equals", "contains", "matches", "gt", "gte", "lt", "lte"]);
|
|
535
|
+
var RecordedAssertionSchema = z3.object({
|
|
120
536
|
kind: RecordedAssertionKindSchema,
|
|
121
|
-
value:
|
|
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:
|
|
542
|
+
not: z3.boolean().optional(),
|
|
127
543
|
/** Which attribute, for kind "attribute". Ignored otherwise. */
|
|
128
|
-
attr:
|
|
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:
|
|
133
|
-
why:
|
|
134
|
-
enabled:
|
|
135
|
-
confidence:
|
|
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 =
|
|
139
|
-
url:
|
|
140
|
-
title:
|
|
141
|
-
heading:
|
|
142
|
-
alerts:
|
|
143
|
-
fields:
|
|
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:
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
/**
|
|
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
|
-
|
|
572
|
-
/**
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
683
|
-
|
|
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
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
713
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
771
|
+
var MIGRATIONS = {
|
|
772
|
+
2: (doc) => {
|
|
773
|
+
migrateScrollPrerequisites(doc);
|
|
774
|
+
migrateRadioCheckedState(doc);
|
|
775
|
+
},
|
|
776
|
+
3: migrateExplicitSecretRefs,
|
|
777
|
+
4: migrateBareModifierPresses
|
|
763
778
|
};
|
|
764
|
-
function
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
if (
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
781
|
-
return who ? `\u201C${who}\u201D ${label}${expects}` : `${label}${expects}`;
|
|
794
|
+
return FlowDocumentSchema.parse(raw);
|
|
782
795
|
}
|
|
783
796
|
|
|
784
|
-
// ../
|
|
785
|
-
import {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
886
|
-
const first =
|
|
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
|
-
|
|
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
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
|
|
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
|
-
// ../
|
|
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
|
|
2147
|
-
import
|
|
2396
|
+
import fs22 from "fs";
|
|
2397
|
+
import path22 from "path";
|
|
2148
2398
|
import "playwright";
|
|
2149
|
-
import
|
|
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
|
-
|
|
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 =
|
|
2668
|
+
cachedAccNameScript = fs4.readFileSync(accNameScriptPath(), "utf8");
|
|
2417
2669
|
}
|
|
2418
2670
|
return cachedAccNameScript;
|
|
2419
2671
|
}
|
|
2420
2672
|
function accNameScriptPath() {
|
|
2421
2673
|
try {
|
|
2422
|
-
return
|
|
2674
|
+
return createRequire2(import.meta.url).resolve("@flowriki/recorder/accname.global.js");
|
|
2423
2675
|
} catch {
|
|
2424
|
-
const beside =
|
|
2425
|
-
if (
|
|
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
|
-
|
|
3137
|
+
fs22.mkdirSync(dir, { recursive: true });
|
|
2886
3138
|
await page.screenshot({
|
|
2887
|
-
path:
|
|
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
|
|
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
|
-
|
|
2948
|
-
await context.tracing.stop({ path:
|
|
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
|
-
|
|
2960
|
-
|
|
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 =
|
|
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)
|
|
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 =
|
|
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 =
|
|
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
|
-
// ../
|
|
4006
|
-
import
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
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 } =
|
|
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:")
|
|
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
|
-
// ../
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
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
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
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
|
|
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
|
-
|
|
4749
|
-
return slug;
|
|
5352
|
+
return assertSlug(slug);
|
|
4750
5353
|
}
|
|
4751
5354
|
listFeatures() {
|
|
4752
|
-
if (!
|
|
5355
|
+
if (!fs23.existsSync(this.featuresDir)) return [];
|
|
4753
5356
|
const out = [];
|
|
4754
|
-
for (const entry of
|
|
5357
|
+
for (const entry of fs23.readdirSync(this.featuresDir, { withFileTypes: true })) {
|
|
4755
5358
|
if (!entry.isDirectory()) continue;
|
|
4756
|
-
const file =
|
|
4757
|
-
if (!
|
|
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(
|
|
5362
|
+
out.push(FeatureSchema.parse(JSON.parse(fs23.readFileSync(file, "utf8"))));
|
|
4760
5363
|
} catch (err) {
|
|
4761
5364
|
this.featureProblems.push({
|
|
4762
|
-
path:
|
|
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 =
|
|
4774
|
-
if (
|
|
4775
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
4803
|
-
if (!
|
|
4804
|
-
for (const file of
|
|
4805
|
-
if (!file
|
|
4806
|
-
const full =
|
|
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(
|
|
5411
|
+
out.push(parseFlowDocument(JSON.parse(fs23.readFileSync(full, "utf8"))));
|
|
4809
5412
|
} catch (err) {
|
|
4810
5413
|
this.problemsFound.push({
|
|
4811
|
-
path:
|
|
5414
|
+
path: path23.relative(this.root, full),
|
|
4812
5415
|
error: String(err instanceof Error ? err.message : err).split("\n")[0].trim(),
|
|
4813
|
-
recoverable:
|
|
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
|
|
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 =
|
|
5444
|
+
const file = path23.join(this.root, relativePath);
|
|
4842
5445
|
const backup = this.recoveryPathFor(file);
|
|
4843
|
-
if (!
|
|
4844
|
-
|
|
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 =
|
|
4862
|
-
|
|
4863
|
-
const file =
|
|
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 (
|
|
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 ?
|
|
4883
|
-
if (
|
|
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
|
-
|
|
4886
|
-
|
|
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)
|
|
4891
|
-
return
|
|
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(
|
|
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
|
-
|
|
5536
|
+
fs23.rmSync(path23.join(this.featuresDir, featureSlug, `${doc.slug}${FLOW_SUFFIX}`), { force: true });
|
|
4934
5537
|
}
|
|
4935
5538
|
relPath(doc) {
|
|
4936
|
-
return
|
|
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
|
|
5565
|
+
return path23.join(this.root, ...CONFIG_PATH.split("/"));
|
|
4963
5566
|
}
|
|
4964
5567
|
readConfig() {
|
|
4965
5568
|
try {
|
|
4966
|
-
|
|
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
|
|
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
|
-
|
|
4996
|
-
|
|
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
|
|
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 =
|
|
5091
|
-
return
|
|
5626
|
+
const file = path23.join(this.filesDir, first);
|
|
5627
|
+
return fs23.existsSync(file) ? file : void 0;
|
|
5092
5628
|
}
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
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 ?
|
|
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 &&
|
|
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
|
|
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_${
|
|
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.
|
|
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
|
-
|
|
6423
|
+
store.saveFlow(updated);
|
|
5844
6424
|
this.deps.emit({ type: "debug.ended", flowId, committed: true });
|
|
5845
|
-
return { ok: true, committed: true, flow: { ...updated, path:
|
|
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
|
|
5894
|
-
|
|
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
|
-
|
|
5935
|
-
|
|
5936
|
-
//
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
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
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
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
|
-
|
|
5959
|
-
|
|
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
|
-
|
|
5962
|
-
|
|
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
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
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 {
|
|
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
|
-
|
|
5985
|
-
|
|
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
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
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
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
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 } =
|
|
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
|
-
|
|
6230
|
-
const fd =
|
|
6231
|
-
|
|
6232
|
-
|
|
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
|
|
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
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
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
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
"
|
|
7287
|
+
"flow.delete": async (command, project) => {
|
|
6865
7288
|
if (!project.root) return NO_CHECKOUT;
|
|
6866
7289
|
const store = storeFor(project.root);
|
|
6867
|
-
const
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
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
|
-
//
|
|
6880
|
-
//
|
|
6881
|
-
//
|
|
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
|
-
"
|
|
7311
|
+
"secret.set": async (command, project) => {
|
|
6898
7312
|
if (!project.root) return NO_CHECKOUT;
|
|
6899
7313
|
const store = storeFor(project.root);
|
|
6900
|
-
const
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
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
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
const
|
|
7206
|
-
if (!
|
|
7207
|
-
|
|
7208
|
-
|
|
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
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
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
|
-
|
|
7885
|
+
const agent = buildAgent();
|
|
7886
|
+
journal = agent.journal;
|
|
7887
|
+
return agent;
|
|
7476
7888
|
}
|
|
7477
7889
|
|
|
7478
7890
|
// src/lock.ts
|
|
7479
|
-
import
|
|
7480
|
-
import
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
7943
|
+
fs11.rmSync(file, { force: true });
|
|
7532
7944
|
} catch {
|
|
7533
7945
|
}
|
|
7534
7946
|
}
|
|
7535
7947
|
}
|
|
7536
7948
|
|
|
7537
|
-
// src/
|
|
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
|
-
|
|
7748
|
-
|
|
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
|