kaloko 0.21.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -102,9 +102,12 @@ kaloko schedule hook <id> # URL + secret that start the run after a deploy
102
102
  - Findings: `GET …/scenarios/<id>/findings` (MCP `list_findings`) groups a
103
103
  scenario's open results by check, site, locale and step, with a status
104
104
  people set (acknowledged, ignored with a reason) and fixed when no page
105
- fails. A scenario may open one Jira / Linear issue per new finding
106
- (`PUT …/findings/sync { tracker, min_severity }`, Developer / Tester): at
107
- most 20 per run, a comment on big changes, closed when fixed. Switching it
105
+ fails. A scenario may open one issue per new finding in Jira, Linear,
106
+ GitHub Issues or Plane (`PUT …/findings/sync { tracker, min_severity,
107
+ github?, plane?, routes? }`, Developer / Tester; routes send checks or
108
+ packs to another tracker): at most 20 per run, a comment on big changes,
109
+ closed when fixed. With `traffic:`, findings and issues carry clicks,
110
+ impressions and impact. Switching it
108
111
  on or ignoring findings is the team's call, not an agent's.
109
112
  - Does not work for: native apps, local or private addresses (localhost, IPs,
110
113
  `.local`), header credentials, stacks listed by crawl, script or file.
@@ -449,7 +449,7 @@ function triageContext ({ config, root, settings, report, fetchImpl, uncertain =
449
449
  const video = side?.files?.webm || side?.files?.mp4
450
450
  // a recording that cannot be read costs its frames only, never the screen's findings
451
451
  if (video) try {
452
- const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.frame_width || 640 })
452
+ const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.frame_width || 640, cacheDir })
453
453
  framesLeft -= frames.length; report.triage.frames += frames.length
