kaloko 0.8.0 → 0.9.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/README.md CHANGED
@@ -182,7 +182,9 @@ npx kaloko import walkthrough/ --adhoc # a Cursor or Antigravity walkthroug
182
182
  lays screenshots, Playwright reports and traces out as a run: matched to the
183
183
  scenario's steps (by step id in `steps.json`, URL or title), or as an ad-hoc
184
184
  flow when there is no scenario. Build and share it like any run. When something does not work (Chrome,
185
- token, environment URL, evaluator key), `npx kaloko doctor` says what to fix.
185
+ token, environment URL, evaluator key), `npx kaloko doctor` says what to fix, and
186
+ `npx kaloko doctor --fix` installs a missing browser, creates missing folders and fills in `org:` from your token.
187
+ Every error the CLI prints says what to do next; if you are still stuck, write to support@sinfin.cz.
186
188
 
187
189
  ## Review only what changed
188
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
package/src/cli.mjs CHANGED
@@ -20,7 +20,7 @@ import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
20
20
  import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
21
  import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
22
22
  import { signoffCmd } from './lib/signoff.mjs'
23
- import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
23
+ import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG } from './lib/util.mjs'
24
24
  import { packageRoot } from './lib/schema.mjs'
25
25
  import { compareRuns, formatComparison } from './lib/compare.mjs'
26
26
  import { doctor, formatDoctor } from './lib/doctor.mjs'
@@ -29,7 +29,7 @@ import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './l
29
29
  import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
30
30
  import { detectProject, initScenario, formatDetection } from './lib/init-detect.mjs'
31
31
  import { recheckCaptures } from './lib/recheck.mjs'
32
- import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
32
+ import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft } from './lib/stack.mjs'
33
33
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
34
34
  import { expandPacks } from './lib/packs.mjs'
35
35
  import { upsertPrComment } from './lib/pr.mjs'
@@ -94,7 +94,9 @@ Project
94
94
  the organization and what the token may do; the token (at most 30 days) goes to
95
95
  ~/.config/kaloko/.env, or is printed with --print
96
96
  doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
97
- and app tools; --fix asks macOS for the desktop capture permissions
97
+ and app tools; --fix installs missing Playwright browsers, creates the run and
98
+ scenario folders, writes org: from the token and asks macOS for the desktop
99
+ capture permissions
98
100
  validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
99
101
  Validate scenarios (schema + references); --coverage compares criteria with the plan
100
102
  With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
@@ -449,7 +451,7 @@ async function runContext (args) {
449
451
  }
450
452
 
451
453
  function stepOf (scenario, id) {
452
- if (!id) throw new UserError('--step <id> is required.')
454
+ if (!id) throw new UserError(`Which step? Pass --step <id>; this scenario has: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
453
455
  const node = scenario.nodes.find((n) => n.id === id)
454
456
  if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
455
457
  return node
@@ -655,6 +657,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
655
657
  page.on('response', onResponse)
656
658
  const streak = failureStreak(5)
657
659
  const failed = []; let ok = 0
660
+ const started = Date.now(); let told = started
658
661
  try {
659
662
  for (const [i, item] of chosen.entries()) {
660
663
  if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
@@ -671,7 +674,8 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
671
674
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
672
675
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
673
676
  }
674
- if ((i + 1) % 25 === 0 && i + 1 < chosen.length) say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}`)
677
+ // long stacks: progress every 25 pages (or 30 s) with the time left, estimated from the pages so far
678
+ if (i + 1 < chosen.length && ((i + 1) % 25 === 0 || Date.now() - told > 30000)) { told = Date.now(); const left = timeLeft(i + 1, chosen.length, told - started); say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}${left ? ` · ${left}` : ''}`) }
675
679
  }
676
680
  } finally { page.off('response', onResponse) }
677
681
  say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
@@ -2106,9 +2110,9 @@ const commands = {
2106
2110
  }
2107
2111
  let runId = args.run
2108
2112
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2109
- if (!runId) throw new UserError('--run <id> is required when there is no current run.')
2113
+ if (!runId) throw new UserError('No current run here. Pass --run <id>, or walk the scenario first (kaloko start, then kaloko walk).')
2110
2114
  const run = await load(runId)
2111
- if (!run) throw new UserError(`Run ${runId} not found locally or on the service.`)
2115
+ if (!run) throw new UserError(`Run ${runId} is neither in ${config.output} nor on the service. Check the id (kaloko runs lists the local ones; the run's address on Kaloko shows a shared one).`)
2112
2116
  let baseId = typeof args.baseline === 'string' ? args.baseline : null
2113
2117
  // --design: against the design reference of the scenario (#42)
2114
2118
  if (args.design && run.scenario?.id) { client ||= service(config); baseId = (await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(run.scenario.id)}/references`)).design; if (!baseId) throw new UserError('This scenario has no design reference yet (kaloko reference --design --version <n>).') }
@@ -2120,7 +2124,7 @@ const commands = {
2120
2124
  }
2121
2125
  if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
2122
2126
  const base = await load(baseId)
2123
- if (!base) throw new UserError(`Run ${baseId} not found locally or on the service.`)
2127
+ if (!base) throw new UserError(`Baseline run ${baseId} is neither in ${config.output} nor on the service. Check the id, or pass another --baseline <id>.`)
2124
2128
  const d = compareRuns(run, base)
2125
2129
  if (args.json) { say(JSON.stringify(d, null, 2)); return }
2126
2130
  say(formatComparison(d))
@@ -2199,7 +2203,7 @@ const commands = {
2199
2203
  if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
2200
2204
  if (typeof args.format === 'string') {
2201
2205
  // living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
2202
- if (!config.org) throw new UserError('Set org: in the config first.')
2206
+ if (!config.org) throw new UserError(NO_ORG)
2203
2207
  const { pickProduct } = await import('./lib/changelog.mjs')
2204
2208
  const { exportDocs } = await import('./lib/docs.mjs')
2205
2209
  const product = pickProduct(config, root, args.product)
@@ -2220,7 +2224,7 @@ const commands = {
2220
2224
  }
2221
2225
  let runId = args.run
2222
2226
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2223
- if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
2227
+ if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
2224
2228
  const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
2225
2229
  const { createWriteStream } = await import('node:fs')
2226
2230
  const { Readable } = await import('node:stream')
@@ -2236,7 +2240,7 @@ const commands = {
2236
2240
  const client = service(config)
2237
2241
  let runId = args.run
2238
2242
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2239
- if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
2243
+ if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
2240
2244
  const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
2241
2245
  say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
2242
2246
  },
@@ -2284,7 +2288,7 @@ const commands = {
2284
2288
  const product = pickProduct(config, root, args.product)
2285
2289
  const version = typeof args.version === 'string' ? args.version : 'latest'
2286
2290
  if (sub === 'publish') {
2287
- if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2291
+ if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
2288
2292
  const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
2289
2293
  if (args.json) { say(JSON.stringify(r, null, 2)); return }
2290
2294
  if (r.dry_run) { say(`${product.slug}: ${r.pages} page(s) from ${r.dir} would go to Kaloko as ${r.version} (${r.languages.join(', ')}); dry run, nothing sent.${r.broken.length ? `\n ! broken step references: ${r.broken.join('; ')}` : ''}`); return }
@@ -2312,12 +2316,12 @@ const commands = {
2312
2316
  const { config, root } = await context(args)
2313
2317
  const lib = await import('./lib/changelog.mjs')
2314
2318
  if (sub === 'list') {
2315
- if (!config.org) throw new UserError('Set org: in the config first.')
2319
+ if (!config.org) throw new UserError(NO_ORG)
2316
2320
  const r = await lib.listProducts({ root, config, client: service(config) })
2317
2321
  say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
2318
2322
  return
2319
2323
  }
2320
- if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2324
+ if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
2321
2325
  const r = await lib.syncProducts({ root, config, client: args['dry-run'] ? null : service(config), only: typeof args.product === 'string' ? args.product : null, dryRun: !!args['dry-run'] })
2322
2326
  say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
2323
2327
  if (r.products.some((p) => p.ok === false)) process.exitCode = 1
@@ -2326,7 +2330,7 @@ const commands = {
2326
2330
  /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2327
2331
  async release (args) {
2328
2332
  const { config, root } = await context(args)
2329
- if (!config.org) throw new UserError('Set org: in the config first.')
2333
+ if (!config.org) throw new UserError(NO_ORG)
2330
2334
  const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
2331
2335
  const product = pickProduct(config, root, args.product)
2332
2336
  const client = args['dry-run'] ? null : service(config)
@@ -2360,10 +2364,13 @@ export async function main (argv) {
2360
2364
  const args = parse(argv)
2361
2365
  const name = args._.shift() || 'help'
2362
2366
  const cmd = commands[name] || (args.help ? commands.help : null)
2363
- if (!cmd) { console.error(`Unknown command "${name}".\n`); say(HELP); process.exitCode = 2; return }
2367
+ if (!cmd) { console.error(`There is no command "${name}". The commands are below; kaloko help <command> explains one.\n`); say(HELP); process.exitCode = 2; return }
2364
2368
  try {
2365
2369
  await cmd(args)
2366
2370
  } catch (error) {
2367
- if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else throw error
2371
+ if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
2372
+ console.error(`Kaloko stopped on an unexpected error (a bug on our side). Send the output below to ${SUPPORT_EMAIL} with the command you ran.`)
2373
+ throw error
2374
+ }
2368
2375
  }
2369
2376
  }
@@ -167,7 +167,7 @@ export function totp (base32, { step = 30, digits = 6, now = Date.now() } = {})
167
167
  let bits = ''
168
168
  for (const ch of base32.replace(/[\s=]/g, '').toUpperCase()) {
169
169
  const v = alphabet.indexOf(ch)
170
- if (v < 0) throw new UserError('The TOTP secret is not base32.')
170
+ if (v < 0) throw new UserError('The TOTP secret is not base32 (letters A–Z and digits 2–7). Copy the secret again from the setup screen of the account, not the six-digit code.')
171
171
  bits += v.toString(2).padStart(5, '0')
172
172
  }
173
173
  const key = Buffer.from((bits.match(/.{8}/g) || []).map((b) => parseInt(b, 2)))
@@ -78,7 +78,7 @@ function android (cfg, ctx) {
78
78
  platform: 'android',
79
79
  async open ({ locale, reset, attach = false } = {}) {
80
80
  const { stdout } = await adb(['get-state']).catch((e) => { throw new UserError(`No Android device: ${e.message}. Start an emulator or connect a device (adb devices).`) })
81
- if (!/device/.test(stdout)) throw new UserError('The Android device is not ready (adb get-state).')
81
+ if (!/device/.test(stdout)) throw new UserError('The Android device is connected but not ready (adb get-state). Unlock it and allow USB debugging, or wait until the emulator has booted, then run the command again.')
82
82
  const dens = (await shell('wm', 'density')).stdout.match(/(\d+)\s*$/m)
83
83
  density = dens ? Number(dens[1]) / 160 : 1
84
84
  if (!cfg.device) device = (await shell('getprop', 'ro.product.model')).stdout.trim() || 'android'
@@ -27,7 +27,7 @@ export function webviewSockets (procNetUnix) {
27
27
  * local port and connects Playwright over CDP. Returns { browser, page, pages, close }.
28
28
  */
29
29
  export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
30
- const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH.'); throw e })
30
+ const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH. Install Android platform-tools (kaloko doctor names the command for this system) and open a new terminal.'); throw e })
31
31
  const end = Date.now() + timeout
32
32
  let socket = null
33
33
  while (!socket && Date.now() < end) {
@@ -43,12 +43,12 @@ export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
43
43
  let browser
44
44
  try { browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout }) } catch (e) {
45
45
  await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {})
46
- throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}`)
46
+ throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}. Bring the app to the front and run the command again.`)
47
47
  }
48
48
  const pages = browser.contexts().flatMap((c) => c.pages())
49
49
  // the visible page first: a WebView app often keeps hidden ones around
50
50
  let page = pages[0] || null
51
51
  for (const p of pages) { if (await p.evaluate(() => document.visibilityState === 'visible').catch(() => false)) { page = p; break } }
52
- if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open.') }
52
+ if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open. Open the screen with the WebView in the app, then run the command again.') }
53
53
  return { browser, page, pages, socket: socket.socket, close: async () => { await browser.close().catch(() => {}); await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {}) } }
54
54
  }
@@ -8,7 +8,7 @@ import { promisify } from 'node:util'
8
8
  import { join, resolve, relative, dirname, basename } from 'node:path'
9
9
  import { UserError, sha256 } from './util.mjs'
10
10
  import { repoSlug } from './git.mjs'
11
- import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, groupsMarkdown, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
11
+ import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
12
12
 
13
13
  const run = promisify(execFile)
14
14
  async function out (cmd, args, cwd, timeout = 20000) {
@@ -236,7 +236,7 @@ export async function releaseProduct ({ root, config, product, version = null, d
236
236
  const src = await detectVersionSource(root, product)
237
237
  const v = cleanVersion(version || src.version || '')
238
238
  if (!v) throw new UserError(`Which version? Pass it (kaloko release 2.4.0); ${src.source === 'git-tag' ? `no tag matches ${src.pattern}` : `${src.path || src.source} names none`}.`)
239
- if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`Not a version: ${v}`)
239
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`${v} is not a version Kaloko can use: letters, digits, dots, + and -, at most 80 characters (e.g. 2.4.0).`)
240
240
  const notes = []
241
241
  if (src.version && src.version !== v && src.source !== 'git-tag' && src.source !== 'semantic-release') notes.push(`${src.tool || src.source} (${src.path}) says ${src.version}; recording ${v} as you asked.`)
242
242
  const file = changelogFile(root, product)
@@ -89,7 +89,7 @@ async function runIdOf ({ client, org, scenarioId, ref, branch }) {
89
89
  }
90
90
  if (/^v\d+$/.test(ref)) {
91
91
  const v = (await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/versions`)).find((x) => String(x.version) === ref.slice(1))
