sitelooper 0.3.0
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/LICENSE +21 -0
- package/README.md +625 -0
- package/bin/sitelooper.js +6 -0
- package/dist/agent/llm.js +460 -0
- package/dist/agent/llm.js.map +1 -0
- package/dist/agent/loop.js +870 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompt.js +40 -0
- package/dist/agent/prompt.js.map +1 -0
- package/dist/agent/report.js +545 -0
- package/dist/agent/report.js.map +1 -0
- package/dist/agent/tools.js +1147 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/cli.js +1692 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/browser.js +218 -0
- package/dist/daemon/browser.js.map +1 -0
- package/dist/daemon/codegen.js +241 -0
- package/dist/daemon/codegen.js.map +1 -0
- package/dist/daemon/dialogs.js +57 -0
- package/dist/daemon/dialogs.js.map +1 -0
- package/dist/daemon/diff.js +198 -0
- package/dist/daemon/diff.js.map +1 -0
- package/dist/daemon/fingerprint.js +98 -0
- package/dist/daemon/fingerprint.js.map +1 -0
- package/dist/daemon/inputs.js +134 -0
- package/dist/daemon/inputs.js.map +1 -0
- package/dist/daemon/recorder.js +1232 -0
- package/dist/daemon/recorder.js.map +1 -0
- package/dist/daemon/refs.js +194 -0
- package/dist/daemon/refs.js.map +1 -0
- package/dist/daemon/server.js +1724 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/state.js +239 -0
- package/dist/daemon/state.js.map +1 -0
- package/dist/doctor.js +90 -0
- package/dist/doctor.js.map +1 -0
- package/dist/shared/paths.js +80 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/protocol.js +28 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/secrets.js +92 -0
- package/dist/shared/secrets.js.map +1 -0
- package/dist/shared/text.js +39 -0
- package/dist/shared/text.js.map +1 -0
- package/dist/skills/compile.js +1420 -0
- package/dist/skills/compile.js.map +1 -0
- package/dist/skills/components.js +456 -0
- package/dist/skills/components.js.map +1 -0
- package/dist/skills/flow.js +1041 -0
- package/dist/skills/flow.js.map +1 -0
- package/dist/skills/learn.js +406 -0
- package/dist/skills/learn.js.map +1 -0
- package/dist/skills/ledger.js +304 -0
- package/dist/skills/ledger.js.map +1 -0
- package/dist/skills/relabel.js +206 -0
- package/dist/skills/relabel.js.map +1 -0
- package/dist/skills/repair.js +570 -0
- package/dist/skills/repair.js.map +1 -0
- package/dist/skills/replay.js +1281 -0
- package/dist/skills/replay.js.map +1 -0
- package/dist/skills/store.js +147 -0
- package/dist/skills/store.js.map +1 -0
- package/dist/spec/check.js +428 -0
- package/dist/spec/check.js.map +1 -0
- package/dist/spec/diagnostics.js +58 -0
- package/dist/spec/diagnostics.js.map +1 -0
- package/dist/spec/emit.js +2084 -0
- package/dist/spec/emit.js.map +1 -0
- package/dist/spec/index.js +62 -0
- package/dist/spec/index.js.map +1 -0
- package/dist/spec/ir.js +216 -0
- package/dist/spec/ir.js.map +1 -0
- package/dist/spec/lift.js +162 -0
- package/dist/spec/lift.js.map +1 -0
- package/dist/spec/locators.js +270 -0
- package/dist/spec/locators.js.map +1 -0
- package/dist/spec/lower.js +124 -0
- package/dist/spec/lower.js.map +1 -0
- package/dist/spec/repair.js +657 -0
- package/dist/spec/repair.js.map +1 -0
- package/dist/spec/rerecord.js +169 -0
- package/dist/spec/rerecord.js.map +1 -0
- package/dist/spec/rethread.js +120 -0
- package/dist/spec/rethread.js.map +1 -0
- package/package.json +50 -0
- package/skills/sitelooper/SKILL.md +228 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { rootDir } from '../shared/paths.js';
|
|
4
|
+
import { escapeRe, urlParts } from './compile.js';
|
|
5
|
+
import { idPositionPart, identifierLike } from './ledger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Outputs a later run may substitute as a LITERAL when the producing step did
|
|
8
|
+
* not republish them: `{{sid.out}}` → the recorded value.
|
|
9
|
+
*
|
|
10
|
+
* One demonstration of difference is permanent. A value that changed once
|
|
11
|
+
* names a record, and being wrong in that direction is the silent failure —
|
|
12
|
+
* a step acting on the recording run's record while reporting success —
|
|
13
|
+
* whereas being wrong the other way costs a recovery turn. So `differed` is a
|
|
14
|
+
* veto no amount of later agreement lifts.
|
|
15
|
+
*/
|
|
16
|
+
export function stableOutputs(flow) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const step of flow.steps) {
|
|
19
|
+
for (const [name, ev] of Object.entries(step.outputEvidence ?? {})) {
|
|
20
|
+
if (ev.differed > 0 || ev.same < 1)
|
|
21
|
+
continue;
|
|
22
|
+
const value = step.recorded?.[name];
|
|
23
|
+
if (typeof value === 'string' && value)
|
|
24
|
+
out[`${step.id}.${name}`] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Record what a replay of `step` produced, against what the recording run saw.
|
|
31
|
+
* Only outputs BOTH runs reported can be compared — a tier-A replay honestly
|
|
32
|
+
* drops what it could not re-observe, and silence is not disagreement.
|
|
33
|
+
* Returns the names whose verdict changed, for progress reporting.
|
|
34
|
+
*/
|
|
35
|
+
export function noteOutputEvidence(step, reported) {
|
|
36
|
+
const changed = [];
|
|
37
|
+
for (const [name, recorded] of Object.entries(step.recorded ?? {})) {
|
|
38
|
+
const seen = reported[name];
|
|
39
|
+
if (typeof seen !== 'string' || !seen || typeof recorded !== 'string' || !recorded)
|
|
40
|
+
continue;
|
|
41
|
+
const ev = (step.outputEvidence ??= {})[name] ?? { same: 0, differed: 0 };
|
|
42
|
+
const agrees = seen.trim() === recorded.trim();
|
|
43
|
+
const wasStable = ev.differed === 0 && ev.same >= 1;
|
|
44
|
+
if (agrees)
|
|
45
|
+
ev.same += 1;
|
|
46
|
+
else
|
|
47
|
+
ev.differed += 1;
|
|
48
|
+
step.outputEvidence[name] = ev;
|
|
49
|
+
if (wasStable !== (ev.differed === 0 && ev.same >= 1))
|
|
50
|
+
changed.push(name);
|
|
51
|
+
else if (ev.same + ev.differed === 1)
|
|
52
|
+
changed.push(name);
|
|
53
|
+
}
|
|
54
|
+
return changed;
|
|
55
|
+
}
|
|
56
|
+
export function flowsDir() {
|
|
57
|
+
return process.env.SITELOOPER_FLOWS_DIR || path.join(rootDir(), 'flows');
|
|
58
|
+
}
|
|
59
|
+
function flowFile(name) {
|
|
60
|
+
const safe = name.replace(/[^A-Za-z0-9._-]+/g, '_');
|
|
61
|
+
return path.join(flowsDir(), `${safe}.json`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Write a flow. `file` defaults to the flows dir under the flow's own name;
|
|
65
|
+
* a run that loaded the flow from somewhere else passes that path back so
|
|
66
|
+
* evidence and re-pins land where they came from. Tmp + rename, so a reader
|
|
67
|
+
* never sees a half-written file.
|
|
68
|
+
*/
|
|
69
|
+
export function saveFlow(flow, file = flowFile(flow.name)) {
|
|
70
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
71
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(flow, null, 2));
|
|
73
|
+
fs.renameSync(tmp, file);
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A flow the export REFUSED, written where nothing will replay it.
|
|
78
|
+
*
|
|
79
|
+
* Refusing is right — a flow carrying a run value in a locator quietly does
|
|
80
|
+
* its work on the wrong record — but throwing the recording away with it is
|
|
81
|
+
* not. fwrd23l cost 37 minutes and $0.26 to record and left nothing at all
|
|
82
|
+
* behind, and a cloud run is dearer. The `.rejected.json` suffix keeps it out
|
|
83
|
+
* of listFlows — which is excluded explicitly, since `.rejected.json` ends in
|
|
84
|
+
* `.json` too — while leaving it for verify-artifacts and for a human to read.
|
|
85
|
+
*/
|
|
86
|
+
export function saveRejectedFlow(flow, reason) {
|
|
87
|
+
const dir = flowsDir();
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
const file = flowFile(flow.name).replace(/\.json$/, '.rejected.json');
|
|
90
|
+
fs.writeFileSync(file, JSON.stringify({ rejected: reason, flow }, null, 2));
|
|
91
|
+
return file;
|
|
92
|
+
}
|
|
93
|
+
/** A flow and the file it was read from — a path first, then a name in the flows dir. */
|
|
94
|
+
export function loadFlowFile(nameOrPath) {
|
|
95
|
+
const candidates = [nameOrPath, flowFile(nameOrPath)];
|
|
96
|
+
for (const file of candidates) {
|
|
97
|
+
try {
|
|
98
|
+
return { flow: JSON.parse(fs.readFileSync(file, 'utf8')), file };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* try next */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
export function loadFlow(nameOrPath) {
|
|
107
|
+
return loadFlowFile(nameOrPath)?.flow ?? null;
|
|
108
|
+
}
|
|
109
|
+
export function listFlows() {
|
|
110
|
+
let names;
|
|
111
|
+
try {
|
|
112
|
+
names = fs.readdirSync(flowsDir()).filter((n) => n.endsWith('.json') && !n.endsWith('.rejected.json'));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
return names.map((n) => loadFlow(path.join(flowsDir(), n))).filter((f) => Boolean(f));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build a flow from one session's recording.
|
|
121
|
+
*
|
|
122
|
+
* References are resolved in two passes so a step can never reference a value
|
|
123
|
+
* that has not been produced yet:
|
|
124
|
+
* 1. Declared run variables (`vars`) → `{{name}}`, everywhere they occur.
|
|
125
|
+
* 2. A value that equals an *earlier* step's named output → `{{stepId.output}}`.
|
|
126
|
+
* Anything left literal is a constant the caller typed, and stays literal.
|
|
127
|
+
*/
|
|
128
|
+
export function buildFlow(entries, opts) {
|
|
129
|
+
const groups = resolveGroups(groupByInstruction(entries));
|
|
130
|
+
if (!groups.length)
|
|
131
|
+
return null;
|
|
132
|
+
const steps = [];
|
|
133
|
+
const warnings = [];
|
|
134
|
+
const produced = [];
|
|
135
|
+
const seenUrl = new Set(urlParts(opts.startUrl).map((p) => p.value));
|
|
136
|
+
const varEntries = Object.entries(opts.vars).filter(([, v]) => v.length >= 2).sort((a, b) => b[1].length - a[1].length);
|
|
137
|
+
let prevId;
|
|
138
|
+
let prevGroup;
|
|
139
|
+
groups.forEach((g, i) => {
|
|
140
|
+
const id = stepId(g.instruction.text, i);
|
|
141
|
+
// Read the RAW instruction, before references go in: a substituted
|
|
142
|
+
// {{02-create.quotation_ref}} shifts every later word and can push the
|
|
143
|
+
// ask outside the scan window.
|
|
144
|
+
const noop = noopStepWarning(id, g);
|
|
145
|
+
if (noop)
|
|
146
|
+
warnings.push(noop);
|
|
147
|
+
if (prevId && prevGroup) {
|
|
148
|
+
const contradiction = contradictionWarning(prevId, prevGroup, id, g);
|
|
149
|
+
if (contradiction)
|
|
150
|
+
warnings.push(contradiction);
|
|
151
|
+
}
|
|
152
|
+
prevId = id;
|
|
153
|
+
prevGroup = g;
|
|
154
|
+
let text = g.instruction.text;
|
|
155
|
+
for (const [name, value] of varEntries)
|
|
156
|
+
text = replaceToken(text, value, `{{${name}}}`);
|
|
157
|
+
// Reference earlier outputs (longest values first so nested ids resolve).
|
|
158
|
+
for (const p of [...produced].sort((a, b) => b.value.length - a.value.length)) {
|
|
159
|
+
if (p.value.length >= 2 && !coincidental(text, p.value))
|
|
160
|
+
text = replaceToken(text, p.value, `{{${p.stepId}.${p.output}}}`);
|
|
161
|
+
}
|
|
162
|
+
const outputs = Object.keys(g.report?.values ?? {});
|
|
163
|
+
// Capture the skill's slot bindings, referencized like the instruction, so
|
|
164
|
+
// replay binds params from the flow rather than re-parsing the wording.
|
|
165
|
+
let params;
|
|
166
|
+
if (g.report?.skill && opts.bind) {
|
|
167
|
+
const raw = opts.bind(g.report.skill, g.instruction.text);
|
|
168
|
+
if (raw) {
|
|
169
|
+
params = {};
|
|
170
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
171
|
+
let rv = v;
|
|
172
|
+
for (const [name, value] of varEntries)
|
|
173
|
+
rv = replaceToken(rv, value, `{{${name}}}`);
|
|
174
|
+
for (const pr of [...produced].sort((a, b) => b.value.length - a.value.length)) {
|
|
175
|
+
if (pr.value.length >= 2 && !coincidental(rv, pr.value))
|
|
176
|
+
rv = replaceToken(rv, pr.value, `{{${pr.stepId}.${pr.output}}}`);
|
|
177
|
+
}
|
|
178
|
+
params[k] = rv;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
steps.push({
|
|
183
|
+
id,
|
|
184
|
+
instruction: text,
|
|
185
|
+
...(g.report?.skill ? { skill: g.report.skill } : {}),
|
|
186
|
+
...(params ? { params } : {}),
|
|
187
|
+
outputs,
|
|
188
|
+
recorded: g.report?.values ?? {},
|
|
189
|
+
...(g.adopted ? { adopted: true } : {}),
|
|
190
|
+
});
|
|
191
|
+
// Provenance (PLAN-replay-v2): url parts this step MINTED (absent from
|
|
192
|
+
// every earlier url) are outputs too — a later step's recorded literal
|
|
193
|
+
// equal to one becomes {{stepId.url.<part>}}, re-bound each run from
|
|
194
|
+
// where the replay's own browser lands. Same guards as report outputs:
|
|
195
|
+
// length >= 4, first appearance wins.
|
|
196
|
+
//
|
|
197
|
+
// Url parts go into `produced` BEFORE report values, and a report value
|
|
198
|
+
// that duplicates a minted part is not referencized under its report
|
|
199
|
+
// name: a zero-model (tier-A) replay synthesizes its report from live
|
|
200
|
+
// read-backs only and honestly DROPS recorded values it could not
|
|
201
|
+
// re-observe, so a {{step.reportName}} ref dies exactly when the replay
|
|
202
|
+
// is at its best — while {{step.url.<part>}} is published by every
|
|
203
|
+
// replay unconditionally. fwgr-n2/n3 halted on precisely this: the
|
|
204
|
+
// recorded instructions referenced {{02-create.dashboard_uid}}, the
|
|
205
|
+
// tier-A replay's report legitimately omitted it, and recovery ran with
|
|
206
|
+
// the uid blanked until it turn-capped.
|
|
207
|
+
const minted = [];
|
|
208
|
+
if (g.endUrl) {
|
|
209
|
+
// The WHOLE url, not only its parts. A step's params often carry it
|
|
210
|
+
// entire ("On ticket {{v1}} (url {{v2}})"), and without provenance that
|
|
211
|
+
// literal gets referencized under whatever the RECORDING run's report
|
|
212
|
+
// happened to name it. fwrd21l shows the cost: 02-add's model report
|
|
213
|
+
// named it `url`, so the flow said {{02-add.url}} — then on replay 02-add
|
|
214
|
+
// went tier A, synthesizeReport honestly dropped a recorded url it could
|
|
215
|
+
// not re-observe, the ref went unresolved, and FOUR later steps skipped
|
|
216
|
+
// the zero-model path entirely. Exactly the fwgr-n2/n3 failure the parts
|
|
217
|
+
// loop below was written for, one level up.
|
|
218
|
+
if (!produced.some((p) => p.value === g.endUrl) && !varEntries.some(([, v]) => v === g.endUrl)) {
|
|
219
|
+
minted.push({ stepId: id, output: 'url', value: g.endUrl });
|
|
220
|
+
}
|
|
221
|
+
for (const part of urlParts(g.endUrl)) {
|
|
222
|
+
const fresh = !seenUrl.has(part.value);
|
|
223
|
+
seenUrl.add(part.value);
|
|
224
|
+
// Looser than compile-level derived params, which demand a digit:
|
|
225
|
+
// grafana mints digitless uids ("cfwcsdxqdjabkf" sank fwgr2), so
|
|
226
|
+
// identifierLike() accepts a long word too. It still refuses short
|
|
227
|
+
// route words — "tickets" out of a url was being substituted into
|
|
228
|
+
// fwrd8's verify prose ("on the {{01-open.url.h0}} list").
|
|
229
|
+
// Position is evidence here too: a `q.id` part is a record id
|
|
230
|
+
// whatever its length — odoo's `#id=44` failed the shape test and the
|
|
231
|
+
// recording's record rode into fwod27's replays (see idPositionPart).
|
|
232
|
+
if (!fresh || (!identifierLike(part.value) && !idPositionPart(part)))
|
|
233
|
+
continue;
|
|
234
|
+
if (produced.some((p) => p.value === part.value) || varEntries.some(([, v]) => v === part.value))
|
|
235
|
+
continue;
|
|
236
|
+
minted.push({ stepId: id, output: `url.${part.label}`, value: part.value });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
produced.push(...minted);
|
|
240
|
+
for (const [output, value] of Object.entries(g.report?.values ?? {})) {
|
|
241
|
+
if (typeof value !== 'string' || !value)
|
|
242
|
+
continue;
|
|
243
|
+
if (minted.some((m) => m.value === value))
|
|
244
|
+
continue;
|
|
245
|
+
// A value the step's own instruction handed it is an INPUT the report
|
|
246
|
+
// echoed, not something the step observed: a later step quoting it
|
|
247
|
+
// resolves the way this step did — a var, an earlier reference, or a
|
|
248
|
+
// constant of the flow — never through this step's output, which a
|
|
249
|
+
// tier-A replay has no read to republish. fwgr23 05-open referenced
|
|
250
|
+
// {{04-open.tag}} for the "bench" its own instruction typed; the
|
|
251
|
+
// zero-model replay of 04-open could not publish it, and 05-open went
|
|
252
|
+
// to the model on every replay (19–44 turns).
|
|
253
|
+
if (replaceToken(g.instruction.text, value, ' ') !== g.instruction.text)
|
|
254
|
+
continue;
|
|
255
|
+
// EVERY reported value becomes a reference. Run 1 makes no judgement
|
|
256
|
+
// about which of them name a record, because it cannot: "New (unsaved)"
|
|
257
|
+
// and "S00021" are both just strings a step reported, and the question
|
|
258
|
+
// — does the app produce this again, or was it specific to this run? —
|
|
259
|
+
// is about behaviour ACROSS runs.
|
|
260
|
+
//
|
|
261
|
+
// A previous cut of this gated on identifierLike, which reads the
|
|
262
|
+
// characters. That is the failure this plan exists to remove: a record
|
|
263
|
+
// id that does not look like one would be left literal and every replay
|
|
264
|
+
// would act on run 1's record while reporting success.
|
|
265
|
+
//
|
|
266
|
+
// So reference everything, which is the safe default (an unresolved
|
|
267
|
+
// reference costs a recovery turn, never a wrong record), and let run 2
|
|
268
|
+
// demote the ones it demonstrates are app furniture — see
|
|
269
|
+
// noteOutputEvidence/stableOutputs and PLAN-evidence-over-shape.md.
|
|
270
|
+
produced.push({ stepId: id, output, value });
|
|
271
|
+
// An id can be minted where no url ever carries it: an app that saves
|
|
272
|
+
// over its own API answers with JSON, and the run reads that answer
|
|
273
|
+
// back rather than navigating. fwgr5 created its dashboard exactly so —
|
|
274
|
+
// the uid existed only inside the response body — and every later step
|
|
275
|
+
// kept n1's literal uid, which is what made those steps re-derive it on
|
|
276
|
+
// the cheap model on every replay. Publish the JSON's scalar leaves
|
|
277
|
+
// under `{{step.output#path}}`: a tier-A replay re-observes the read,
|
|
278
|
+
// so the path re-reads THIS run's value.
|
|
279
|
+
for (const leaf of jsonLeaves(value)) {
|
|
280
|
+
if (produced.some((p) => p.value === leaf.value) || varEntries.some(([, v]) => v === leaf.value))
|
|
281
|
+
continue;
|
|
282
|
+
produced.push({ stepId: id, output: `${output}#${leaf.path}`, value: leaf.value });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
name: opts.name,
|
|
288
|
+
origin: opts.origin,
|
|
289
|
+
startUrl: opts.startUrl,
|
|
290
|
+
vars: Object.keys(opts.vars),
|
|
291
|
+
steps,
|
|
292
|
+
provenance: { session: opts.session, created: opts.now ?? new Date().toISOString(), ...(opts.model ? { model: opts.model } : {}) },
|
|
293
|
+
...(warnings.length ? { warnings } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function groupByInstruction(entries) {
|
|
297
|
+
const groups = [];
|
|
298
|
+
for (const e of entries) {
|
|
299
|
+
if (e.k === 'instruction') {
|
|
300
|
+
// An escalation continuation (recorded under the original wording,
|
|
301
|
+
// marked `resume`) is the same instruction still in flight: keep the
|
|
302
|
+
// predecessor's group open so its clean start context survives and the
|
|
303
|
+
// continuation's report/endUrl land on it. A resume with no same-text
|
|
304
|
+
// predecessor (truncated recording) stands alone.
|
|
305
|
+
const prev = groups[groups.length - 1];
|
|
306
|
+
if (e.resume && prev?.instruction.text === e.text)
|
|
307
|
+
continue;
|
|
308
|
+
groups.push({ instruction: e, mutations: 0, mutationsDiffed: 0, mutationsEffective: 0 });
|
|
309
|
+
}
|
|
310
|
+
else if (e.k === 'report' && groups.length)
|
|
311
|
+
groups[groups.length - 1].report = e;
|
|
312
|
+
else if (e.k === 'step' && groups.length) {
|
|
313
|
+
const g = groups[groups.length - 1];
|
|
314
|
+
if (e.diff?.url)
|
|
315
|
+
g.endUrl = e.diff.url;
|
|
316
|
+
if (!g.firstTool)
|
|
317
|
+
g.firstTool = e.tool;
|
|
318
|
+
if (MUTATING_TOOLS.has(e.tool)) {
|
|
319
|
+
g.mutations += 1;
|
|
320
|
+
if (e.diff) {
|
|
321
|
+
g.mutationsDiffed += 1;
|
|
322
|
+
const moved = Boolean(e.diff.url) && Boolean(g.instruction.url) && e.diff.url !== g.instruction.url;
|
|
323
|
+
if (e.diff.added?.length || e.diff.alerts?.length || moved)
|
|
324
|
+
g.mutationsEffective += 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return groups;
|
|
330
|
+
}
|
|
331
|
+
/** Same page, ignoring query and hash — view state, not location. */
|
|
332
|
+
function samePage(a, b) {
|
|
333
|
+
if (!a || !b)
|
|
334
|
+
return false;
|
|
335
|
+
try {
|
|
336
|
+
const ua = new URL(a);
|
|
337
|
+
const ub = new URL(b);
|
|
338
|
+
return ua.origin === ub.origin && decodeURIComponent(ua.pathname) === decodeURIComponent(ub.pathname);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The groups that ARE the resolved path, in order.
|
|
346
|
+
*
|
|
347
|
+
* Success groups, obviously. But dropping every other group loses work the
|
|
348
|
+
* session provably built on: fwgr14's "create a NEW dashboard. Add a Stat
|
|
349
|
+
* panel..." blocked on the turn budget, then failed on escalation — yet both
|
|
350
|
+
* attempts HAD created the dashboard, and the very next (successful)
|
|
351
|
+
* instruction began "The browser is on an unsaved new Grafana dashboard..."
|
|
352
|
+
* and saved it. The exported flow started on in-memory state no replay could
|
|
353
|
+
* reach and scored 1/6 on every replay. Same class as fwod27, where the
|
|
354
|
+
* dropped create meant no step produced the record and its id could not be
|
|
355
|
+
* referencized.
|
|
356
|
+
*
|
|
357
|
+
* So a non-success group is ADOPTED when the recording itself testifies its
|
|
358
|
+
* work is part of the path:
|
|
359
|
+
* 1. it changed the app (ran mutating tools) — an observing group that
|
|
360
|
+
* blocked contributed nothing a replay needs;
|
|
361
|
+
* 2. the next kept group picked up exactly where it left off — issued on
|
|
362
|
+
* the same page the group ended on, and not opening with a `goto`
|
|
363
|
+
* (a successor that navigates away first is the workaround case, where
|
|
364
|
+
* the drop is correct).
|
|
365
|
+
* Scanned right-to-left so a chain of continuations adopts as a chain.
|
|
366
|
+
*
|
|
367
|
+
* Marks `adopted` on the group (unbankedMutations reads it) and returns the
|
|
368
|
+
* kept groups.
|
|
369
|
+
*/
|
|
370
|
+
function resolveGroups(groups) {
|
|
371
|
+
const kept = groups.map((g) => g.report?.status === 'success');
|
|
372
|
+
for (let i = groups.length - 2; i >= 0; i--) {
|
|
373
|
+
if (kept[i])
|
|
374
|
+
continue;
|
|
375
|
+
const g = groups[i];
|
|
376
|
+
const next = groups[i + 1];
|
|
377
|
+
if (!g.report || !g.mutations || !kept[i + 1])
|
|
378
|
+
continue;
|
|
379
|
+
if (next.firstTool === 'goto')
|
|
380
|
+
continue;
|
|
381
|
+
if (!samePage(g.endUrl, next.instruction.url))
|
|
382
|
+
continue;
|
|
383
|
+
g.adopted = true;
|
|
384
|
+
kept[i] = true;
|
|
385
|
+
}
|
|
386
|
+
return groups.filter((_, i) => kept[i]);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Verbs whose presence makes an instruction MUTATING BY INTENT — the caller
|
|
390
|
+
* asked for the app to be different afterwards, not merely observed.
|
|
391
|
+
*/
|
|
392
|
+
const MUTATING_VERBS = [
|
|
393
|
+
'create', 'add', 'delete', 'remove', 'cancel', 'confirm', 'change', 'update',
|
|
394
|
+
'set', 'save', 'submit', 'move', 'archive', 'rename', 'upload',
|
|
395
|
+
];
|
|
396
|
+
/**
|
|
397
|
+
* One verb, in the shapes an orchestrator actually writes it. Spelled out
|
|
398
|
+
* rather than a `verb\w{0,4}` wildcard on purpose: the wildcard makes "set"
|
|
399
|
+
* match "settings" and "settled", and every false verb here becomes a false
|
|
400
|
+
* warning against a step that is fine.
|
|
401
|
+
*/
|
|
402
|
+
function verbForms(verb) {
|
|
403
|
+
const noE = verb.replace(/e$/, '');
|
|
404
|
+
const last = verb[verb.length - 1];
|
|
405
|
+
return [verb, `${verb}s`, `${verb}es`, `${verb}d`, `${verb}ed`, `${verb}ing`, `${noE}ing`, `${verb}${last}ed`, `${verb}${last}ing`];
|
|
406
|
+
}
|
|
407
|
+
const MUTATING_VERB_RE = new RegExp(`^(?:${MUTATING_VERBS.flatMap(verbForms).join('|')})$`, 'i');
|
|
408
|
+
/** A word that turns the verb after it into an instruction NOT to do the thing. */
|
|
409
|
+
const NEGATOR_RE = /^(?:not|never|no|without|cannot|don't|dont|doesn't|isn't|avoid|skip)$/i;
|
|
410
|
+
/**
|
|
411
|
+
* An instruction that says, anywhere, that it changes nothing. fwod34's
|
|
412
|
+
* 07-open and 09-change both do ("this is a read-only check", "Read-only
|
|
413
|
+
* check, do not change anything") while both quote a mutating verb — 09's
|
|
414
|
+
* step id is literally `09-change`. Cheap, exact, and it costs only warnings
|
|
415
|
+
* we would rather not have made.
|
|
416
|
+
*/
|
|
417
|
+
const READ_ONLY_RE = /read[- ]?only|do(?: not|n't|nt) (?:change|modify|edit|alter)|without (?:chang|modify|edit)/i;
|
|
418
|
+
/**
|
|
419
|
+
* The verb this instruction asks for, or null if it asks for nothing that
|
|
420
|
+
* changes the app.
|
|
421
|
+
*
|
|
422
|
+
* Scans the opening of the instruction — where the orchestrator states the
|
|
423
|
+
* job, before the how-to prose and the reporting boilerplate. The window is
|
|
424
|
+
* WIDE (40 words) because real wording puts the ask late: fwod34's 08-open
|
|
425
|
+
* spends nineteen words identifying the record ("The sales order S00021
|
|
426
|
+
* (model sale.order, record id 21) is currently in 'Sales Order' status and
|
|
427
|
+
* needs to be") before it says "cancelled". A wide window is safe only
|
|
428
|
+
* because both guards above run first — a negated verb and a self-declared
|
|
429
|
+
* read-only instruction are dropped whatever the window.
|
|
430
|
+
*/
|
|
431
|
+
export function mutatingIntent(instruction) {
|
|
432
|
+
if (READ_ONLY_RE.test(instruction))
|
|
433
|
+
return null;
|
|
434
|
+
const words = instruction.split(/\s+/).slice(0, 40).map((w) => w.replace(/^[^A-Za-z']+|[^A-Za-z']+$/g, ''));
|
|
435
|
+
for (let i = 0; i < words.length; i++) {
|
|
436
|
+
if (!MUTATING_VERB_RE.test(words[i]))
|
|
437
|
+
continue;
|
|
438
|
+
if (words.slice(Math.max(0, i - 6), i).some((w) => NEGATOR_RE.test(w)))
|
|
439
|
+
continue;
|
|
440
|
+
const lower = words[i].toLowerCase();
|
|
441
|
+
const verb = MUTATING_VERBS.find((v) => verbForms(v).some((f) => f === lower));
|
|
442
|
+
if (verb)
|
|
443
|
+
return verb;
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A value this instruction reported that the page was ALREADY showing before
|
|
449
|
+
* it ran — the strongest single line of evidence that the step's outcome was
|
|
450
|
+
* not the step's doing.
|
|
451
|
+
*
|
|
452
|
+
* Conservative by construction: single-line values only (a status bar's whole
|
|
453
|
+
* multi-line text is never a fair substring test), at least three characters,
|
|
454
|
+
* and an exact match against the pre-state snapshot. When nothing qualifies
|
|
455
|
+
* the caller omits the clause rather than guessing.
|
|
456
|
+
*/
|
|
457
|
+
function alreadyShown(g) {
|
|
458
|
+
const before = g.instruction.startText;
|
|
459
|
+
if (!before)
|
|
460
|
+
return null;
|
|
461
|
+
for (const value of Object.values(g.report?.values ?? {})) {
|
|
462
|
+
if (typeof value !== 'string')
|
|
463
|
+
continue;
|
|
464
|
+
const v = value.trim();
|
|
465
|
+
if (v.length < 3 || v.includes('\n'))
|
|
466
|
+
continue;
|
|
467
|
+
if (before.includes(v))
|
|
468
|
+
return v;
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* A step whose instruction asked for a change and whose recording shows none.
|
|
474
|
+
*
|
|
475
|
+
* This is the record-time half of the fwod34 08-open failure. The recording
|
|
476
|
+
* orchestrator wrote an instruction to cancel an order a previous instruction
|
|
477
|
+
* had already been told to cancel; the step reported success, and every later
|
|
478
|
+
* replay halts there because the Cancel button its skill clicks does not
|
|
479
|
+
* exist once the order is cancelled. Every fact needed to say "re-record
|
|
480
|
+
* 08-open" was already in the recording at export time — this reads them.
|
|
481
|
+
*
|
|
482
|
+
* Two shapes count as "changed nothing", and both need the report to say
|
|
483
|
+
* SUCCESS (a blocked or failed instruction is reported elsewhere, and its
|
|
484
|
+
* emptiness is expected rather than suspicious):
|
|
485
|
+
*
|
|
486
|
+
* 1. the instruction ran no state-changing tool at all; or
|
|
487
|
+
* 2. it ran them and NONE of them moved the page — no signature line added,
|
|
488
|
+
* no alert, no navigation. fwod34's 08-open is this one: five clicks and
|
|
489
|
+
* an Escape against a form whose cancel dialog the previous instruction
|
|
490
|
+
* had already left open, every diff empty on all three counts, while its
|
|
491
|
+
* five genuinely mutating siblings (02-create through 05-open) each show
|
|
492
|
+
* one to eighteen effective diffs.
|
|
493
|
+
*
|
|
494
|
+
* Shape 2 demands that diffs were being captured at all (`mutationsDiffed`),
|
|
495
|
+
* so a recording made without them cannot be read as a flow of no-ops.
|
|
496
|
+
*
|
|
497
|
+
* Warn-level, and worded as a suspicion: the flow still exports. What it buys
|
|
498
|
+
* is that whoever can cheaply re-record is told, and that the warning rides
|
|
499
|
+
* on the flow (`Flow.warnings`) for compile to raise again later.
|
|
500
|
+
*/
|
|
501
|
+
function noopStepWarning(id, g) {
|
|
502
|
+
if (g.report?.status !== 'success')
|
|
503
|
+
return null;
|
|
504
|
+
const verb = mutatingIntent(g.instruction.text);
|
|
505
|
+
if (!verb)
|
|
506
|
+
return null;
|
|
507
|
+
const noAction = g.mutations === 0;
|
|
508
|
+
const noEffect = g.mutations > 0 && g.mutationsDiffed > 0 && g.mutationsEffective === 0;
|
|
509
|
+
if (!noAction && !noEffect)
|
|
510
|
+
return null;
|
|
511
|
+
const evidence = noAction
|
|
512
|
+
? 'the recording made no state-changing action'
|
|
513
|
+
: `the recording's ${g.mutations} state-changing action${g.mutations === 1 ? '' : 's'} left the page unchanged`;
|
|
514
|
+
const shown = alreadyShown(g);
|
|
515
|
+
const clause = shown ? `, and the page already showed '${shown}' before it ran` : '';
|
|
516
|
+
return `noop-step: ${id} changed nothing: its instruction asks to ${verb}, ${evidence}${clause}. The step may be redundant.`;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* A mutating step's report contradicted by the very next read-only step.
|
|
520
|
+
*
|
|
521
|
+
* fwod34's 06-open reported "Cancelled" for the sales order; 07-open, a
|
|
522
|
+
* read-only step immediately after it, read the same order's status back as
|
|
523
|
+
* "Sales Order" — the value the order carries whenever it is NOT cancelled.
|
|
524
|
+
* Nothing in the flow said this out loud: compile just kept both facts and
|
|
525
|
+
* let a much later step (08-open, told to cancel an order that was already
|
|
526
|
+
* cancelled) take the blame. The contradiction is visible at export time —
|
|
527
|
+
* this reads it directly off the two instructions' report values, the same
|
|
528
|
+
* way `noopStepWarning` reads a step's own report against its own pre-state.
|
|
529
|
+
*
|
|
530
|
+
* Scope is deliberately narrow: `i` must be mutating by intent and report
|
|
531
|
+
* success (a step that failed or was never asked to change anything cannot
|
|
532
|
+
* be "contradicted" — there is nothing for the next read to disagree with),
|
|
533
|
+
* and `j` must immediately follow `i` with no gap and be read-only by intent
|
|
534
|
+
* (a second mutating step is expected to change what the first one did, so
|
|
535
|
+
* comparing it would be noise, not a contradiction).
|
|
536
|
+
*
|
|
537
|
+
* Values are matched by label first (the natural case — the same field read
|
|
538
|
+
* twice), falling back to any label pair where BOTH names look like a status
|
|
539
|
+
* or state field, since an orchestrator's wording for the same field drifts
|
|
540
|
+
* step to step ("order_status" vs "current_status"). Only the first line of
|
|
541
|
+
* each value is compared: a status bar lists every reachable state on one
|
|
542
|
+
* line each, so the first line is the CURRENT one and the rest is noise the
|
|
543
|
+
* same way `alreadyShown` treats it. Containment either way counts as
|
|
544
|
+
* agreement (a single-line report next to a status bar's fuller line, or a
|
|
545
|
+
* value that reappeared verbatim, is not a contradiction) — only two first
|
|
546
|
+
* lines that share nothing warrant a warning.
|
|
547
|
+
*/
|
|
548
|
+
function contradictionWarning(idI, gi, idJ, gj) {
|
|
549
|
+
if (gi.report?.status !== 'success')
|
|
550
|
+
return null;
|
|
551
|
+
if (!mutatingIntent(gi.instruction.text))
|
|
552
|
+
return null;
|
|
553
|
+
if (mutatingIntent(gj.instruction.text))
|
|
554
|
+
return null;
|
|
555
|
+
const iValues = gi.report?.values ?? {};
|
|
556
|
+
const jValues = gj.report?.values ?? {};
|
|
557
|
+
for (const [label, jRaw] of Object.entries(jValues)) {
|
|
558
|
+
if (typeof jRaw !== 'string')
|
|
559
|
+
continue;
|
|
560
|
+
let iLabel = typeof iValues[label] === 'string' ? label : undefined;
|
|
561
|
+
if (!iLabel && /status|state/i.test(label)) {
|
|
562
|
+
iLabel = Object.keys(iValues).find((k) => typeof iValues[k] === 'string' && /status|state/i.test(k));
|
|
563
|
+
}
|
|
564
|
+
if (!iLabel)
|
|
565
|
+
continue;
|
|
566
|
+
const iRaw = iValues[iLabel];
|
|
567
|
+
const jLine = jRaw.split('\n')[0].trim();
|
|
568
|
+
const iLine = iRaw.split('\n')[0].trim();
|
|
569
|
+
if (!jLine || !iLine)
|
|
570
|
+
continue;
|
|
571
|
+
const lj = jLine.toLowerCase();
|
|
572
|
+
const li = iLine.toLowerCase();
|
|
573
|
+
if (lj.includes(li) || li.includes(lj))
|
|
574
|
+
continue;
|
|
575
|
+
return `contradicted-step: ${idJ} read ${label} "${jLine}" right after ${idI} reported "${iLine}"; ${idI}'s change may not have landed and a later step may be retrying it. Re-record ${idI}.`;
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
/** Tools that CHANGE the app, as opposed to observing it. */
|
|
580
|
+
const MUTATING_TOOLS = new Set(['click', 'dblclick', 'right_click', 'modifier_click', 'fill', 'type', 'press', 'select', 'check', 'drag', 'upload']);
|
|
581
|
+
/**
|
|
582
|
+
* Instructions that CHANGED the app but did not report success AND were not
|
|
583
|
+
* adopted, so their work contributed nothing to the flow.
|
|
584
|
+
*
|
|
585
|
+
* resolveGroups now adopts the fwgr13/fwgr14 shape (the next kept group
|
|
586
|
+
* carried straight on from the blocked work), so what remains here is the
|
|
587
|
+
* genuinely dropped case: mutating work the session abandoned or worked
|
|
588
|
+
* around. That drop is right — but it must not be silent, because whether the
|
|
589
|
+
* workaround actually replaced the work is a judgement only the person
|
|
590
|
+
* reading the export can make.
|
|
591
|
+
*/
|
|
592
|
+
/**
|
|
593
|
+
* Flow instructions that quote a DATABASE ID this recording minted.
|
|
594
|
+
*
|
|
595
|
+
* The one channel no leak guard reads is the instruction prose itself. In
|
|
596
|
+
* fwod27 the recording-time orchestrator wrote "You are on an Odoo contact
|
|
597
|
+
* form for res.partner id 44" — the id of the record ITS run created (in a
|
|
598
|
+
* blocked instruction, so no flow step produces the value and nothing can be
|
|
599
|
+
* referencized). Locator and navigation guards all passed; both replays
|
|
600
|
+
* navigated to record 44, which the reset had deleted, and halted at step 2
|
|
601
|
+
* with 0/6.
|
|
602
|
+
*
|
|
603
|
+
* Scans EVERY recorded url (blocked instructions included — that is where
|
|
604
|
+
* fwod27's id was minted) for id-position parts, then flags any flow
|
|
605
|
+
* instruction that still quotes one as a literal id. Warn-level: the flow
|
|
606
|
+
* still exports, but the person who can re-record is told while it is cheap.
|
|
607
|
+
*/
|
|
608
|
+
export function staleInstructionIds(entries, flow) {
|
|
609
|
+
const minted = new Set();
|
|
610
|
+
for (const e of entries) {
|
|
611
|
+
const url = e.k === 'step' ? e.diff?.url : e.k === 'instruction' ? e.url : undefined;
|
|
612
|
+
if (!url)
|
|
613
|
+
continue;
|
|
614
|
+
for (const part of urlParts(url))
|
|
615
|
+
if (idPositionPart(part))
|
|
616
|
+
minted.add(part.value);
|
|
617
|
+
}
|
|
618
|
+
if (!minted.size)
|
|
619
|
+
return [];
|
|
620
|
+
const out = [];
|
|
621
|
+
const seen = new Set();
|
|
622
|
+
for (const step of flow.steps) {
|
|
623
|
+
for (const m of step.instruction.matchAll(/\bid\s*[:#]?\s*(\d{1,10})\b/gi)) {
|
|
624
|
+
if (!minted.has(m[1]) || seen.has(`${step.id}:${m[1]}`))
|
|
625
|
+
continue;
|
|
626
|
+
seen.add(`${step.id}:${m[1]}`);
|
|
627
|
+
out.push(`${step.id}'s instruction quotes record id ${m[1]} — a database id this recording minted, so every replay ` +
|
|
628
|
+
`will act on the RECORDING run's record (deleted by the next reset). Re-record with instructions that ` +
|
|
629
|
+
`name records by what is on screen (a name or reference), never by internal id.`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return out;
|
|
633
|
+
}
|
|
634
|
+
export function unbankedMutations(entries) {
|
|
635
|
+
const groups = groupByInstruction(entries);
|
|
636
|
+
resolveGroups(groups); // marks `adopted` in place
|
|
637
|
+
const out = [];
|
|
638
|
+
for (const g of groups) {
|
|
639
|
+
if (g.report?.status === 'success' || g.adopted || !g.mutations)
|
|
640
|
+
continue;
|
|
641
|
+
const text = g.instruction.text;
|
|
642
|
+
out.push(`instruction "${text.slice(0, 70)}${text.length > 70 ? '…' : ''}" ran ${g.mutations} state-changing step(s) ` +
|
|
643
|
+
`but reported ${g.report ? g.report.status : 'nothing'} — its work is NOT in the flow`);
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
function stepId(text, i) {
|
|
648
|
+
const verb = (/\b(sign in|log ?in|create|add|edit|change|set|delete|remove|archive|open|verify|find|report)\b/i.exec(text)?.[1] ?? 'step')
|
|
649
|
+
.toLowerCase()
|
|
650
|
+
.replace(/\s+/g, '');
|
|
651
|
+
return `${String(i + 1).padStart(2, '0')}-${verb}`;
|
|
652
|
+
}
|
|
653
|
+
/** Replace a value on token boundaries, leaving substrings of longer words alone. */
|
|
654
|
+
/**
|
|
655
|
+
* The value only LOOKS like this reference: a common word matching inside a
|
|
656
|
+
* hyphenated compound that means something else.
|
|
657
|
+
*
|
|
658
|
+
* fwgr8 reported `tags: "bench"` and its dashboard slug was
|
|
659
|
+
* `fwgr8-n1-bench-dashboard`, so four later steps had their url rewritten to
|
|
660
|
+
* `{{runid}}-{{04-open.tags}}-dashboard`. The "bench" in that slug comes from
|
|
661
|
+
* the dashboard's NAME, not from its tags; the two agreed by coincidence on
|
|
662
|
+
* the recording run and would not on any other. Every one of those refs then
|
|
663
|
+
* failed to resolve and cost its step the zero-model path.
|
|
664
|
+
*
|
|
665
|
+
* Hyphens deliberately do not bind in replaceToken, because a runid prefix in
|
|
666
|
+
* `x7-bench-dashboard` IS worth threading. The distinction is what the value
|
|
667
|
+
* is: a minted identifier is specific enough that matching inside a compound
|
|
668
|
+
* is evidence, while a common word is not. So a non-identifier must stand
|
|
669
|
+
* alone to be referencized.
|
|
670
|
+
*/
|
|
671
|
+
function coincidental(text, value) {
|
|
672
|
+
if (identifierLike(value))
|
|
673
|
+
return false;
|
|
674
|
+
return new RegExp(`(?<![A-Za-z0-9_-])${escapeRe(value)}(?![A-Za-z0-9_-])`).test(text) === false;
|
|
675
|
+
}
|
|
676
|
+
function replaceToken(text, value, marker) {
|
|
677
|
+
if (!value)
|
|
678
|
+
return text;
|
|
679
|
+
// Underscores bind: `o_form_view_group` is ONE identifier, so a var whose
|
|
680
|
+
// value is "form" must not rewrite its middle (fwod5 shipped exactly that
|
|
681
|
+
// corruption). Hyphens do not bind — a runid prefix in "x7-bench-dashboard"
|
|
682
|
+
// is a reference worth threading.
|
|
683
|
+
const re = new RegExp(`(?<![A-Za-z0-9_])${escapeRe(value)}(?![A-Za-z0-9_])`, 'g');
|
|
684
|
+
// Never substitute INSIDE a reference already placed by an earlier pass: a
|
|
685
|
+
// provenance value that happens to be a common word ("form") rewrote the
|
|
686
|
+
// middle of an output NAME, and fwod5 shipped steps referencing
|
|
687
|
+
// `{{02-create.o_{{01-open.url.q.view_type}}_view_o_group_tabl}}` — a ref
|
|
688
|
+
// that can never resolve. Split on markers, rewrite only the gaps.
|
|
689
|
+
return text
|
|
690
|
+
.split(/(\{\{[^{}]*\}\})/g)
|
|
691
|
+
.map((piece) => (piece.startsWith('{{') && piece.endsWith('}}') ? piece : piece.replace(re, marker)))
|
|
692
|
+
.join('');
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Name the cause of a flow step's recovery, for the progress line and drift
|
|
696
|
+
* telemetry. ALL causes now run cheap-first with the strong model as
|
|
697
|
+
* escalation-on-blocked: the fwrd4l sweep showed the session model rescuing
|
|
698
|
+
* every recovery — replay-failed ones included — at a fraction of the strong
|
|
699
|
+
* model's rate ($0.041 warm vs $0.104 when the same steps routed straight to
|
|
700
|
+
* the strong tier), and a replay refusal is usually a binding problem (a
|
|
701
|
+
* stale template), not the genuine drift the straight-to-strong route was
|
|
702
|
+
* priced for. The strong model is still one blocked report away.
|
|
703
|
+
*/
|
|
704
|
+
export function recoveryRoute(step, unresolved) {
|
|
705
|
+
if (step.adopted && !step.skill)
|
|
706
|
+
return { easy: true, cause: 'adopted' };
|
|
707
|
+
if (!step.skill)
|
|
708
|
+
return { easy: true, cause: 'no-skill' };
|
|
709
|
+
if (unresolved)
|
|
710
|
+
return { easy: true, cause: 'unthreaded-ref' };
|
|
711
|
+
return { easy: true, cause: 'replay-failed' };
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Export-time reference lint (PLAN-no-skill-steps case 4a): find every
|
|
715
|
+
* `{{stepId.output}}` reference whose producing step cannot re-publish the
|
|
716
|
+
* value deterministically on replay, and say so while the author can still do
|
|
717
|
+
* something about it. `url` and its `url.*` parts are exempt — every replay re-binds them
|
|
718
|
+
* from where its own browser lands. `publishes` answers, for a skill id, which
|
|
719
|
+
* output names a tier-A replay re-observes (labelled reads + param-derived
|
|
720
|
+
* report values); null when the skill is not in the store. Advisory only:
|
|
721
|
+
* replay behaviour is unchanged — an unthreaded ref already routes to cheap
|
|
722
|
+
* recovery — this surfaces the debt at build time instead of replay time.
|
|
723
|
+
*/
|
|
724
|
+
export function lintFlowRefs(flow, publishes) {
|
|
725
|
+
const byId = new Map(flow.steps.map((s) => [s.id, s]));
|
|
726
|
+
const warnings = [];
|
|
727
|
+
const seen = new Set();
|
|
728
|
+
for (const step of flow.steps) {
|
|
729
|
+
const texts = [step.instruction, ...Object.values(step.params ?? {})];
|
|
730
|
+
for (const text of texts) {
|
|
731
|
+
for (const m of text.matchAll(/\{\{([\w-]+)\.([\w.#-]+)\}\}/g)) {
|
|
732
|
+
const [, sid, out] = m;
|
|
733
|
+
if (out === 'url' || out.startsWith('url.'))
|
|
734
|
+
continue;
|
|
735
|
+
const producer = byId.get(sid);
|
|
736
|
+
if (!producer || seen.has(`${sid}.${out}`))
|
|
737
|
+
continue;
|
|
738
|
+
seen.add(`${sid}.${out}`);
|
|
739
|
+
const pubs = producer.skill ? publishes(producer.skill) : [];
|
|
740
|
+
// A JSON-path ref (`body#dashboard.uid`) lives or dies with the read
|
|
741
|
+
// that publishes `body`; the path itself is applied after the fact.
|
|
742
|
+
if (pubs === null || pubs.includes(out.split('#')[0]))
|
|
743
|
+
continue;
|
|
744
|
+
warnings.push(`{{${sid}.${out}}} (used by ${step.id}) can only be re-observed by model recovery — ` +
|
|
745
|
+
`consider re-recording so the value is read from the page.`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return warnings;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* One step output, with support for a JSON path suffix: `body#dashboard.uid`
|
|
753
|
+
* reads the `body` output, parses it as JSON, and walks the path. That is how
|
|
754
|
+
* an id an app only ever returned in a response body gets threaded — see the
|
|
755
|
+
* jsonLeaves() publication in buildFlow.
|
|
756
|
+
*/
|
|
757
|
+
/**
|
|
758
|
+
* The outputs a step's END URL publishes: the whole url, plus every part
|
|
759
|
+
* specific enough to be a reference.
|
|
760
|
+
*
|
|
761
|
+
* ONE function, because the producer and the consumer disagreeing is a silent
|
|
762
|
+
* dead reference. buildFlow mints `{{step.url.h1}}` for any part that is
|
|
763
|
+
* `identifierLike` (three characters is enough — repair-desk's ids are "t15"),
|
|
764
|
+
* while the daemon published parts at `length >= 4`. So every flow that named
|
|
765
|
+
* a three-character record id minted a ref nothing would ever resolve, and the
|
|
766
|
+
* four steps depending on it skipped the zero-model path on every replay.
|
|
767
|
+
*/
|
|
768
|
+
export function urlOutputs(url) {
|
|
769
|
+
const out = { url };
|
|
770
|
+
for (const part of urlParts(url)) {
|
|
771
|
+
const key = `url.${part.label}`;
|
|
772
|
+
if (identifierLike(part.value) && !(key in out))
|
|
773
|
+
out[key] = part.value;
|
|
774
|
+
}
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Which of each step's `url.*` outputs some OTHER step actually consumes
|
|
779
|
+
* (`{{03-open.url.q.id}}` in an instruction or a param). The flow runner
|
|
780
|
+
* captures a step's end-url outputs in one snapshot — but an SPA can update
|
|
781
|
+
* its URL a beat AFTER the page itself settles, and a structural replay is
|
|
782
|
+
* fast enough to finish inside that beat. Odoo does exactly this with the
|
|
783
|
+
* `id=` of a freshly saved record: fwod30's replays finished 03-open before
|
|
784
|
+
* the hash carried the id, `{{03-open.url.q.id}}` went unresolved, and every
|
|
785
|
+
* consumer of it fell to full recovery. Knowing which url outputs are
|
|
786
|
+
* consumed lets the capture wait for them, bounded, instead of snapshotting
|
|
787
|
+
* whatever the URL happened to say. The whole-url output (`url`) is always
|
|
788
|
+
* present, so only dotted parts are listed.
|
|
789
|
+
*/
|
|
790
|
+
export function consumedUrlOutputs(steps) {
|
|
791
|
+
const wanted = new Map();
|
|
792
|
+
for (const s of steps) {
|
|
793
|
+
for (const text of [s.instruction, ...Object.values(s.params ?? {})]) {
|
|
794
|
+
for (const m of text.matchAll(/\{\{([\w-]+)\.(url\.[\w.-]+?)(#[\w.-]+)?\}\}/g)) {
|
|
795
|
+
if (m[1] === s.id)
|
|
796
|
+
continue; // own-step refs resolve after its capture regardless
|
|
797
|
+
const set = wanted.get(m[1]) ?? new Set();
|
|
798
|
+
set.add(m[2]);
|
|
799
|
+
wanted.set(m[1], set);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return wanted;
|
|
804
|
+
}
|
|
805
|
+
export function lookupOutput(outputs, sid, out) {
|
|
806
|
+
const hash = out.indexOf('#');
|
|
807
|
+
if (hash < 0)
|
|
808
|
+
return outputs[sid]?.[out];
|
|
809
|
+
const base = outputs[sid]?.[out.slice(0, hash)];
|
|
810
|
+
if (base === undefined)
|
|
811
|
+
return undefined;
|
|
812
|
+
let node;
|
|
813
|
+
try {
|
|
814
|
+
node = JSON.parse(base);
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
for (const key of out.slice(hash + 1).split('.')) {
|
|
820
|
+
if (node === null || typeof node !== 'object')
|
|
821
|
+
return undefined;
|
|
822
|
+
node = node[key];
|
|
823
|
+
}
|
|
824
|
+
return typeof node === 'string' || typeof node === 'number' ? String(node) : undefined;
|
|
825
|
+
}
|
|
826
|
+
/** One reference's value: a `{{var}}` from the run's vars, a `{{step.output}}` from prior outputs, else the stable literal. */
|
|
827
|
+
function lookupRef(ref, src) {
|
|
828
|
+
if (!ref.includes('.'))
|
|
829
|
+
return ref in src.vars ? src.vars[ref] : undefined;
|
|
830
|
+
const dot = ref.indexOf('.');
|
|
831
|
+
const v = lookupOutput(src.outputs, ref.slice(0, dot), ref.slice(dot + 1));
|
|
832
|
+
// Demonstrated stable by an earlier run: the app produced this exact value
|
|
833
|
+
// again, so it is furniture and the recorded literal is right. Anything not
|
|
834
|
+
// demonstrated stays missing and goes to recovery.
|
|
835
|
+
if (v === undefined && ref in src.stable)
|
|
836
|
+
return src.stable[ref];
|
|
837
|
+
return v;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Fill every `{{ref}}` in `text`. ONE substitution for instructions and
|
|
841
|
+
* params alike — a reference that resolves in one and not the other is a
|
|
842
|
+
* silent way to send a step to recovery. `missing` names what could not be
|
|
843
|
+
* filled; an unfilled reference is left in place (`keep`) or blanked.
|
|
844
|
+
*/
|
|
845
|
+
export function resolveRefs(text, src, unresolved = 'keep') {
|
|
846
|
+
const missing = [];
|
|
847
|
+
const filled = text.replace(/\{\{([\w.#-]+)\}\}/g, (m, ref) => {
|
|
848
|
+
const v = lookupRef(ref, src);
|
|
849
|
+
if (v !== undefined)
|
|
850
|
+
return v;
|
|
851
|
+
missing.push(ref);
|
|
852
|
+
return unresolved === 'keep' ? m : '';
|
|
853
|
+
});
|
|
854
|
+
return { text: filled, missing };
|
|
855
|
+
}
|
|
856
|
+
/** Fill {{var}} and {{step.output}} references from run vars and prior outputs. */
|
|
857
|
+
export function resolveInstruction(step, vars, outputs, stable = {}) {
|
|
858
|
+
return resolveRefs(step.instruction, { vars, outputs, stable });
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Like resolveInstruction, but for the recovery path: fill every reference that
|
|
862
|
+
* can be filled and blank the rest (rather than leaving `{{...}}` in the text),
|
|
863
|
+
* so the strong model gets a readable instruction built from what IS known —
|
|
864
|
+
* e.g. the ticket title even when its id could not be threaded.
|
|
865
|
+
*/
|
|
866
|
+
export function softResolveInstruction(step, vars, outputs, stable = {}) {
|
|
867
|
+
return resolveRefs(step.instruction, { vars, outputs, stable }, 'blank')
|
|
868
|
+
.text.replace(/[ \t]{2,}/g, ' ')
|
|
869
|
+
.trim();
|
|
870
|
+
}
|
|
871
|
+
/** Resolve a step's stored param bindings from run vars and prior outputs. */
|
|
872
|
+
/**
|
|
873
|
+
* Which of a step's unresolved references its pinned skill can do without.
|
|
874
|
+
*
|
|
875
|
+
* A reference lives in the step's wording and, sometimes, in a param. The
|
|
876
|
+
* pinned procedure is fixed: only a param some step TYPES or LOCATES by, or
|
|
877
|
+
* that names the record the skill must find (requireText), can change what
|
|
878
|
+
* it does. A reference that reaches nothing else — a tag the instruction
|
|
879
|
+
* mentions for context, a price quoted from the recording — cannot alter a
|
|
880
|
+
* zero-model replay, so its absence is no reason to skip one. fwgr23 05-open
|
|
881
|
+
* went to 19–44 model turns on both replays because `{{04-open.tag}}` was
|
|
882
|
+
* blank, bound to a param no step used.
|
|
883
|
+
*/
|
|
884
|
+
export function ignorableRefs(missing, step, skill) {
|
|
885
|
+
if (!skill)
|
|
886
|
+
return [];
|
|
887
|
+
const needed = new Set();
|
|
888
|
+
for (const [name, p] of Object.entries(skill.params))
|
|
889
|
+
if (p.usedIn.length)
|
|
890
|
+
needed.add(name);
|
|
891
|
+
for (const marker of skill.preconditions.requireText ?? [])
|
|
892
|
+
for (const m of marker.matchAll(/\{\{(v\d+)\}\}/g))
|
|
893
|
+
needed.add(m[1]);
|
|
894
|
+
return [...new Set(missing)].filter((ref) => {
|
|
895
|
+
const token = `{{${ref}}}`;
|
|
896
|
+
return !Object.entries(step.params ?? {}).some(([name, tmpl]) => needed.has(name) && tmpl.includes(token));
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Slot bindings for a step that has just been re-pinned onto another skill.
|
|
901
|
+
* The old bindings are keyed by the OLD skill's slot names, which mean
|
|
902
|
+
* nothing to the new skill — rpat1-r1 re-pinned 04-add and kept
|
|
903
|
+
* `v2: "{{runid}}"` where the new skill's v2 was the project name, so r2
|
|
904
|
+
* bound the wrong values and the delete step was refused for a missing
|
|
905
|
+
* slot. Bindings are re-derived by VALUE: each new slot's example (the value
|
|
906
|
+
* the recovery actually used) is matched against what this run knew —
|
|
907
|
+
* the old bindings as resolved, the declared vars, earlier steps' outputs —
|
|
908
|
+
* and the matching template is carried over; a value containing a known
|
|
909
|
+
* value is templated on that part; anything else stays literal, which is
|
|
910
|
+
* what the recovery typed. `inherited` are the bindings a sibling step
|
|
911
|
+
* that already pins this skill stores, used for slots with no origin.
|
|
912
|
+
*/
|
|
913
|
+
export function remapParams(skill, inherited = {}) {
|
|
914
|
+
// A binding key names where a value comes from: "runid" / "var:runid" (a
|
|
915
|
+
// declared var), "01-open.landed_page" / "output:01-open:landed_page" (an
|
|
916
|
+
// earlier step's output), "02-create.url.p1" / "url:02-create:p1" (a url
|
|
917
|
+
// part a step minted). All of them are a {{ref}} the flow resolves.
|
|
918
|
+
const templateOf = (key) => {
|
|
919
|
+
const m = /^(var|url|output|input)(?::(.*))?$/.exec(key);
|
|
920
|
+
if (!m)
|
|
921
|
+
return `{{${key}}}`;
|
|
922
|
+
if (m[1] === 'var')
|
|
923
|
+
return `{{${m[2]}}}`;
|
|
924
|
+
if (m[1] === 'url') {
|
|
925
|
+
const [step, label] = String(m[2]).split(':');
|
|
926
|
+
return step && label ? `{{${step}.url.${label}}}` : null;
|
|
927
|
+
}
|
|
928
|
+
if (m[1] === 'output') {
|
|
929
|
+
const [step, name] = String(m[2]).split(':');
|
|
930
|
+
return step && name ? `{{${step}.${name}}}` : null;
|
|
931
|
+
}
|
|
932
|
+
return null; // 'input': the run typed it — the example is the value
|
|
933
|
+
};
|
|
934
|
+
// Slots with an origin, as (example → template) pairs; a slot whose
|
|
935
|
+
// example is a composite ("<runid> MTP Bench Project") is templated on the
|
|
936
|
+
// bound slots' examples — this skill's own run, so the values line up.
|
|
937
|
+
const bound = [];
|
|
938
|
+
for (const p of Object.values(skill.params)) {
|
|
939
|
+
const t = p.binding ? templateOf(p.binding) : null;
|
|
940
|
+
if (t && p.example)
|
|
941
|
+
bound.push({ value: String(p.example), template: t });
|
|
942
|
+
}
|
|
943
|
+
bound.sort((a, b) => b.value.length - a.value.length);
|
|
944
|
+
const params = {};
|
|
945
|
+
const unbound = [];
|
|
946
|
+
for (const [name, p] of Object.entries(skill.params)) {
|
|
947
|
+
const ex = String(p.example ?? '');
|
|
948
|
+
const direct = p.binding ? templateOf(p.binding) : null;
|
|
949
|
+
if (direct) {
|
|
950
|
+
params[name] = direct;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
// A slot the skill recorded no origin for, but which another step of
|
|
954
|
+
// the same flow already binds (the flow's `params` for that step are
|
|
955
|
+
// {{ref}} templates that resolve on every run): inherit that binding.
|
|
956
|
+
// rr2od's 08-open was covered by 07-open's read-only status check,
|
|
957
|
+
// whose store entry predates slot origins; the flow knew them all along.
|
|
958
|
+
if (inherited[name] !== undefined) {
|
|
959
|
+
params[name] = inherited[name];
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
let text = ex;
|
|
963
|
+
for (const b of bound)
|
|
964
|
+
if (b.value && text.includes(b.value))
|
|
965
|
+
text = text.split(b.value).join(b.template);
|
|
966
|
+
params[name] = text;
|
|
967
|
+
// A value that identifies the record (known) and could not be templated
|
|
968
|
+
// would replay as the LEARNING run's literal — the re-pin is not safe.
|
|
969
|
+
if (p.known && text === ex)
|
|
970
|
+
unbound.push(name);
|
|
971
|
+
}
|
|
972
|
+
return { params, unbound };
|
|
973
|
+
}
|
|
974
|
+
export function resolveStepParams(step, vars, outputs, stable = {}) {
|
|
975
|
+
if (!step.params)
|
|
976
|
+
return null;
|
|
977
|
+
const params = {};
|
|
978
|
+
const missing = [];
|
|
979
|
+
for (const [k, tmpl] of Object.entries(step.params)) {
|
|
980
|
+
const r = resolveRefs(tmpl, { vars, outputs, stable });
|
|
981
|
+
params[k] = r.text;
|
|
982
|
+
missing.push(...r.missing);
|
|
983
|
+
}
|
|
984
|
+
return { params, missing };
|
|
985
|
+
}
|
|
986
|
+
/** Cap on JSON leaves published per read value, and how deep to walk. */
|
|
987
|
+
const MAX_JSON_LEAVES = 12;
|
|
988
|
+
const MAX_JSON_DEPTH = 4;
|
|
989
|
+
/**
|
|
990
|
+
* Url parts of `url` that are identifier-like and that this session has not
|
|
991
|
+
* seen before — the values it just minted. First appearance wins, so the
|
|
992
|
+
* start url's own parts (and anything already banked) never qualify.
|
|
993
|
+
*/
|
|
994
|
+
export function freshUrlIds(url, seen) {
|
|
995
|
+
const out = [];
|
|
996
|
+
for (const part of urlParts(url)) {
|
|
997
|
+
// Three characters, not four: repair-desk's record ids are "t15", and at
|
|
998
|
+
// a four-character floor fwrd16 left a literal `#/tickets/t15` in six
|
|
999
|
+
// flow steps. identifierLike() still does the real work — a three-letter
|
|
1000
|
+
// route word carries no digit and no separator, so it never qualifies.
|
|
1001
|
+
if (seen.has(part.value) || !identifierLike(part.value))
|
|
1002
|
+
continue;
|
|
1003
|
+
seen.add(part.value);
|
|
1004
|
+
out.push(part);
|
|
1005
|
+
}
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
/** Scalar leaves of a JSON read value, as `path` (dot/index joined) + value. */
|
|
1009
|
+
export function jsonLeaves(text) {
|
|
1010
|
+
const trimmed = text.trim();
|
|
1011
|
+
if (!(trimmed.startsWith('{') || trimmed.startsWith('[')))
|
|
1012
|
+
return [];
|
|
1013
|
+
let root;
|
|
1014
|
+
try {
|
|
1015
|
+
root = JSON.parse(trimmed);
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
return [];
|
|
1019
|
+
}
|
|
1020
|
+
const out = [];
|
|
1021
|
+
const walk = (node, path, depth) => {
|
|
1022
|
+
if (out.length >= MAX_JSON_LEAVES || depth > MAX_JSON_DEPTH)
|
|
1023
|
+
return;
|
|
1024
|
+
if (node !== null && typeof node === 'object') {
|
|
1025
|
+
for (const [k, v] of Object.entries(node))
|
|
1026
|
+
walk(v, path ? `${path}.${k}` : k, depth + 1);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (typeof node !== 'string' && typeof node !== 'number')
|
|
1030
|
+
return;
|
|
1031
|
+
const value = String(node);
|
|
1032
|
+
if (value.length > 120 || !identifierLike(value))
|
|
1033
|
+
return;
|
|
1034
|
+
if (!path)
|
|
1035
|
+
return;
|
|
1036
|
+
out.push({ path, value });
|
|
1037
|
+
};
|
|
1038
|
+
walk(root, '', 0);
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
//# sourceMappingURL=flow.js.map
|