454
454
  for (const batch of frameBatches(frames)) {
455
455
  const r = await ask(w, { kind: 'motion', context: { ...context, frames: batch.map((f) => ({ t_ms: f.t, why: f.why })) }, images: batch.map((f) => f.data) }, { frames: batch.map((f) => f.t) })
@@ -8,6 +8,7 @@
8
8
  // Pure: no Node APIs. The shapes are documented in help/reference/health-api.md.
9
9
  import { parseHealthKey } from './health-index.mjs'
10
10
  import { severityRank, SEVERITIES } from './health-core.mjs'
11
+ import { impactOf } from './traffic-core.mjs'
11
12
 
12
13
  export const HEALTH_FINDINGS_SCHEMA = 'kaloko.health-findings.v1'
13
14
  /** What a person may set; fixed is never set, it is derived. */
@@ -45,10 +46,13 @@ export function findingId (fp) {
45
46
  /**
46
47
  * Findings of a flow from its open results. rows: health_open rows ({ key, severity, url, first_run, first_at, misses
47
48
  * }); checks: { HC…: { text, pack } } of the newest run's index; edit: "step|page" → edit_url; seen: { run, at } of
48
- * the newest run (last seen of what it checked). Returns findings, worst first: { id, fingerprint, check, text, pack,
49
- * site, locale, step, severity, count, unchecked, samples: [{ page, url, edit_url }], first_seen, last_seen }.
49
+ * the newest run (last seen of what it checked); traffic: "step|page" → the page's Search Console and GA4 numbers
50
+ * (#157). Returns findings, worst first: { id, fingerprint, check, text, pack, site, locale, step, severity, count,
51
+ * unchecked, samples: [{ page, url, edit_url, traffic? }], first_seen, last_seen, traffic?, impact? }. With traffic,
52
+ * a finding adds up clicks and impressions of its pages that have numbers (`traffic.pages` of them) and its impact
53
+ * (the sum of each page's impact, traffic-core.mjs impactOf); without, it has neither.
50
54
  */
51
- export function groupFindings (rows, { checks = {}, edit = {}, seen = null } = {}) {
55
+ export function groupFindings (rows, { checks = {}, edit = {}, seen = null, traffic = null } = {}) {
52
56
  const out = new Map()
53
57
  for (const r of rows || []) {
54
58
  const k = parseHealthKey(r.key)
@@ -64,9 +68,15 @@ export function groupFindings (rows, { checks = {}, edit = {}, seen = null } = {
64
68
  if (r.misses > 0) f.unchecked++
65
69
  else if (seen) f.last_seen = seen
66
70
  if (r.first_at && (!f.first_seen || r.first_at < f.first_seen.at)) f.first_seen = { run: r.first_run, at: r.first_at }
71
+ const ek = `${k.step}|${k.page}`
72
+ const t = traffic && Object.hasOwn(traffic, ek) && traffic[ek] && typeof traffic[ek] === 'object' ? traffic[ek] : null
73
+ if (t) {
74
+ const sum = (f.traffic ||= { clicks: 0, impressions: 0, pages: 0 })
75
+ sum.clicks += Number(t.clicks) || 0; sum.impressions += Number(t.impressions) || 0; sum.pages++
76
+ f.impact = (f.impact || 0) + impactOf(t, r.severity)
77
+ }
67
78
  if (f.samples.length < SAMPLE_MAX) {
68
- const ek = `${k.step}|${k.page}`
69
- f.samples.push({ page: k.page || null, url: r.url || null, edit_url: (edit && Object.hasOwn(edit, ek) ? edit[ek] : null) || null })
79
+ f.samples.push({ page: k.page || null, url: r.url || null, edit_url: (edit && Object.hasOwn(edit, ek) ? edit[ek] : null) || null, ...(t ? { traffic: { clicks: Number(t.clicks) || 0, impressions: Number(t.impressions) || 0 } } : {}) })
70
80
  }
71
81
  }
72
82
  return [...out.values()].sort(worstFirst)
@@ -79,8 +89,8 @@ export const FINDINGS_SUMMARY_SCHEMA = 'kaloko.health-findings-summary.v1'
79
89
  * the flow keeps open, plus the findings whose every page the run fixed (`fixed`: the diff's fixed results as rows;
80
90
  * count 0, last seen `previous`). An open finding counts the pages the run fixed in `fixed` too.
81
91
  */
82
- export function summarizeFindings (rows, { checks = {}, edit = {}, seen = null, fixed = [], previous = null } = {}) {
83
- const list = groupFindings(rows, { checks, edit, seen })
92
+ export function summarizeFindings (rows, { checks = {}, edit = {}, seen = null, fixed = [], previous = null, traffic = null } = {}) {
93
+ const list = groupFindings(rows, { checks, edit, seen, traffic })
84
94
  const at = new Map(list.map((f) => [f.id, f]))
85
95
  for (const g of groupFindings(fixed, { checks, edit })) {
86
96
  const f = at.get(g.id)
@@ -114,3 +124,54 @@ export const atLeastSeverity = (severity, min) => SEVERITIES.includes(min) && se
114
124
  * the issue last said.
115
125
  */
116
126
  export const countChanged = (before, now) => before != null && Math.abs(now - before) >= Math.max(3, Math.ceil(before * 0.25))
127
+
128
+ // ---------- where issues go (#191) ----------
129
+
130
+ /** Trackers the findings sync can open issues in. */
131
+ export const SYNC_TRACKERS = ['jira', 'linear', 'github', 'plane']
132
+ /** Routes a scenario may have; checks and packs per route. */
133
+ export const ROUTE_MAX = 20
134
+ const ROUTE_LIST_MAX = 50
135
+ const CHECK_PATTERN = /^[A-Z]{1,6}[0-9]{0,6}\*?$/
136
+ const PACK = /^[a-z][a-z0-9_-]{0,30}$/
137
+
138
+ /**
139
+ * Routes of a sync setting from the API: [{ tracker, checks?: ['HC127', 'HC1*'], packs?: ['aeo'] }], in order. A check
140
+ * ends with * to take every check that starts so (HC1* is HC100 to HC199 and HC1000…). Throws an Error that says
141
+ * what is wrong; [] for none.
142
+ */
143
+ export function cleanRoutes (list) {
144
+ if (list == null) return []
145
+ if (!Array.isArray(list)) throw new Error('routes is a list of { tracker, checks, packs }.')
146
+ if (list.length > ROUTE_MAX) throw new Error(`At most ${ROUTE_MAX} routes.`)
147
+ return list.map((r, i) => {
148
+ if (!r || typeof r !== 'object') throw new Error(`Route ${i + 1} is not an object.`)
149
+ const tracker = String(r.tracker || '')
150
+ if (!SYNC_TRACKERS.includes(tracker)) throw new Error(`Route ${i + 1}: tracker is ${SYNC_TRACKERS.join(', ')}.`)
151
+ const words = (v, name) => {
152
+ if (v == null) return []
153
+ if (!Array.isArray(v) || v.length > ROUTE_LIST_MAX) throw new Error(`Route ${i + 1}: ${name} is a list of at most ${ROUTE_LIST_MAX}.`)
154
+ return [...new Set(v.map((x) => String(x ?? '').trim()))]
155
+ }
156
+ const checks = words(r.checks, 'checks').map((c) => c.toUpperCase())
157
+ const packs = words(r.packs, 'packs').map((p) => p.toLowerCase())
158
+ const badCheck = checks.find((c) => !CHECK_PATTERN.test(c) || c === '*')
159
+ if (badCheck !== undefined) throw new Error(`Route ${i + 1}: ${badCheck.slice(0, 20) || '(empty)'} is not a check id (HC127) or a prefix (HC1*).`)
160
+ const badPack = packs.find((p) => !PACK.test(p))
161
+ if (badPack !== undefined) throw new Error(`Route ${i + 1}: ${badPack.slice(0, 20) || '(empty)'} is not a pack name (seo, aeo).`)
162
+ if (!checks.length && !packs.length) throw new Error(`Route ${i + 1}: name checks or packs.`)
163
+ return { tracker, ...(checks.length ? { checks } : {}), ...(packs.length ? { packs } : {}) }
164
+ })
165
+ }
166
+
167
+ const checkMatches = (pattern, check) => pattern.endsWith('*') ? String(check).startsWith(pattern.slice(0, -1)) : pattern === check
168
+ /**
169
+ * The tracker a finding's issue goes to: the first route that names its check (or a prefix of it) or its pack, else
170
+ * the scenario's tracker.
171
+ */
172
+ export function routeOf (f, routes = [], fallback = null) {
173
+ for (const r of routes || []) {
174
+ if ((r.checks || []).some((c) => checkMatches(c, f.check)) || (f.pack && (r.packs || []).includes(String(f.pack).toLowerCase()))) return r.tracker
175
+ }
176
+ return fallback
177
+ }
@@ -8,6 +8,7 @@
8
8
  // (worker/src/health-index.js); the shapes are documented in help/reference/health-api.md.
9
9
  import { healthOfNode, levelOf, findingSeverity, severityRank } from './health-core.mjs'
10
10
  import { worst } from './manifest-core.mjs'
11
+ import { trafficView } from './traffic-core.mjs'
11
12
 
12
13
  export const HEALTH_INDEX_SCHEMA = 'kaloko.health-index.v1'
13
14
  export const HEALTH_AGGREGATE_SCHEMA = 'kaloko.health-aggregate.v1'
@@ -42,14 +43,16 @@ const firstUrl = (vps) => { for (const c of Object.values(vps || {})) if (c?.url
42
43
  * [{ locale, step, check, counts, top: [{ page, path, url, verdict, evidence }] }], open: [[key, severity, url]],
43
44
  * open_total, truncated, passed, edit }. passed says which checks passed on which page: { "step|locale": { sigs:
44
45
  * ["HC103,HC112", …], pages: { <page>: <index into sigs> } } }; only a result that passed counts as fixed later
45
- * (passedIn). edit maps "step|page" to the edit_url of a stack page with open results (findings link it).
46
+ * (passedIn). edit maps "step|page" to the edit_url of a stack page with open results (findings link it); traffic
47
+ * maps "step|page" to the Search Console and GA4 numbers of such a page when its stack has `traffic:` (#157), for the
48
+ * same pages and bound (findings add them up, an issue shows them).
46
49
  */
47
50
  export function buildHealthIndex (manifest, stacks = {}) {
48
51
  const checks = Object.create(null); const groups = new Map(); const open = []; const pages = new Set(); const ok = new Map()
49
52
  // where a page with open results is edited (a stack item's edit_url): "step|page" → URL, kept below for the open list only
50
- const edits = new Map()
53
+ const edits = new Map(); const visits = new Map()
51
54
  const totals = { results: 0, ...blank() }
52
- const add = (step, locale, page, path, url, check, verdict, evidence, editUrl = null) => {
55
+ const add = (step, locale, page, path, url, check, verdict, evidence, editUrl = null, traffic = null) => {
53
56
  if (!VERDICTS.includes(verdict)) return
54
57
  pages.add(`${step}|${page}`)
55
58
  const gk = `${locale}\u0000${step}\u0000${check}`
@@ -63,6 +66,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
63
66
  if (list.length < TOP_KEEP) list.push({ page, ...(path ? { path } : {}), url: url || null, verdict, ...(evidence ? { evidence: cut(evidence) } : {}) })
64
67
  open.push([healthKey(step, locale, check, page), severity, url || null])
65
68
  if (editUrl && typeof editUrl === 'string' && /^https?:\/\//i.test(editUrl)) edits.set(`${step}|${page}`, cut(editUrl, 500))
69
+ if (traffic && typeof traffic === 'object') { const t = trafficView(traffic); if (t && Object.keys(t).length) visits.set(`${step}|${page}`, t) }
66
70
  }
67
71
  for (const n of manifest?.nodes || []) {
68
72
  if (n.kind === 'decision') continue
@@ -85,7 +89,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
85
89
  if (!was || worst([was.v, x[0]]) !== was.v) per.set(k, { locale, id, v: x[0], e: x[1] })
86
90
  }
87
91
  }
88
- for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e, it.edit_url)
92
+ for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e, it.edit_url, it.traffic)
89
93
  }
90
94
  continue
91
95
  }
@@ -115,16 +119,18 @@ export function buildHealthIndex (manifest, stacks = {}) {
115
119
  // edit links of the pages in the kept open list only, at most EDIT_MAX of them (worst first, like the list)
116
120
  const kept = open.slice(0, OPEN_MAX)
117
121
  const edit = Object.create(null); let links = 0
122
+ const traffic = Object.create(null); let numbers = 0
118
123
  for (const [key] of kept) {
119
- if (links >= EDIT_MAX) break
124
+ if (links >= EDIT_MAX && numbers >= EDIT_MAX) break
120
125
  const { step, page } = parseHealthKey(key)
121
126
  const k = `${step}|${page}`
122
- if (edits.has(k) && !Object.hasOwn(edit, k)) { edit[k] = edits.get(k); links++ }
127
+ if (links < EDIT_MAX && edits.has(k) && !Object.hasOwn(edit, k)) { edit[k] = edits.get(k); links++ }
128
+ if (numbers < EDIT_MAX && visits.has(k) && !Object.hasOwn(traffic, k)) { traffic[k] = visits.get(k); numbers++ }
123
129
  }
124
130
  return {
125
131
  schema: HEALTH_INDEX_SCHEMA, checks, totals: { ...totals, pages: pages.size },
126
132
  groups: [...groups.values()].map(({ fails, unsure, ...g }) => ({ ...g, top: [...fails, ...unsure].slice(0, TOP_KEEP) })),
127
- open: kept, open_total: open.length, truncated: open.length > OPEN_MAX, passed, edit
133
+ open: kept, open_total: open.length, truncated: open.length > OPEN_MAX, passed, edit, ...(numbers ? { traffic } : {})
128
134
  }
129
135
  }
130
136
 
@@ -4,6 +4,7 @@
4
4
  // CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID straight to Workers AI. Findings never change a verdict.
5
5
  import { join } from 'node:path'
6
6
  import { readFile } from 'node:fs/promises'
7
+ import { createHash } from 'node:crypto'
7
8
  import { readJson, writeJson, sha256 } from './util.mjs'
8
9
  import { buildTriageRequest, parseTriage, mergeFindings, clefModelId, TRIAGE_PROMPT_VERSION, MAX_TRIAGE_IMAGES } from './triage-core.mjs'
9
10
 
@@ -77,15 +78,24 @@ export function hostedTriageSender (config, report) {
77
78
  return send
78
79
  }
79
80
 
81
+ // bump when the choice of frames or their encoding changes, so cached frames are chosen again
82
+ const FRAMES_VERSION = 1
83
+
80
84
  /**
81
85
  * Frames of a recording for the motion questions, chosen in a browser page: around each tap, where the picture changes
82
86
  * most between samples (a jump, a flash, a message), and the last frame. Returns [{ t, why, data }] in time order,
83
- * at most `max` frames, each a WebP data URL `maxW` wide.
87
+ * at most `max` frames, each a WebP data URL `maxW` wide. With `cacheDir` the frames are kept by the recording's
88
+ * content and the settings, so evaluating the same recording again sends the very same frames (and its triage answers
89
+ * from the cache) even when the browser decodes or encodes a frame differently the second time.
84
90
  */
85
- export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024 }) {
91
+ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024, cacheDir = null }) {
86
92
  if (!(max > 0)) return []
87
- const src = `data:video/${file.endsWith('.mp4') ? 'mp4' : 'webm'};base64,${(await readFile(file)).toString('base64')}`
88
- return page.evaluate(async ({ src, taps, max, maxW }) => {
93
+ const bytes = await readFile(file)
94
+ const cacheFile = cacheDir && join(cacheDir, `frames-${createHash('sha256').update(bytes).update(JSON.stringify({ taps, max, maxW, v: FRAMES_VERSION })).digest('hex')}.json`)
95
+ const kept = cacheFile && await readJson(cacheFile, null)
96
+ if (Array.isArray(kept)) return kept
97
+ const src = `data:video/${file.endsWith('.mp4') ? 'mp4' : 'webm'};base64,${bytes.toString('base64')}`
98
+ const frames = await page.evaluate(async ({ src, taps, max, maxW }) => {
89
99
  const v = document.createElement('video'); v.muted = true; v.preload = 'auto'; v.src = src
90
100
  // every wait has a limit: a stalled video must not hang the evaluation
91
101
  const within = (p, ms) => Promise.race([p, new Promise((ok, no) => setTimeout(() => no(new Error('the recording did not answer in time')), ms))])
@@ -96,13 +106,25 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
96
106
  const dur = Number.isFinite(v.duration) ? v.duration : 0
97
107
  if (!dur) return []
98
108
  const seek = (t) => within(new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(Math.max(0, t), Math.max(0, dur - 0.01)) }), 5000)
109
+ // `seeked` (even at readyState 4) does not mean the frame can be painted yet: on a busy machine the first draw
110
+ // sometimes paints nothing, which read as a change from an empty picture and moved the chosen frames. A decoded
111
+ // frame is opaque, so the draw is repeated until its middle row holds one.
112
+ const draw = async (g, w, h) => {
113
+ const painted = () => { const row = g.getImageData(0, h >> 1, w, 1).data; for (let i = 3; i < row.length; i += 4) if (row[i]) return true; return false }
114
+ for (let tries = 0; ; tries++) {
115
+ g.clearRect(0, 0, w, h); g.drawImage(v, 0, 0, w, h)
116
+ if (painted()) return
117
+ if (tries >= 100) throw new Error('a frame of the recording could not be drawn')
118
+ await new Promise((ok) => setTimeout(ok, 20))
119
+ }
120
+ }
99
121
  const small = document.createElement('canvas'); small.width = 64; small.height = Math.max(1, Math.round(64 * v.videoHeight / v.videoWidth))
100
122
  const sg = small.getContext('2d', { willReadFrequently: true })
101
123
  // sampled differences: 4 samples a second, at most 240
102
124
  const step = Math.max(0.25, dur / 240)
103
125
  const diffs = []; let prev = null
104
126
  for (let t = 0; t <= dur; t += step) {
105
- await seek(t); sg.drawImage(v, 0, 0, small.width, small.height)
127
+ await seek(t); await draw(sg, small.width, small.height)
106
128
  const px = sg.getImageData(0, 0, small.width, small.height).data
107
129
  if (prev) { let d = 0; for (let i = 0; i < px.length; i += 4) d += Math.abs(px[i] - prev[i]) + Math.abs(px[i + 1] - prev[i + 1]) + Math.abs(px[i + 2] - prev[i + 2]); diffs.push({ t, d: d / (px.length / 4) / 765 }) }
108
130
  prev = px
@@ -117,11 +139,13 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
117
139
  const list = chosen.length > max ? [...chosen.slice(0, max - 1), chosen.at(-1)] : chosen
118
140
  const big = document.createElement('canvas'); const s = Math.min(1, maxW / v.videoWidth)
119
141
  big.width = Math.round(v.videoWidth * s); big.height = Math.round(v.videoHeight * s)
120
- const bg = big.getContext('2d')
142
+ const bg = big.getContext('2d', { willReadFrequently: true })
121
143
  const out = []
122
- for (const f of list) { await seek(f.t); bg.drawImage(v, 0, 0, big.width, big.height); out.push({ t: Math.round(f.t * 1000), why: f.why, data: big.toDataURL('image/webp', 0.8) }) }
144
+ for (const f of list) { await seek(f.t); await draw(bg, big.width, big.height); out.push({ t: Math.round(f.t * 1000), why: f.why, data: big.toDataURL('image/webp', 0.8) }) }
123
145
  return out
124
146
  }, { src, taps, max, maxW })
147
+ if (cacheFile && frames.length) await writeJson(cacheFile, frames)
148
+ return frames
125
149
  }
126
150
 
127
151
  /** Frames in requests of MAX_TRIAGE_IMAGES, each overlapping the previous by one frame so a change at the seam is seen. */