scenescout 3.22.0 → 3.23.1

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/dist/engine/ci.js CHANGED
@@ -16,6 +16,7 @@ import { markdownCell } from "./check.js";
16
16
  import { parseLimitFlag } from "./limits.js";
17
17
  import { isWorthALook, redactSecrets } from "./memory.js";
18
18
  import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifLocation } from "./sarif.js";
19
+ import { fromRunLine, resolveFromRun } from "./from-run.js";
19
20
  // ── options ─────────────────────────────────────────────────────────────────
20
21
  export const PROVIDERS = ["anthropic", "openai"];
21
22
  /** Where each provider's key is read from. Nothing else is: no flag, no file, no input. */
@@ -93,6 +94,9 @@ export const CI_OPTION_NAMES = [
93
94
  "show",
94
95
  "compare-url",
95
96
  "dedup",
97
+ "from-run",
98
+ "from-run-mode",
99
+ "from-run-turns-per-page",
96
100
  "sarif-file-anchor",
97
101
  ];
98
102
  /** The longest --show description: it becomes a line of the model's prompt. */
@@ -115,7 +119,8 @@ export function checkBaseUrl(raw) {
115
119
  return { ok: true, url: u.toString().replace(/\/+$/, "") };
116
120
  return { ok: false, error: "--base-url must be https (plain http only to 127.0.0.1 or localhost): the API key is sent to it" };
117
121
  }