92
- if (!v) throw new UserError(`${scenarioId} has no version ${ref}.`)
92
+ if (!v) throw new UserError(`${scenarioId} has no version ${ref}. kaloko history lists its versions.`)
93
93
  return v.id
94
94
  }
95
95
  return ref
@@ -111,7 +111,7 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
111
111
  const sink = exportSink(out)
112
112
  const fileOf = async (path) => {
113
113
  const res = draft ? await fetch(manifest.files_base + path) : await client.fetch(`/orgs/${org}/runs/${runId}/files/${path.split('/').map(enc).join('/')}`)
114
- if (!res.ok) throw new UserError(`${path}: HTTP ${res.status}`)
114
+ if (!res.ok) throw new UserError(`Downloading ${path} failed (HTTP ${res.status}). Run the export again; if it keeps failing, the file may have expired with its run.`)
115
115
  return Buffer.from(await res.arrayBuffer())
116
116
  }
117
117
  // the live files keep the design folder layout: links between steps work from disk exactly as in the folder
@@ -2,8 +2,10 @@
2
2
  // Every check returns { name, ok, detail, hint } so the CLI and tests can read it.
3
3
  import { appTools, macPermissions } from './apps/drivers.mjs'
4
4
  import { createServer } from 'node:net'
5
- import { access as exists, stat } from 'node:fs/promises'
6
- import { resolve } from 'node:path'
5
+ import { access as exists, stat, mkdir, readFile, writeFile } from 'node:fs/promises'
6
+ import { resolve, join, dirname } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { spawn } from 'node:child_process'
7
9
  import { environment } from './config.mjs'
8
10
  import { gateOf, gateHeaders, accountFor } from './access.mjs'
9
11
  import { missing } from './secrets.mjs'
@@ -38,21 +40,38 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
38
40
  * @param {Function} [o.launchEngine] async (engine) => { version } — throws when the engine is not installed
39
41
  * @param {object} [o.tools] app tools found (default: looked up on PATH)
40
42
  * @param {object[]} [o.scenarios] { file, product, module } of the scenario files (products checks)
43
+ * @param {boolean} [o.fix] repair what can be repaired here: install missing Playwright browsers, create the run and
44
+ * scenario folders, write org: from the token into the config, ask macOS for permissions
45
+ * @param {Function} [o.installBrowser] async (engine) => void — installs a Playwright engine (--fix)
41
46
  */
42
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [] }) {
47
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall }) {
43
48
  const checks = []
44
- const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
49
+ const add = (name, ok, detail = '', hint = '', fixed = null) => checks.push({ name, ok, detail, hint, ...(fixed ? { fixed } : {}) })
50
+ // --fix: install an engine, then look again; returns the version or throws what the install said
51
+ const installed = async (engine, retry) => { await installBrowser(engine); return retry() }
45
52
 
46
53
  const major = Number(process.versions.node.split('.')[0])
47
54
  add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
48
55
 
49
56
  add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
50
- if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the organization slug from kaloko.app).')
57
+ if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko doctor --fix with a token to fill it in.')
58
+ await folderChecks({ config, root, fix, add })
51
59
 
52
- try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) { add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (npx playwright install chromium).' : 'Run: npx playwright install chromium') }
60
+ try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) {
61
+ let done = null
62
+ // the bundled Chromium can be installed from here; a branded Chrome (browser.channel) is the person's to install
63
+ if (fix && !config.browser?.channel) { try { done = await installed('chromium', () => launchChrome(config)) } catch {} }
64
+ if (done) add('chrome', true, `Chromium ${done.version || ''}`.trim(), '', 'installed Chromium for Playwright')
65
+ else add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (kaloko browser install).' : fix ? 'Installing Chromium did not work; run kaloko browser install and read its output (on Linux add --with-deps).' : 'Run kaloko doctor --fix (or kaloko browser install) to install Chromium for Playwright.')
66
+ }
53
67
  // cross-browser runs (batch E): the Playwright engines the scenarios and walk.browsers name