118
- export function parseCiArgs(args, cwd) {
122
+ /** `env` is read for the earlier run alone (SCENESCOUT_FROM_RUN, SCENESCOUT_FROM_RUN_MODE), since a flag wins over it. */
123
+ export function parseCiArgs(args, cwd, env = {}) {
119
124
  const positional = [];
120
125
  const flags = new Map();
121
126
  for (let i = 0; i < args.length; i++) {
@@ -271,6 +276,13 @@ export function parseCiArgs(args, cwd) {
271
276
  const anchor = flags.has("sarif-file-anchor") ? checkSarifAnchor(flags.get("sarif-file-anchor")) : undefined;
272
277
  if (anchor && !anchor.ok)
273
278
  return anchor;
279
+ const runFlags = { path: flags.get("from-run"), mode: flags.get("from-run-mode"), turnsPerPage: flags.get("from-run-turns-per-page") };
280
+ if (show && (runFlags.path !== undefined || runFlags.mode !== undefined || runFlags.turnsPerPage !== undefined))
281
+ return { ok: false, error: "--from-run starts an exploration from an earlier run, and --show explores nothing: give one or the other" };
282
+ // A run asked to show an element explores nothing, so an earlier run named in the environment does not apply to it.
283
+ const fromRun = show ? { ok: true } : resolveFromRun(runFlags, env);
284
+ if (!fromRun.ok)
285
+ return fromRun;
274
286
  const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
275
287
  return {
276
288
  ok: true,
@@ -296,6 +308,7 @@ export function parseCiArgs(args, cwd) {
296
308
  ...(navTimeout.value !== undefined ? { navTimeoutMs: navTimeout.value } : {}),
297
309
  dedup: dedup,
298
310
  ...(anchor ? { sarifFileAnchor: anchor.value } : {}),
311
+ ...(fromRun.fromRun ? { fromRun: { ...fromRun.fromRun, path: resolve(fromRun.fromRun.path), given: fromRun.fromRun.path } } : {}),
299
312
  },
300
313
  };
301
314
  }
@@ -691,6 +704,7 @@ export function ciKickoff(o) {
691
704
  return [
692
705
  `Run an exploratory test session following the method.`,
693
706
  `Target: ${o.url}`,
707
+ ...(o.fromRunLines ?? []),
694
708
  `Project directory (for scout_scan): ${o.projectDir}`,
695
709
  `Level: ${o.level}`,
696
710
  `Write mode: ${o.mode}`,
@@ -760,6 +774,7 @@ export function ciSummaryMarkdown(r, secrets = []) {
760
774
  `| Model | ${r.provider} ${cell(r.model, secrets)}, effort ${r.effort} |`,
761
775
  ...(r.lanes ? [`| Lanes | ${lanesCell(r.lanes, secrets)} |`] : []),
762
776
  ...(r.dedup ? [`| Finding dedup | ${dedupLine(r.dedup)} |`] : []),
777
+ ...(r.fromRun ? [`| Started from | ${cell(fromRunLine(r.fromRun), secrets)} |`] : []),
763
778
  `| Usage | ${usageLine(r.spend, r.model, r.endedAt, r.price)} |`,
764
779
  ``,
765
780
  ];
@@ -785,6 +800,27 @@ export function ciSummaryMarkdown(r, secrets = []) {
785
800
  lines.push(r.capture ? `The pictures are in shots/.` : `The full report, with repro steps and the gap ledger, is report.md.`, ``);
786
801
  return lines.join("\n");
787
802
  }
803
+ /** A run record with every text a page or a person wrote in it passed through `clean`, as everything else ci.json holds is. */
804
+ function cleanRecord(r, clean) {
805
+ const all = (xs) => xs.map(clean);
806
+ return {
807
+ ...r,
808
+ knownRoutes: all(r.knownRoutes),
809
+ visited: all(r.visited),
810
+ lanes: r.lanes.map((l) => ({ session: l.session, routes: all(l.routes) })),
811
+ steps: r.steps.map((s) => ({ ...s, route: clean(s.route), ...(s.target !== undefined ? { target: clean(s.target) } : {}) })),
812
+ left: r.left.map((l) => ({
813
+ ...l,
814
+ route: clean(l.route),
815
+ unexercised: all(l.unexercised),
816
+ forms: all(l.forms),
817
+ unchosen: l.unchosen.map((d) => ({ key: clean(d.key), options: all(d.options) })),
818
+ })),
819
+ gaps: all(r.gaps),
820
+ ...(r.assigned ? { assigned: all(r.assigned) } : {}),
821
+ ...(r.prefixes ? { prefixes: r.prefixes.map((n) => ({ ...n, target: clean(n.target), ...(n.why !== undefined ? { why: clean(n.why) } : {}) })) } : {}),
822
+ };
823
+ }
788
824
  function lanesCell(l, secrets) {
789
825
  if (l.oneLoop)
790
826
  return `${l.asked} asked; explored in one loop: ${cell(l.oneLoop, secrets)}`;
@@ -876,6 +912,8 @@ export function ciSummaryJson(r, version, secrets = []) {
876
912
  })),
877
913
  ...(r.capture ? { capture: cleanCapture(r.capture, clean) } : {}),
878
914
  ...(r.lanes ? { lanes: lanesJson(r.lanes, clean) } : {}),
915
+ ...(r.fromRun ? { fromRun: { mode: r.fromRun.mode, source: clean(r.fromRun.source), runId: r.fromRun.runId, recordAt: r.fromRun.recordAt } } : {}),
916
+ ...(r.record ? { record: cleanRecord(r.record, clean) } : {}),
879
917
  };
880
918
  }
881
919
  /** The lanes as ci.json holds them. Module paths and lane names come from the app's routes: redacted like the rest. */
@@ -98,3 +98,73 @@ export class SessionQueue {
98
98
  this.forget(key);
99
99
  }
100
100
  }
101
+ /**
102
+ * The work of every tool call still running, per session.
103
+ *
104
+ * The watchdog answers a slow call, but it cannot stop the call: the work goes
105
+ * on in the background and can still write into the project (its action log,
106
+ * a frame, a debounced memory save). Left alone, those writes land after
107
+ * `scout_close` has answered, and after another session has attached to the
108
+ * same folder. So close waits for them: it closes the browser first, which
109
+ * makes every page-bound step of the abandoned work fail at once, then waits
110
+ * here for the work to finish unwinding. The wait is bounded, so work that is
111
+ * stuck on something other than the browser cannot hold a close forever; the
112
+ * caller is told how many calls were still running when it gave up.
113
+ */
114
+ export class CallWork {
115
+ running = new Map();
116
+ /** Record `work` as running for `key` until it settles, whether it resolves or rejects. */
117
+ track(key, work) {
118
+ let set = this.running.get(key);
119
+ if (!set) {
120
+ set = new Set();
121
+ this.running.set(key, set);
122
+ }
123
+ set.add(work);
124
+ const done = () => {
125
+ const left = this.running.get(key);
126
+ if (!left)
127
+ return;
128
+ left.delete(work);
129
+ if (left.size === 0)
130
+ this.running.delete(key);
131
+ };
132
+ // The rejection is the call's own caller's to handle; here it only means "finished".
133
+ work.then(done, done);
134
+ }
135
+ /** Calls still running for `key`. */
136
+ count(key) {
137
+ return this.running.get(key)?.size ?? 0;
138
+ }
139
+ /** Wait up to `ms` for `key`'s running calls to finish; resolves with how many are still running. */
140
+ async settle(key, ms) {
141
+ return this.settleKeys([key], ms);
142
+ }
143
+ /** The same for every session (close-all). */
144
+ async settleAll(ms) {
145
+ return this.settleKeys([...this.running.keys()], ms);
146
+ }
147
+ async settleKeys(keys, ms) {
148
+ const work = keys.flatMap((k) => [...(this.running.get(k) ?? [])]);
149
+ if (work.length > 0) {
150
+ let timer;
151
+ await Promise.race([Promise.allSettled(work), new Promise((resolve) => (timer = setTimeout(resolve, ms)))]);
152
+ clearTimeout(timer);
153
+ }
154
+ return keys.reduce((n, k) => n + this.count(k), 0);
155
+ }
156
+ }
157
+ /**
158
+ * The cap on every tool call's watchdog, from SCENESCOUT_WATCHDOG_MS. Unset
159
+ * means each tool keeps its own limit. Anything else must be a whole number
160
+ * of milliseconds of at least 100, or the server refuses to start: a typo
161
+ * here would otherwise run with limits nobody chose.
162
+ */
163
+ export function watchdogCap(raw) {
164
+ if (raw === undefined || raw.trim() === "")
165
+ return undefined;
166
+ const ms = Number(raw);
167
+ if (!Number.isInteger(ms) || ms < 100)
168
+ throw new Error(`SCENESCOUT_WATCHDOG_MS must be a whole number of milliseconds, at least 100; got "${raw}"`);
169
+ return ms;
170
+ }