54
68
  for (const engine of [...new Set([...(engines || []), ...(config.walk?.browsers || [])])].filter((e) => e !== 'chromium')) {
55
- try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch { add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run: kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`) }
69
+ try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch {
70
+ let done = null
71
+ if (fix) { try { done = await installed(engine, () => launchEngine(engine)) } catch {} }
72
+ if (done) add(`browser ${engine}`, true, `${engine} ${done.version || ''}`.trim(), '', `installed ${engine} for Playwright`)
73
+ else add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run kaloko doctor --fix, or kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`)
74
+ }
56
75
  }
57
76
 
58
77
  for (const name of Object.keys(config.environments || {})) {
@@ -132,16 +151,21 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
132
151
  else {
133
152
  try {
134
153
  const res = await fetchImpl(`${service}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } })
135
- if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked; create a new one in Settings.` : 'The service did not accept the request.')
154
+ if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (it replaces the old token), or create one in Settings → API tokens.` : `The service did not accept the request; try again in a few minutes (${service}/status).`)
136
155
  else {
137
156
  const me = await res.json()
157
+ // --fix: a config without org: takes the token's organization
158
+ if (!config.org && fix && me.org && file) {
159
+ const wrote = await writeOrg(file, me.org).catch(() => false)
160
+ if (wrote) { config.org = me.org; const i = checks.findIndex((c) => c.name === 'org'); if (i >= 0) checks[i] = { name: 'org', ok: true, detail: `org ${me.org}`, hint: '', fixed: `wrote org: ${me.org} into ${file}` } }
161
+ }
138
162
  const sameOrg = !config.org || me.org === config.org
139
163
  // sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
140
164
  const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
141
165
  add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${canShare ? '' : ' (this token cannot share runs)'}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
142
166
  if (!canShare) add('token role', false, Array.isArray(me.scopes) ? `${me.role} · ${me.scopes.join(', ')}` : me.role, 'kaloko share needs a Developer / Tester token with the upload scope.')
143
167
  }
144
- } catch (e) { add('token', false, `${service} unreachable: ${e.message}`, 'Check your network; KALOKO_URL overrides the service URL.') }
168
+ } catch (e) { add('token', false, `${service} unreachable: ${e.cause?.code || e.message}`, 'Check your network or VPN; KALOKO_URL points the CLI to another service.') }
145
169
  }
146
170
 
147
171
  const jev = config.evaluators?.jev
@@ -173,6 +197,44 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
173
197
  return { ok: checks.every((c) => c.ok), checks }
174
198
  }
175
199
 
200
+ /** The run folder and the folders the scenario globs start in: reported, and created with --fix. */
201
+ async function folderChecks ({ config, root, fix, add }) {
202
+ const there = (d) => exists(d).then(() => true, () => false)
203
+ const out = resolve(root, config.output || 'tmp/kaloko')
204
+ if (!await there(out)) {
205
+ if (fix && await mkdir(out, { recursive: true }).then(() => true, () => false)) add('folders', true, `${config.output} created`, '', `created ${config.output}`)
206
+ }
207
+ for (const pattern of config.scenarios || []) {
208
+ const fixed = []
209
+ for (const p of pattern.split('/')) { if (/[*?{[]/.test(p)) break; fixed.push(p) }
210
+ if (!fixed.length || fixed.length === pattern.split('/').length) continue
211
+ const dir = resolve(root, ...fixed)
212
+ if (await there(dir)) continue
213
+ if (fix && await mkdir(dir, { recursive: true }).then(() => true, () => false)) add(`scenarios ${fixed.join('/')}`, true, 'folder created; it has no scenarios yet', 'Draft a first one: kaloko draft <url>', `created ${fixed.join('/')}`)
214
+ else add(`scenarios ${fixed.join('/')}`, false, `${fixed.join('/')} does not exist (scenarios: ${pattern})`, 'Run kaloko doctor --fix to create it, then kaloko draft <url> for a first scenario.')
215
+ }
216
+ }
217
+
218
+ /** Writes org: <slug> into the config file, keeping its comments and layout. */
219
+ export async function writeOrg (file, org) {
220
+ if (!/^[a-z0-9-]+$/.test(org)) return false
221
+ const text = await readFile(file, 'utf8')
222
+ let next
223
+ if (/^org:.*$/m.test(text)) next = text.replace(/^org:.*$/m, `org: ${org}`)
224
+ else if (/^schema:.*$/m.test(text)) next = text.replace(/^(schema:.*)$/m, `$1\norg: ${org}`)
225
+ else next = `org: ${org}\n${text}`
226
+ if (next === text) return false
227
+ await writeFile(file, next)
228
+ return true
229
+ }
230
+
231
+ /** Installs a Playwright engine with the Playwright that kaloko itself uses (same as kaloko browser install). */
232
+ async function defaultInstall (engine) {
233
+ const cli = join(dirname(fileURLToPath(import.meta.resolve('playwright/package.json'))), 'cli.js')
234
+ const code = await new Promise((done) => spawn(process.execPath, [cli, 'install', engine], { stdio: 'inherit' }).on('close', done).on('error', () => done(1)))
235
+ if (code) throw new Error(`playwright install ${engine} exited with ${code}`)
236
+ }
237
+
176
238
  async function defaultEngine (engine) {
177
239
  const pw = await import('playwright')
178
240
  const browser = await pw[engine].launch({ headless: true })
@@ -189,4 +251,4 @@ async function defaultLaunch (config) {
189
251
  return { version }
190
252
  }
191
253
 
192
- export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${!c.ok && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : 'Fix the ✕ items above.'].join('\n')
254
+ export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${c.fixed ? ` (fixed: ${c.fixed})` : ''}${(!c.ok || c.fixed) && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : checks.some((c) => !c.ok && /doctor --fix/.test(c.hint || '')) ? 'Fix the ✕ items above; kaloko doctor --fix repairs the ones that say so.' : 'Fix the ✕ items above.'].join('\n')
@@ -288,7 +288,7 @@ function hostedSender (config, report) {
288
288
  try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no evaluator key (TYPESAFE_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
289
289
  try { return { response: await client.request('POST', `/orgs/${config.org}/semantic`, JSON.parse(body)) } } catch (e) {
290
290
  const m = String(e.message || e)
291
- if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
291
+ if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = e.serviceMessage ?? m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
292
292
  return { error: m }
293
293
  }
294
294
  }
package/src/lib/figma.mjs CHANGED
@@ -63,7 +63,7 @@ export function figmaClient ({ token, fetch: f = globalThis.fetch, base = figmaA
63
63
  variables: (key) => get(`/v1/files/${key}/variables/local`, { optional: true }),
64
64
  async download (url) {
65
65
  const res = await f(url)
66
- if (!res.ok) throw new UserError(`Downloading a frame image failed: HTTP ${res.status}.`)
66
+ if (!res.ok) throw new UserError(`Downloading a frame image from Figma failed (HTTP ${res.status}). Figma's image links expire after a while; run the import again.`)
67
67
  return Buffer.from(await res.arrayBuffer())
68
68
  }
69
69
  }
@@ -4,7 +4,7 @@
4
4
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
5
  import { existsSync } from 'node:fs'
6
6
  import { resolve, relative, dirname, join } from 'node:path'
7
- import { UserError } from './util.mjs'
7
+ import { UserError, NO_ORG } from './util.mjs'
8
8
  import { gateOf, accountFor } from './access.mjs'
9
9
  import { secret } from './secrets.mjs'
10
10
  import { runner } from './ci.mjs'
@@ -23,7 +23,7 @@ const issueLine = (i) => `${i.key.padEnd(10)} ${String(i.state).padEnd(8)} step
23
23
  export async function issueCmd ({ args, config, client, runId, say }) {
24
24
  const sub = args._[0]
25
25
  const org = config.org
26
- if (!org) throw new UserError('Set org: in the config first.')
26
+ if (!org) throw new UserError(NO_ORG)
27
27
  if (sub === 'list') {
28
28
  const q = new URLSearchParams(Object.entries({ run: typeof args.run === 'string' ? args.run : null, scenario: typeof args.scenario === 'string' ? args.scenario : null, state: typeof args.state === 'string' ? args.state : null }).filter(([, v]) => v))
29
29
  const items = await client.request('GET', `/orgs/${org}/issues${q.size ? `?${q}` : ''}`)
@@ -169,7 +169,7 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
169
169
  say(`Wrote ${relative(process.cwd(), out)}: ${scenario.id} on ${envName} ${every === 'week' ? 'every week' : 'every day'} at ${String(atHour(args.at)).padStart(2, '0')}:00 UTC with the full walk (clicks, forms, every criterion).\nNext: add the repository secret KALOKO_TOKEN${secretNames(env, scenario).length ? ` and ${secretNames(env, scenario).join(', ')}` : ''}, then commit the workflow.`)
170
170
  return { file: out }
171
171
  }
172
- if (!org) throw new UserError('Set org: in the config first.')
172
+ if (!org) throw new UserError(NO_ORG)
173
173
  if (sub === 'list' || !sub) {
174
174
  const data = await client().request('GET', `/orgs/${org}/schedules`)
175
175
  if (args.json) { say(JSON.stringify(data, null, 2)); return data }
package/src/lib/login.mjs CHANGED
@@ -34,7 +34,7 @@ export async function login (base, { org = null, print = false, open = true, tok
34
34
  base = base.replace(/\/$/, '')
35
35
  const post = async (path, body) => {
36
36
  let res
37
- try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base}: ${e.message}`) }
37
+ try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base} (${e.cause?.code || e.message}). Check your network or VPN and run kaloko login again; --url or KALOKO_URL points it to another service.`) }
38
38
  return { status: res.status, body: await res.json().catch(() => ({})) }
39
39
  }
40
40
  const start = await post('/cli/device', { client: `kaloko${version ? ` ${version}` : ''} on ${hostname()}`.slice(0, 60) })
package/src/lib/mail.mjs CHANGED
@@ -13,7 +13,7 @@ const address = (a) => (a?.Address ? (a.Name ? `${a.Name} <${a.Address}>` : a.Ad
13
13
 
14
14
  /** Mailpit REST client (https://mailpit.axllent.org/docs/api-v1/). */
15
15
  export function mailpit (mail) {
16
- if (!mail || mail.provider !== 'mailpit' || !mail.url) throw new UserError('Environment has no mail.provider: mailpit with url.')
16
+ if (!mail || mail.provider !== 'mailpit' || !mail.url) throw new UserError('This environment has no Mailpit mailbox. Add mail: { provider: mailpit, url: <Mailpit address> } to it in kaloko.config.yml.')
17
17
  const headers = {}
18
18
  // `auth.basic` (older configs: user_env / password_env next to url), plus any headers of a gate in front of it
19
19
  const basic = mail.auth?.basic || (mail.user_env ? { user_env: mail.user_env, password_env: mail.password_env } : null)
package/src/lib/run.mjs CHANGED
@@ -64,7 +64,7 @@ export async function resolveRun ({ root, output, run }) {
64
64
  dir = join(base, id)
65
65
  }
66
66
  const meta = await readJson(join(dir, 'meta.json'), null)
67
- if (!meta) throw new UserError(`Run directory ${dir} has no meta.json`)
67
+ if (!meta) throw new UserError(`${dir} is not a complete run (meta.json is missing). Start a new one with kaloko start, or delete the folder if it is left over.`)
68
68
  return { dir, meta, paths: runPaths(dir) }
69
69
  }
70
70
 
@@ -19,19 +19,49 @@ export function ciName (vars = process.env) {
19
19
  return vars.CI && vars.CI !== 'false' ? 'ci' : null
20
20
  }
21
21
 
22
+ /** What to do after the service answered `status`; '' when its own message says enough. */
23
+ export function nextStepFor (status, { tokenEnv = 'KALOKO_TOKEN', url = 'https://kaloko.app' } = {}) {
24
+ if (status === 401) return `The token in ${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (or put a fresh token from Settings → API tokens into .env).`
25
+ if (status === 403) return 'Your role or the token\'s scopes do not allow this; ask an admin of the organization, or use a token that may do it.'
26
+ if (status === 413) return 'The file is larger than the service takes; leave it out of the run or make it smaller.'
27
+ if (status === 429) return 'The service is limiting requests from this token. Wait a minute and run the command again.'
28
+ if (status >= 500) return `The service had a problem on its side. Try again in a few minutes; ${url}/status says whether it is a known outage.`
29
+ return ''
30
+ }
31
+
32
+ /** A UserError for an HTTP answer: what failed, the service's message, what to do next; `status` kept for callers. */
33
+ export function httpError (what, status, message, opts) {
34
+ const err = new UserError([`${what} → HTTP ${status}: ${message}`, nextStepFor(status, opts)].filter(Boolean).join(' '))
35
+ err.status = status
36
+ err.serviceMessage = message
37
+ return err
38
+ }
39
+
40
+ /** A UserError for a request that got no answer at all (offline, DNS, refused, TLS). */
41
+ export function unreachable (url, e) {
42
+ const code = e?.cause?.code || e?.code || e?.message || 'no answer'
43
+ const err = new UserError(`Cannot reach ${url} (${code}). Check your network or VPN and run the command again; KALOKO_URL points the CLI to another service.`)
44
+ err.offline = true
45
+ return err
46
+ }
47
+
22
48
  export function service (config) {
23
- const base = serviceUrl(config) + '/api/v1'
49
+ const root = serviceUrl(config)
50
+ const base = root + '/api/v1'
24
51
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
25
52
  const token = process.env[tokenEnv]
26
- if (!token) throw new UserError(`Set ${tokenEnv} (create a token in Kaloko → Settings).`)
53
+ if (!token) throw new UserError(`${tokenEnv} is not set, so the CLI cannot reach your organization on ${root}. Run kaloko login, or create a token in Settings → API tokens and put ${tokenEnv}=… into .env.`)
54
+ const opts = { tokenEnv, url: root }
27
55
  // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
28
56
  const ci = ciName()
29
57
  const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
30
58
  const check = async (res, what) => {
31
59
  if (res.ok) return res.json()
32
60
  const body = await res.json().catch(() => ({}))
33
- throw new UserError(`${what} → HTTP ${res.status}: ${body.error || res.statusText}`)
61
+ throw httpError(what, res.status, body.error || res.statusText, opts)
34
62
  }
63
+ // fetch rejects without an answer (offline, refused, DNS): say so instead of a stack trace
64
+ const call = (send) => send().catch((e) => { if (e instanceof UserError) throw e; throw unreachable(root, e) })
35
65
  // the service limits requests per token (HTTP 429, nothing was processed): wait as told and try again
36
66
  // read-your-writes across requests: the service answers with a database bookmark the next request sends back
37
67
  let bookmark = null
@@ -46,18 +76,18 @@ export function service (config) {
46
76
  }
47
77
  return {
48
78
  async request (method, path, body) {
49
- const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
79
+ const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
50
80
  return check(res, `${method} ${path}`)
51
81
  },
52
82
  /** A request whose answer is not JSON (an export): the response, or an error with the service's message. */
53
83
  async raw (method, path, body) {
54
- const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
55
- if (!res.ok) { const b = await res.json().catch(() => ({})); throw new UserError(`${method} ${path} → HTTP ${res.status}: ${b.error || res.statusText}`) }
84
+ const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
85
+ if (!res.ok) { const b = await res.json().catch(() => ({})); throw httpError(`${method} ${path}`, res.status, b.error || res.statusText, opts) }
56
86
  return res
57
87
  },
58
88
  async fetch (path) {
59
- const res = await fetch(base + path, { headers })
60
- if (!res.ok) { const body = await res.json().catch(() => ({})); throw new UserError(`GET ${path} → HTTP ${res.status}: ${body.error || res.statusText}`) }
89
+ const res = await call(() => limited(() => fetch(base + path, { headers })))
90
+ if (!res.ok) { const body = await res.json().catch(() => ({})); throw httpError(`GET ${path}`, res.status, body.error || res.statusText, opts) }
61
91
  return res
62
92
  },
63
93
  async upload (path, data, name, extra = {}) {
@@ -65,7 +95,7 @@ export function service (config) {
65
95
  // a dropped connection (flaky Wi-Fi, CI runner hiccup) is tried again like a 5xx; the next share resumes otherwise
66
96
  let res
67
97
  try { res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data })) } catch (e) {
68
- if (attempt >= 3) throw new UserError(`upload ${name}: ${e.cause?.code || e.message}. Run kaloko share again: files already stored are not sent twice.`)
98
+ if (attempt >= 3) throw new UserError(`The upload of ${name} broke off (${e.cause?.code || e.message}). Check your network and run kaloko share again: files already stored are not sent twice.`)
69
99
  await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
70
100
  continue
71
101
  }
@@ -129,7 +129,7 @@ export async function detectUnstable ({ root, config, run, scenario, last = 4, r
129
129
  const others = runs ? (await Promise.all(runs.map(async (id) => {
130
130
  const dir = id.includes('/') ? resolve(id) : join(resolve(root, config.output), id)
131
131
  const meta = await readJson(join(dir, 'meta.json'), null)
132
- if (!meta) throw new UserError(`Run ${id} not found in ${config.output}.`)
132
+ if (!meta) throw new UserError(`Run ${id} is not in ${config.output}. kaloko runs lists the runs here.`)
133
133
  return { dir, meta }
134
134
  }))) : (await siblingRuns({ root, config, run, sameCommit })).slice(0, Math.max(0, last - 1))
135
135
  const all = [{ dir: run.dir, meta: run.meta }, ...others]
package/src/lib/stack.mjs CHANGED
@@ -66,7 +66,7 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
66
66
  if (first.index) { locs = []; for (const u of first.locs.slice(0, 50)) locs.push(...sitemapLocs(await get(u)).locs) }
67
67
  raw = locs.map((u) => ({ path: u }))
68
68
  } else if (src.crawl) {
69
- if (!page) throw new UserError('items.crawl needs a browser page.')
69
+ if (!page) throw new UserError('items.crawl needs the browser. Walk the step with kaloko walk (not offline), or list the pages with items.file or items.sitemap.')
70
70
  let url = toUrl(src.crawl.from)
71
71
  for (let i = 0; i < (src.crawl.pages || 1) && url; i++) {
72
72
  await page.goto(url, { waitUntil: 'domcontentloaded' })
@@ -129,3 +129,13 @@ export function readFields (specs) {
129
129
  }
130
130
  return out
131
131
  }
132
+
133
+ /** "about 4 min left" after `done` of `total` pages took `ms`; '' before the first page or after the last. */
134
+ export function timeLeft (done, total, ms) {
135
+ if (!(done > 0) || done >= total || !(ms >= 0)) return ''
136
+ const s = Math.round((ms / done) * (total - done) / 1000)
137
+ if (s < 60) return `about ${Math.max(5, Math.round(s / 5) * 5)} s left`
138
+ if (s < 3600) return `about ${Math.round(s / 60)} min left`
139
+ const h = Math.floor(s / 3600); const m = Math.round((s % 3600) / 60)
140
+ return `about ${h} h${m ? ` ${m} min` : ''} left`
141
+ }
package/src/lib/util.mjs CHANGED
@@ -67,6 +67,12 @@ export function globToRegExp (glob) {
67
67
 
68
68
  export class UserError extends Error {}
69
69
 
70
+ /** Every CLI error ends with a way to a person (docs/HARDENING.md phase 6, item 12). */
71
+ export const SUPPORT_EMAIL = 'support@sinfin.cz'
72
+ /** No `org:` in the config: where the slug comes from. */
73
+ export const NO_ORG = 'No organization in kaloko.config.yml. Add org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko login.'
74
+ export const SUPPORT_LINE = `Stuck? Write to ${SUPPORT_EMAIL} with the command you ran and this message.`
75
+
70
76
  /**
71
77
  * Tracks consecutive step failures per session. When `limit` steps in a row fail with the same first
72
78
  * error line, the remaining steps of that session are almost certainly failing on a shared
package/src/lib/video.mjs CHANGED
@@ -188,7 +188,12 @@ function install () {
188
188
  if (this.enc) {
189
189
  const f = new VideoFrame(this.canvas, { timestamp: Math.round(tk.t * 1000), duration: Math.round(1e6 / c.fps) })
190
190
  this.enc.encode(f, { keyFrame: this.n % c.keyEvery === 0 }); f.close(); this.n++
191
- while (this.enc.encodeQueueSize > 6) await new Promise((resolve) => setTimeout(resolve, 2))
191
+ // a stalled encoder (seen under heavy load) must not hold the walk: after 20 s the recording gives up
192
+ const until = Date.now() + 20000
193
+ while (this.enc && this.enc.encodeQueueSize > 6) {
194
+ if (Date.now() > until) { this.error = 'the video encoder stopped responding'; try { this.enc.close() } catch {} this.enc = null; break }
195
+ await new Promise((resolve) => setTimeout(resolve, 2))
196
+ }
192
197
  }
193
198
  if (tk.preview) {
194
199
  this.pg.drawImage(this.canvas, 0, 0, c.pw, c.ph)
@@ -199,7 +204,11 @@ function install () {
199
204
  return { ...out, chunks: this.chunks.splice(0), error: this.error }
200
205
  },
201
206
  async finish () {
202
- if (this.enc) { try { await this.enc.flush() } catch (e) { this.error = String(e?.message || e) } this.enc.close() }
207
+ if (this.enc) {
208
+ // flush has no time limit of its own: a stalled encoder ends the recording with an error instead of hanging
209
+ try { await Promise.race([this.enc.flush(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('the video encoder did not finish within 30 s')), 30000))]) } catch (e) { this.error = String(e?.message || e) }
210
+ try { this.enc.close() } catch {}
211
+ }
203
212
  for (const bm of this.bitmaps.values()) bm.close()
204
213
  this.bitmaps.clear()
205
214
  return { chunks: this.chunks.splice(0), error: this.error }
@@ -120,7 +120,7 @@ export function hostedVisionSender (config, report) {
120
120
  try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no vision key (ANTHROPIC_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
121
121
  try { return { response: await client.request('POST', `/orgs/${config.org}/semantic/vision`, payload) } } catch (e) {
122
122
  const m = String(e.message || e)
123
- if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
123
+ if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = e.serviceMessage ?? m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
124
124
  return { error: m }
125
125
  }
126
126
  }
package/viewer/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  @font-face{font-family:"Instrument Sans Fallback";src:local("Arial Bold"),local("Arial");font-weight:700;size-adjust:98.1%;ascent-override:99%;descent-override:25%;line-gap-override:0%}
15
15
  :root{
16
16
  --ground:#ecece8; --dots:#c9c9c3; --panel:#ffffff; --panel-2:#f5f5f2;
17
- --ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514;
17
+ --ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514; --field:#7a7a73;
18
18
  --accent:#ffe452; --accent-ink:#151514; --focus:#151514;
19
19
  --pass:#1d8249; --fail:#c8102e; --partial:#b86e00; --skip:#8d8d86;
20
20
  --pass-bg:#e3f3e9; --fail-bg:#fbe5e8; --partial-bg:#fbf0dc; --skip-bg:#ececea;
@@ -26,7 +26,7 @@
26
26
  @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
27
27
  color-scheme:dark;
28
28
  --ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
29
- --ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
29
+ --ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --field:#7d7d76; --line-strong:#f0f0ec;
30
30
  --pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
31
31
  --pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
32
32
  --pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
@@ -35,7 +35,7 @@
35
35
  :root[data-theme="dark"]{
36
36
  color-scheme:dark;
37
37
  --ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
38
- --ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
38
+ --ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --field:#7d7d76; --line-strong:#f0f0ec;
39
39
  --pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
40
40
  --pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
41
41
  --pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
@@ -124,6 +124,8 @@ svg.i{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stro
124
124
  .picker .kchip{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 6px;border-radius:4px;background:var(--accent);color:var(--accent-ink);flex:none}
125
125
  .picker .kchip--flow{background:var(--ink);color:var(--panel)}
126
126
  .picker kbd,.kbd{font-family:var(--mono);font-size:10.5px;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--muted);flex:none}
127
+ /* a key inside a button takes the button's text colour: grey on the yellow primary button is unreadable */
128
+ .btn .kbd{color:inherit}
127
129
  .tog{width:30px;height:30px;display:inline-grid;place-items:center;border-radius:6px;border:1px solid var(--line);background:var(--panel)}
128
130
  .tog[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
129
131
  .tog svg.i{width:16px;height:16px}
@@ -295,6 +297,9 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
295
297
 
296
298
  .toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:var(--ink);color:var(--panel);padding:8px 14px;border-radius:999px;font-size:13px;z-index:30;box-shadow:var(--shadow)}
297
299
  .sess-lost{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--panel);color:var(--ink);border:1px solid var(--fail);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:var(--shadow);width:max-content;max-width:calc(100vw - 32px)}
300
+ .net-bar{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:59;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--panel);color:var(--ink);border:1px solid var(--partial);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:var(--shadow);width:max-content;max-width:calc(100vw - 32px)}
301
+ .net-bar .note{color:var(--muted)}
302
+ .sess-lost ~ .net-bar{top:76px}
298
303
 
299
304
  @media (max-width:1100px){
300
305
  .main{grid-template-columns:minmax(0,1fr)}
@@ -784,7 +789,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
784
789
  .c-note{margin:4px 0 2px;font-size:13.5px;overflow-wrap:anywhere}.c-at{color:var(--ink);background:color-mix(in srgb,var(--accent,#ffe452) 45%,transparent);border-radius:3px;padding:0 2px;font-weight:600}
785
790
  .c-acts{display:flex;gap:10px;font-size:12px}.c-acts button,.linkish{all:unset;cursor:pointer;color:var(--muted);text-decoration:underline;text-underline-offset:3px}.c-acts button:hover,.linkish:hover{color:var(--ink)}
786
791
  .c-pin{all:unset;cursor:pointer;min-width:18px;height:18px;padding:0 4px;border-radius:9px 9px 9px 2px;background:#e4572e;color:#fff;font:600 10.5px/18px system-ui,sans-serif;text-align:center}
787
- .c-compose{margin-top:6px}.c-compose .c-text{width:100%;box-sizing:border-box;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:4px 0 8px;resize:vertical}
792
+ .c-compose{margin-top:6px}.c-compose .c-text{width:100%;box-sizing:border-box;font:inherit;padding:8px;border:1px solid var(--field,var(--line));border-radius:6px;background:var(--panel);color:var(--ink);margin:4px 0 8px;resize:vertical}
788
793
  .c-hint{margin-left:auto;font-size:11.5px}@media (hover:none) and (pointer:coarse){.c-hint{display:none}}.c-pinrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px}.c-pinned{display:inline-flex;gap:5px;align-items:center}.c-pinbtn[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
789
794
  .c-tabs{margin:0 0 10px}.cmt{display:inline-flex;align-items:center;gap:3px;font:600 11px/1 system-ui,sans-serif;color:var(--ink);padding:2px 6px;border-radius:999px;background:color-mix(in srgb,var(--partial) 22%,transparent)}.cmt .i{width:12px;height:12px}
790
795
  .lb__img.is-pinning,.lb__img.is-pinning img{cursor:crosshair}.pinmark.pin-xy{border:0;cursor:pointer;transform:translate(-2px,-100%)}.pinmark.pin-xy.is-done{background:#8a8a84}.pinmark.pin-xy.is-new{background:var(--ink)}.pinmark.flash{animation:pinflash 1s 2}
@@ -869,6 +874,10 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
869
874
  .rvm__side{overflow:auto;background:var(--panel);border-left:1px solid var(--line)}
870
875
  .rvm__foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 16px calc(10px + env(safe-area-inset-bottom));background:var(--panel);border-top:1px solid var(--line)}
871
876
  .rvm__foot .btn .kbd,.rvm__top .kbd{margin-left:4px}
877
+ .rvm__flash{flex:1 1 100%;margin:0;padding:8px 12px;border-radius:8px;font-size:14px;animation:rvflash .25s ease-out}
878
+ .rvm__flash--approved{background:var(--pass-bg);color:var(--pass-ink)}.rvm__flash--returned{background:var(--fail-bg);color:var(--fail-ink)}
879
+ @keyframes rvflash{from{opacity:0;transform:translateY(6px)}}
880
+ @media (prefers-reduced-motion:reduce){.rvm__flash{animation:none}}
872
881
  .rvm__why{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
873
882
  .rvm__dec{display:grid;gap:6px}
874
883
  .rvm__dec p{margin:0}
@@ -883,6 +892,8 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
883
892
  #rvBtn .rv-count{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 5px;margin-left:4px;border-radius:999px;background:var(--ink);color:var(--panel);font-size:11px;font-weight:600}
884
893
  .badge.rv-carry{margin-left:auto}
885
894
  @media (max-width:900px){.rvm__body{grid-template-columns:1fr;grid-template-rows:minmax(220px,55%) 1fr}.rvm__side{border-left:0;border-top:1px solid var(--line)}.rvm__stage{padding:12px}.rvm__bar{width:72px}.rvm__keys{display:none}}
895
+ /* a phone: the decisions sit in the bottom row, under the thumb (Approve on the right, wide), moving on above them */
896
+ @media (max-width:560px){.rvm__foot--act{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-template-areas:"flash flash flash" "prev cmt next" "ret ok ok"}.rvm__foot--act .rvm__flash{grid-area:flash}.rvm__foot--act #rvPrev{grid-area:prev}.rvm__foot--act #rvCmt{grid-area:cmt}.rvm__foot--act #rvNext{grid-area:next}.rvm__foot--act #rvRet{grid-area:ret}.rvm__foot--act #rvOk{grid-area:ok}.rvm__foot--act #rvRet,.rvm__foot--act #rvOk{min-height:52px;font-size:16px}.rvm__foot .btn{min-height:44px}.rvm__foot--act .btn{min-width:0}}
886
897
  @media (max-width:560px){.rvm__foot{gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom))}.rvm__foot .btn{flex:1 1 auto;justify-content:center;padding:9px 8px}.rvm__foot .spacer{display:none}.rvm__top{padding:8px 10px}.rvm__top .brand small{display:none}}
887
898
  @media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
888
899
  @media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}
@@ -1102,6 +1113,9 @@ Object.assign(I18N.cs, { devMode:'Dev mode', devTab:'Dev', devTitle:'Míry, toke
1102
1113
  Object.assign(I18N.en, { devMode:'Dev mode', devTab:'Dev', devTitle:'Measurements, tokens and states of elements for development', devTabEl:'Element', devTabTokens:'Tokens', devEmpty:'Hover an element in the preview and click it. Shift + arrows move through the element tree; hold Alt to see the distances to another element.', devStart:'Select the main content', devNavLabel:'Move through elements', navParent:'Parent', navChild:'Child', navPrev:'Previous', navNext:'Next', g_type:'Typography', g_color:'Colors', g_space:'Spacing', g_size:'Size', g_shape:'Shape', g_layout:'Layout', notToken:'not a token', nearest:'nearest', typedRaw:'The CSS has this value typed raw although a token exists; use the custom property.', typedRawShort:'typed raw', copyTokenPath:'Copy the token path', contrast:'Contrast', aaPass:'passes AA', aaFail:'fails AA', aaNeed:'needs', largeText:'large text', contrastApprox:'There is an image behind the text; the contrast is approximate.', states:'States', stDefault:'default', stNone:'The page styles define no states for this element.', changedInState:'Highlighted values change in this state.', distances:'Distances', toParent:'To the parent', toTarget:'To', measure:'Measure', measureHint:'Hold Alt and hover another element, or turn on Measure and tap it.', assets:'Images and icons', download:'Download', assetFail:'The file could not be downloaded.', copyCssVars:'Copy CSS with variables', copyTokens:'Copy tokens (JSON)', copyPrompt:'Copy prompt for an agent', copyLine:'Copy the prompt line for this value', pinFromDev:'Comment on this element', tokUsed:'Used on this step', tokOff:'Values outside the token set', tokHard:'Typed raw in the CSS instead of a token', tokUnused:'Not used', tokNone:'This step has no token set (tokens.json).', tokHlN:'Elements highlighted:', tokHint:'Click a token to highlight the elements that use it.', devShotNote:'Measurements and tokens come from the screenshot as the CLI captured it; states and image downloads need the live page.', devNoSpec:'This capture has no spec. Walk the step again with the current CLI (kaloko walk).', devLocalLive:'From disk, Dev mode reads the screenshot; the live page is measured on kaloko.app.', devAgentHint:'An agent gets the same: kaloko spec %N or MCP get_step_spec.', devLoading:'Loading…', keyDev:'Dev mode in the step detail: measurements, tokens and states of elements (M measures, Shift + arrows move through elements)', promptHead:'Build %E as approved in Kaloko (step %S).', promptUse:'Use token %T for the %W.', promptOff:'%P %V is not a token; the nearest is %T. Use the raw value only if the designer agrees.', promptOffNone:'%P %V is not a token.', promptSpec:'Spec of the whole step: kaloko spec %N' });
1103
1114
  Object.assign(I18N.cs, { sessionLost:'Přihlášení vypršelo. Přihlaste se znovu, rozepsaný text tu na vás počká.', signInAgain:'Přihlásit se znovu' });
1104
1115
  Object.assign(I18N.en, { sessionLost:'Your session has expired. Sign in again; what you were writing waits here.', signInAgain:'Sign in again' });
1116
+ // the connection drops or the service fails while the canvas is open (NET, netSend)
1117
+ Object.assign(I18N.cs, { netOffline:'Jste offline. Co napíšete, tu počká, než se připojení vrátí.', netOfflineN:(n) => `Jste offline. ${n === 1 ? '1 změna čeká' : n < 5 ? `${n} změny čekají` : `${n} změn čeká`} tady a odešle se, až se připojení vrátí.`, netFailedN:(n, why) => `Kaloko ${n === 1 ? '1 změnu' : n < 5 ? `${n} změny` : `${n} změn`} zatím neuložilo (${why}). Nic se neztratilo.`, netNoAnswer:'služba neodpovídá', netError:(c) => c === 429 ? 'příliš mnoho požadavků' : `chyba ${c}`, netAgainIn:(s) => `Zkusím to znovu za ${s} s.`, netRetry:'Zkusit znovu', netSending:'Odesílám…', netBack:'Připojení je zpět, všechno se odeslalo.', netLeave:'Některé změny ještě nejsou odeslané.' });
1118
+ Object.assign(I18N.en, { netOffline:'You are offline. What you write waits here until the connection is back.', netOfflineN:(n) => `You are offline. ${n === 1 ? '1 change waits here and goes' : `${n} changes wait here and go`} out when the connection is back.`, netFailedN:(n, why) => `Kaloko has not saved ${n === 1 ? '1 change' : `${n} changes`} yet (${why}). Nothing is lost.`, netNoAnswer:'the service does not answer', netError:(c) => c === 429 ? 'too many requests' : `error ${c}`, netAgainIn:(s) => `Trying again in ${s} s.`, netRetry:'Try again', netSending:'Sending…', netBack:'Back online; everything has been sent.', netLeave:'Some changes have not been sent yet.' });
1105
1119
  // back after signing in again: the session expired on the canvas (sessionLost), the server kept the path, but the
1106
1120
  // sign-in mail cannot carry the fragment (the open step, the comments), so the canvas stored it for this moment
1107
1121
  (() => { try { const r = JSON.parse(localStorage.getItem('qawalk-return') || 'null'); if (!r) return; localStorage.removeItem('qawalk-return'); if (r.path === location.pathname && r.hash && !location.hash && Date.now() - r.at < 864e5) history.replaceState(null, '', location.pathname + location.search + r.hash); } catch {} })();
@@ -1431,7 +1445,8 @@ function commentsBase (scope) {
1431
1445
  return scope === 'flow' ? `${a.api}/orgs/${a.org}/scenarios/${encodeURIComponent(RUN.scenario.id)}/comments` : `${a.api}/orgs/${a.org}/runs/${a.run}/comments`;
1432
1446
  }
1433
1447
  async function cApi (scope, path, method, body) {
1434
- const res = await fetch(commentsBase(scope) + path, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify({ ...body, ...(isDraft() ? { branch: RUN.draft.branch } : {}) }) : undefined });
1448
+ const init = { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify({ ...body, ...(isDraft() ? { branch: RUN.draft.branch } : {}) }) : undefined };
1449
+ const res = method === 'GET' ? await fetch(commentsBase(scope) + path, init) : await netSend(commentsBase(scope) + path, init, method === 'POST' && !path ? { find: commentSent(scope, body) } : {});
1435
1450
  if (res.status === 401) throw sessionLost();
1436
1451
  const data = await res.json().catch(() => ({}));
1437
1452
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
@@ -3780,13 +3795,88 @@ function sessionLost () {
3780
3795
  return new Error(T('sessionLost'));
3781
3796
  }
3782
3797
  async function apiJson (method, url, body) {
3783
- const res = await fetch(url, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
3798
+ const init = { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined };
3799
+ const res = method === 'GET' ? await fetch(url, init) : await netSend(url, init);
3784
3800
  if (res.status === 401) throw sessionLost();
3785
3801
  const data = await res.json().catch(() => ({}));
3786
3802
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
3787
3803
  return data;
3788
3804
  }
3789
3805
 
3806
+ /**
3807
+ * Writes survive a dropped connection. A request that cannot reach the service (offline, a dropped connection) or that
3808
+ * the service could not handle (5xx, 429, 408) waits in a queue the bar at the top names; it goes out again when the
3809
+ * browser is back online, on a timer (Retry-After when the service sends one) or on "Try again". The caller's promise
3810
+ * only settles with a real answer, so its button stays busy and nothing is lost silently; leaving the page while
3811
+ * something waits asks first. `find` looks for what the first attempt may already have created (a new comment), so a
3812
+ * request that did arrive is not sent twice.
3813
+ */
3814
+ const NET = { pending: [], timer: null, tick: null, was: false };
3815
+ const netTrouble = (res) => !res || res.status >= 500 || res.status === 429 || res.status === 408;
3816
+ async function netSend (url, init, { find } = {}) {
3817
+ const started = Date.now();
3818
+ for (let attempt = 0; ; attempt++) {
3819
+ if (attempt && find) {
3820
+ let hit;
3821
+ try { hit = await find(started); } catch { hit = undefined; }
3822
+ if (hit) return new Response(JSON.stringify(hit), { status: 200, headers: { 'Content-Type': 'application/json' } });
3823
+ if (hit === undefined) { await netWait({ status: 0, attempt }); continue; } // cannot look yet: wait again
3824
+ }
3825
+ let res = null;
3826
+ if (navigator.onLine !== false) { try { res = await fetch(url, init); } catch { res = null; } }
3827
+ if (!netTrouble(res)) { if (NET.was && !NET.pending.length) { NET.was = false; toast(T('netBack')); } return res; }
3828
+ const after = Number(res?.headers.get('retry-after'));
3829
+ await netWait({ status: res?.status || 0, attempt, after: Number.isFinite(after) && after > 0 ? Math.min(after, 300) : 0 });
3830
+ }
3831
+ }
3832
+ function netWait (entry) {
3833
+ return new Promise((resolve) => {
3834
+ entry.go = () => { NET.pending = NET.pending.filter((x) => x !== entry); resolve(); };
3835
+ // the service failed or did not answer: try again by itself a few times (3, 10, 30 s), then wait for a person
3836
+ const delay = entry.after ? entry.after * 1000 : [3000, 10000, 30000][entry.attempt];
3837
+ entry.at = navigator.onLine === false || !delay ? 0 : Date.now() + delay;
3838
+ NET.pending.push(entry); NET.was = true;
3839
+ netBar();
3840
+ });
3841
+ }
3842
+ /** Sends everything waiting now (back online, "Try again", or a timer that came due). */
3843
+ function netFlush (only) {
3844
+ const due = NET.pending.filter((x) => !only || only(x));
3845
+ for (const x of due) x.go();
3846
+ netBar();
3847
+ }
3848
+ function netBar () {
3849
+ clearTimeout(NET.timer); clearInterval(NET.tick); NET.tick = null;
3850
+ const offline = navigator.onLine === false;
3851
+ const n = NET.pending.length;
3852
+ let bar = $('netBar');
3853
+ if (!n && !offline) { bar?.remove(); return; }
3854
+ if (!bar) { document.body.insertAdjacentHTML('beforeend', '<div id="netBar" class="net-bar" role="status" aria-live="polite"><span class="net-bar__msg"></span><span class="note net-bar__when"></span><button type="button" class="btn net-bar__retry"></button></div>'); bar = $('netBar'); bar.querySelector('.net-bar__retry').onclick = () => { bar.querySelector('.net-bar__when').textContent = T('netSending'); netFlush(); }; }
3855
+ const last = NET.pending.at(-1);
3856
+ bar.querySelector('.net-bar__msg').textContent = offline ? (n ? T('netOfflineN')(n) : T('netOffline')) : T('netFailedN')(n, last?.status ? T('netError')(last.status) : T('netNoAnswer'));
3857
+ const btn = bar.querySelector('.net-bar__retry'); btn.textContent = T('netRetry'); btn.hidden = !n;
3858
+ const next = offline ? 0 : Math.min(...NET.pending.map((x) => x.at || Infinity));
3859
+ const when = bar.querySelector('.net-bar__when');
3860
+ const show = () => { const s = Math.max(1, Math.ceil((next - Date.now()) / 1000)); when.textContent = T('netAgainIn')(s); };
3861
+ if (Number.isFinite(next) && next > 0) {
3862
+ show(); NET.tick = setInterval(show, 1000);
3863
+ NET.timer = setTimeout(() => netFlush((x) => x.at && x.at <= Date.now() + 50), Math.max(0, next - Date.now()));
3864
+ } else when.textContent = '';
3865
+ }
3866
+ addEventListener('online', () => { if (NET.pending.length) netFlush(); else netBar(); });
3867
+ addEventListener('offline', () => { if (APP()) netBar(); });
3868
+ addEventListener('beforeunload', (e) => { if (NET.pending.length) { e.preventDefault(); e.returnValue = T('netLeave'); return T('netLeave'); } });
3869
+ /** A new comment the first attempt may have created already: same step, text, status and thread, by this person. */
3870
+ function commentSent (scope, body) {
3871
+ return async (since) => {
3872
+ const me = APP()?.user?.email;
3873
+ const res = await fetch(commentsBase(scope) + (isDraft() ? branchQ() : ''), { credentials: 'same-origin' });
3874
+ if (!res.ok) return undefined;
3875
+ const list = await res.json();
3876
+ return (Array.isArray(list) ? list : []).find((c) => c.node_id === body.node_id && (c.note || '') === (body.note || '') && (c.status || 'comment') === (body.status || 'comment') && (c.parent_id || null) === (body.parent_id || null) && (!me || c.email === me) && Date.parse(c.created_at) >= since - 120000) || null;
3877
+ };
3878
+ }
3879
+
3790
3880
  // the draft is live: re-read it when someone (a person or an agent) changes it
3791
3881
  let reloadTimer = null;
3792
3882
  function reloadDraftSoon () { clearTimeout(reloadTimer); reloadTimer = setTimeout(reloadDraft, 350); }
@@ -4784,7 +4874,7 @@ Object.assign(I18N.cs, {
4784
4874
  rvCarriedWhy: 'Snímky i kritéria jsou stejné jako v přijatém běhu a automatické kontroly prošly.',
4785
4875
  rvRevoke: 'Zrušit převzaté schválení', rvRevoked: 'Převzaté schválení je zrušené; krok čeká na kontrolu.',
4786
4876
  rvUnconfirmed: 'Rozhodnutí agenta nebo tokenu; za rozhodnutí člověka se nepočítá', rvChangedIn: 'Změněné snímky', rvCritChanged: 'Kritéria se od přijatého běhu změnila.',
4787
- rvApprovedToast: 'Schváleno', rvReturnedToast: 'Vráceno autorovi běhu', rvReadOnly: 'Jen ke čtení', rvLocal: 'Rozhodovat se dá na kaloko.app, až se běh nasdílí.',
4877
+ rvApprovedToast: 'Schváleno', rvFlashNext: 'další:', rvReturnedToast: 'Vráceno autorovi běhu', rvReadOnly: 'Jen ke čtení', rvLocal: 'Rozhodovat se dá na kaloko.app, až se běh nasdílí.',
4788
4878
  rvDone: 'Všechny kroky jsou rozhodnuté', rvLeft: (n) => `Na rozhodnutí ${n === 1 ? 'čeká 1 krok' : n < 5 ? `čekají ${n} kroky` : `čeká ${n} kroků`}`,
4789
4879
  rvNothing: 'Nic nečeká na člověka: kroky prošly a od přijatého běhu se nezměnily.', rvSumApproved: 'schváleno', rvSumReturned: 'vráceno', rvSumClear: 'beze změny a bez připomínek',
4790
4880
  rvCarriedTitle: 'Převzatá schválení', rvCarriedLead: 'Tyto kroky vypadají stejně jako v přijatém běhu a jejich automatické kontroly prošly. U každého je, kdo ho schválil.',
@@ -4805,14 +4895,14 @@ Object.assign(I18N.en, {
4805
4895
  rvCarriedWhy: 'The screenshots and criteria are the same as in the accepted run and the automatic checks passed.',
4806
4896
  rvRevoke: 'Revoke the carried approval', rvRevoked: 'Carried approval revoked; the step waits for review.',
4807
4897
  rvUnconfirmed: 'An agent’s or token’s decision; it does not count as a person’s', rvChangedIn: 'Changed screenshots', rvCritChanged: 'The criteria changed since the accepted run.',
4808
- rvApprovedToast: 'Approved', rvReturnedToast: 'Returned to the author of the run', rvReadOnly: 'Read only', rvLocal: 'Decisions are made on kaloko.app once the run is shared.',
4898
+ rvApprovedToast: 'Approved', rvFlashNext: 'next:', rvReturnedToast: 'Returned to the author of the run', rvReadOnly: 'Read only', rvLocal: 'Decisions are made on kaloko.app once the run is shared.',
4809
4899
  rvDone: 'Every step is decided', rvLeft: (n) => `${n} step${n === 1 ? '' : 's'} still wait${n === 1 ? 's' : ''} for a decision`,
4810
4900
  rvNothing: 'Nothing waits for a person: the steps passed and did not change since the accepted run.', rvSumApproved: 'approved', rvSumReturned: 'returned', rvSumClear: 'unchanged, nothing to look at',
4811
4901
  rvCarriedTitle: 'Carried-over approvals', rvCarriedLead: 'These steps look the same as in the accepted run and their automatic checks passed. Each says who approved it.',
4812
4902
  rvShow: 'Show', rvVerdictLead: 'Carried approvals do not count as accepting the run: a person accepts the run as a whole.',
4813
4903
  rvReturnedHint: 'The author of the run sees returned steps in the inbox, the agent in kaloko feedback.', rvCarriedShort: 'carried over', rvToEnd: 'Summary', rvHint: 'review step by step', rvQueue: 'Steps to review', rvRefFailed: 'The accepted run could not be loaded.', rvComparedWith: 'Compared with the accepted run'
4814
4904
  })
4815
- const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [] };
4905
+ const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [], flash: null };
4816
4906
  const rvService = () => !!(APP()?.user && APP()?.run && !isDraft());
4817
4907
  const rvWritable = () => rvService() && canWrite();
4818
4908
  const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
@@ -4973,7 +5063,9 @@ function rvFootHtml (it) {
4973
5063
  const items = rvItems();
4974
5064
  const act = it && rvWritable() ? `<button class="btn" id="rvRet">${esc(T('rvReturn'))} <span class="kbd">R</span></button><button class="btn" id="rvCmt">${ICON.comment} ${esc(T('comment'))} <span class="kbd">C</span></button><button class="btn btn--primary" id="rvOk">${esc(T('rvApprove'))} <span class="kbd">A</span></button>`
4975
5065
  : it ? `<span class="note">${esc(T(rvService() ? 'rvReadOnly' : 'rvLocal'))}</span>` : '';
4976
- return `<div class="rvm__foot"><button class="btn" id="rvPrev" ${RV.idx <= 0 ? 'disabled' : ''}>${ICON.prev} ${esc(T('rvPrev'))} <span class="kbd">K</span></button><span class="spacer"></span>${act}${RV.idx < items.length ? `<button class="btn" id="rvNext">${esc(RV.idx === items.length - 1 ? T('rvToEnd') : T('rvNext'))} ${ICON.next} <span class="kbd">J</span></button>` : ''}</div>`;
5066
+ const f = RV.flash && Date.now() - RV.flash.at < 4000 ? RV.flash : null;
5067
+ const flash = f ? `<p class="rvm__flash rvm__flash--${f.state}" id="rvFlash" role="status">${f.state === 'approved' ? '✓' : '↩'} ${esc(T(f.state === 'approved' ? 'rvApprovedToast' : 'rvReturnedToast'))}: <b>${esc(f.title)}</b>${it ? ` · ${esc(T('rvFlashNext'))} ${esc(loc(byId(it.step)?.title) || it.step)}` : ''}</p>` : '';
5068
+ return `<div class="rvm__foot${it && rvWritable() ? ' rvm__foot--act' : ''}">${flash}<button class="btn" id="rvPrev" ${RV.idx <= 0 ? 'disabled' : ''}>${ICON.prev} ${esc(T('rvPrev'))} <span class="kbd">K</span></button><span class="spacer"></span>${act}${RV.idx < items.length ? `<button class="btn" id="rvNext">${esc(RV.idx === items.length - 1 ? T('rvToEnd') : T('rvNext'))} ${ICON.next} <span class="kbd">J</span></button>` : ''}</div>`;
4977
5069
  }
4978
5070
  function rvEndHtml () {
4979
5071
  const d = RV.data, c = d.counts || {}, items = d.items || [];
@@ -5074,10 +5166,16 @@ async function rvApprove () {
5074
5166
  // the person's look answers what the evaluator could not decide: open criteria pass, then the step is approved
5075
5167
  if (n.kind !== 'stack') for (const c of (n.criteria || []).filter((x) => x.type !== 'deterministic' && rvOpenCrit(x))) await postJson('/criteria', { node_id: n.id, criterion_id: c.id, verdict: 'pass', note: '' });
5076
5168
  await cApi('step', '', 'POST', { node_id: n.id, status: 'approved', note: '' });
5077
- toast(T('rvApprovedToast'));
5169
+ rvFlash('approved', n);
5078
5170
  await rvAfter(it.step);
5079
5171
  } catch (e) { toast(e.message); if (b) b.disabled = false; } finally { RV.busy = false; }
5080
5172
  }
5173
+ /** Feedback after a decision that stays readable on a phone: a line above the buttons (with the next step), a short buzz. */
5174
+ function rvFlash (state, n) {
5175
+ RV.flash = { state, title: loc(n?.title) || '', at: Date.now() };
5176
+ try { if (lbPhone()) navigator.vibrate?.(state === 'approved' ? 15 : [15, 60, 15]); } catch {}
5177
+ clearTimeout(RV.flashT); RV.flashT = setTimeout(() => { RV.flash = null; $('rvFlash')?.remove(); }, 4000);
5178
+ }
5081
5179
  function rvReturn () { if (!rvCur() || !rvWritable()) return; RV.ret = true; rvRender(); }
5082
5180
  async function rvReturnSend () {
5083
5181
  const it = rvCur(); const t = $('rvRetNote'); if (!it || !t || RV.busy) return;
@@ -5087,7 +5185,7 @@ async function rvReturnSend () {
5087
5185
  try {
5088
5186
  await cApi('step', '', 'POST', { node_id: it.step, status: 'rejected', note, locale: S.loc, viewport: RV.vp || null });
5089
5187
  saveDraft(draftKey('step', it.step, 'return'), '');
5090
- toast(T('rvReturnedToast'));
5188
+ rvFlash('returned', byId(it.step) || { title: it.title });
5091
5189
  await rvAfter(it.step);
5092
5190
  } catch (e) { toast(e.message); $('rvRetSend') && ($('rvRetSend').disabled = false); } finally { RV.busy = false; }
5093
5191
  }
@@ -5186,7 +5284,7 @@ function applyReviewerVerdicts () {
5186
5284
  }
5187
5285
  async function postJson (path, body) {
5188
5286
  const a = APP();
5189
- const res = await fetch(`${a.api}/orgs/${a.org}/runs/${a.run}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
5287
+ const res = await netSend(`${a.api}/orgs/${a.org}/runs/${a.run}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
5190
5288
  if (res.status === 401) throw sessionLost();
5191
5289
  if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
5192
5290
  return res.json();