File size: 26,191 Bytes
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4042a59
9cee884
 
4042a59
9cee884
 
 
39181ba
 
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
9cee884
 
 
 
 
 
 
 
 
4042a59
 
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
9cee884
 
39181ba
9cee884
 
 
39181ba
 
4042a59
39181ba
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
 
 
 
 
 
 
 
 
 
 
 
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
9cee884
 
 
 
39181ba
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
 
 
 
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
9cee884
 
4042a59
39181ba
9cee884
 
 
 
 
 
4042a59
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
9cee884
 
 
 
 
4042a59
 
 
 
 
 
 
 
39181ba
9cee884
 
39181ba
 
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
 
 
9cee884
39181ba
9cee884
 
 
 
 
 
 
 
 
 
 
 
39181ba
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
# Server: the wiring of the page. The logic is in the other files of R/; this
# file keeps the state of a session (the question, the data, the answers and
# the spend) and runs the calls to Jev in chunks, as a background task.
#
# app_deps() holds what a test replaces: decide(), the usage store, the status
# reader, the clock, and `async` (FALSE runs the chunks in this R process).
# app.R makes it once for all sessions, so its `state` (the stop for the UTC
# day after a key or credit error) holds for every visitor.

# The dependencies of the server. With SPACE_MOCK=1 (local checks only; the
# Dockerfile never sets it), the mock of decide() and a store in memory
# replace the defaults.
app_deps <- function(decide_fn = decisions::decide, store = usage_store_hf(),
                     status_fn = read_status, now = Sys.time, async = TRUE) {
  if (Sys.getenv("SPACE_MOCK") == "1" && (missing(decide_fn) || missing(store))) {
    mock <- mock_deps()
    if (missing(decide_fn)) decide_fn <- mock$decide_fn
    if (missing(store)) store <- mock$store
  }
  list(
    decide_fn = decide_fn, store = store, status_fn = status_fn, now = now,
    async = async, state = new.env(parent = emptyenv())
  )
}

# The mock of the tests (with the saved answers of both samples) and a store in
# memory. The mock stays in tests/: the app code holds no mock. APP_ROOT is the
# app folder (set by app.R, and by the helper of the tests).
mock_deps <- function(path = file.path(APP_ROOT, "tests", "testthat", "helper-mock.R")) {
  if (!file.exists(path)) {
    stop(
      "SPACE_MOCK=1 needs ", path,
      ". Start the app from the repository folder, or unset SPACE_MOCK.",
      call. = FALSE
    )
  }
  env <- new.env(parent = environment(mock_deps))
  sys.source(path, envir = env)
  lookup <- dplyr::bind_rows(env$sample_lookup("textiles"), env$sample_lookup("injuries"))
  list(decide_fn = env$mock_decide(lookup), store = usage_store_memory())
}

# The category that stops the runs today, or NULL
stop_reason <- function(deps) {
  if (identical(deps$state$stop_day, utc_date(deps$now()))) deps$state$stop_reason
}

# The accounts of a chunk, in the main R process (never in a daemon): add the
# usage to the store, stop the day after a key or credit error, and write the
# log line. Rows that failed before any request ("config") cost nothing.
account_chunk <- function(answers, deps) {
  categories <- classify_error(answers$.error)
  use <- chunk_usage(answers[!categories %in% "config", ])
  now <- deps$now()
  usage <- if (use$calls > 0) usage_record(deps$store, now, use$calls, use$tokens, use$cost)
  stop <- dplyr::first(categories[stops_the_day(categories)])
  if (!is.na(stop)) {
    deps$state$stop_day <- utc_date(now)
    deps$state$stop_reason <- stop
  }
  log_chunk(now, nrow(answers), use, categories)
  list(answers = answers, cost = use$cost, usage = usage)
}

app_server <- function(input, output, session, deps = app_deps()) {
  # --- The folder of the session: uploads, imports and thumbnails -----------
  dir <- tempfile("session")
  dir.create(file.path(dir, "thumbs"), recursive = TRUE)
  # The thumbnails are served under a prefix that no other visitor can guess
  prefix <- paste(openssl::rand_bytes(16), collapse = "")
  shiny::addResourcePath(prefix, file.path(dir, "thumbs"))
  uploads <- character()
  folders <- 0L
  new_dir <- function(kind) {
    folders <<- folders + 1L
    file.path(dir, kind, folders)
  }
  keep_uploads <- function(files) uploads <<- c(uploads, files$datapath)
  job <- new.env(parent = emptyenv())
  # Files are deleted only when no chunk is in flight: a chunk reads its images
  # and references until it ends, and a missing file would stop the day
  after_chunk <- function(delete) {
    if (isTRUE(job$in_flight)) job$deletes <- c(job$deletes, delete) else delete()
  }
  session$onSessionEnded(\() {
    shiny::removeResourcePath(prefix)
    remove_uploads(uploads)
    after_chunk(\() unlink(dir, recursive = TRUE))
  })

  # The thumbnail of an image, and its address ("" for an image that cannot be read)
  thumb_file <- function(path) file.path(dir, "thumbs", paste0(rlang::hash(path), ".jpg"))
  thumb <- function(paths) {
    purrr::map_chr(paths, \(path) {
      dest <- thumb_file(path)
      if (file.exists(dest) || make_thumb(path, dest)) paste0(prefix, "/", basename(dest)) else ""
    })
  }

  # --- State --------------------------------------------------------------------
  question <- shiny::reactiveVal(START_QUESTION)
  refs <- shiny::reactiveVal(list()) # reference paths, one element for each answer
  data_mode <- shiny::reactiveVal("image")
  data_source <- shiny::reactiveVal("upload") # the page opens empty
  image_upload <- shiny::reactiveVal(NULL)
  csv_file <- shiny::reactiveVal(NULL)
  text_kind <- shiny::reactiveVal("paste")
  # list(answers, key, saved, token of the run, rows of Try on 5 or NULL for all)
  results <- shiny::reactiveVal(NULL)
  mean_tokens <- shiny::reactiveVal(NULL) # list(fingerprint, mean)
  session_cost <- shiny::reactiveVal(0)
  usage <- shiny::reactiveVal(deps$store$read())
  notice <- shiny::reactiveVal(character())
  running <- shiny::reactiveVal(FALSE)
  rows_version <- shiny::reactiveVal(0) # renders the answer rows again

  # --- The spec of the sidebar --------------------------------------------------
  text_upload <- shiny::reactive({
    csv <- csv_file()
    if (text_kind() == "paste" || is.null(csv)) {
      return(c(read_text_paste(input$paste_text), list(name = "texts")))
    }
    if (length(csv$columns) == 0) {
      return(list(
        inputs = empty_inputs(), problems = "The CSV file could not be read.", name = csv$name
      ))
    }
    column <- if (isTRUE(input$csv_text_col %in% csv$columns)) input$csv_text_col else csv$text_col
    label <- if (isTRUE(input$csv_label_col %in% csv$columns)) input$csv_label_col
    c(read_text_csv(csv$path, column, label, csv$file), list(name = csv$name, text_column = column))
  })
  data <- shiny::reactive({
    id <- data_source()
    if (id != "upload") return(list(inputs = sample_data(id)$spec$inputs, name = id))
    upload <- if (data_mode() == "image") image_upload() else text_upload()
    upload %||% list(inputs = empty_inputs(), name = "upload")
  })
  matched <- shiny::reactive(match_labels(data()$inputs, question()$answers))
  spec <- shiny::reactive({
    q <- question()
    references <- if (data_mode() == "image" && q$type != "score") {
      stats::setNames(refs()[seq_along(q$answers)], names(q$answers)) |> purrr::compact()
    }
    list(
      mode = data_mode(), question = q,
      references = if (length(references) > 0) references,
      inputs = matched()$inputs, source = data_source(), name = data()$name,
      text_column = data()$text_column
    )
  })
  key <- shiny::reactive(answers_key(spec()))
  # The type and the names of the answers: the reference rows and the target follow them
  layout <- shiny::reactiveVal()
  shiny::observe(layout(list(type = question()$type, answers = names(question()$answers))))

  # Send the setup of the server to the inputs of the page. The page answers
  # each update with the new value, so an observer never sends its own input
  # (or one above it: mode, then source, then the question) back: two crossing
  # updates would flip the input forever. `send` names the inputs of the data.
  show_setup <- function(send = c("mode", "source")) {
    q <- question()
    if ("mode" %in% send) shiny::updateRadioButtons(session, "mode", selected = data_mode())
    if ("source" %in% send) {
      shiny::updateSelectInput(
        session, "source", choices = source_choices(data_mode()), selected = data_source()
      )
    }
    shiny::updateRadioButtons(session, "qtype", selected = q$type)
    shiny::updateTextAreaInput(session, "instructions", value = q$instructions)
    shiny::updateRadioButtons(
      session, "view", selected = if (data_mode() == "image") "gallery" else "table"
    )
    rows_version(rows_version() + 1)
  }

  # A sample opens with no answers and makes no call. A run of the unchanged
  # sample then shows its saved run (see start_run()).
  load_sample <- function(id, send = c("mode", "source")) {
    sample <- sample_data(id)
    question(sample$spec$question)
    refs(unname(sample$spec$references) %||% list())
    data_mode(sample$spec$mode)
    data_source(id)
    notice(character())
    show_setup(send)
  }
  # The page opens empty: Images, your own data, no sample. The first HTML of
  # the page holds the same values, so the server sends no update at the start.

  # --- Data -------------------------------------------------------------------
  shiny::observeEvent(input$sample_textiles, load_sample("textiles"))
  shiny::observeEvent(input$sample_injuries, load_sample("injuries"))
  shiny::observeEvent(input$mode, {
    if (input$mode == data_mode()) return()
    if (data_source() != "upload") {
      return(load_sample(if (input$mode == "image") "textiles" else "injuries", send = "source"))
    }
    data_mode(input$mode)
    refs(list())
    notice(character())
    show_setup(send = "source")
  })
  shiny::observeEvent(input$source, {
    # A source of the other mode is the late answer to an older update
    if (input$source == data_source() || !input$source %in% source_choices(data_mode())) return()
    if (input$source != "upload") return(load_sample(input$source, send = character()))
    data_source("upload")
    refs(list())
    notice(character())
  })
  shiny::observeEvent(input$upload_images, {
    files <- input$upload_images
    keep_uploads(files)
    notice(character())
    old <- image_upload()
    if (!is.null(old)) {
      after_chunk(\() unlink(c(old$dir, thumb_file(old$inputs$path)), recursive = TRUE))
    }
    read <- read_images(files, dir = new_dir("images"))
    bad <- thumb(read$inputs$path) == ""
    image_upload(list(
      inputs = read$inputs[!bad, ], dir = read$dir,
      problems = c(
        read$problems, sprintf("%s could not be read and was skipped.", read$inputs$file[bad])
      ),
      name = if (nrow(files) == 1) tools::file_path_sans_ext(files$name) else "images"
    ))
  })
  shiny::observeEvent(input$paste_text, text_kind("paste"), ignoreInit = TRUE)
  shiny::observeEvent(input$upload_csv, {
    file <- input$upload_csv
    keep_uploads(file)
    notice(character())
    path <- file.path(new_dir("csv"), "data.csv")
    dir.create(dirname(path), recursive = TRUE)
    file.copy(file$datapath, path)
    columns <- csv_columns(path)
    text_col <- if ("text" %in% columns) "text" else columns[1]
    csv_file(list(
      path = path, file = file$name, name = tools::file_path_sans_ext(file$name),
      columns = columns, text_col = text_col
    ))
    text_kind("csv")
    shiny::updateSelectInput(session, "csv_text_col", choices = columns, selected = text_col)
    shiny::updateSelectInput(
      session, "csv_label_col", choices = c(None = "", columns), selected = ""
    )
  })

  # --- Question ---------------------------------------------------------------
  # Each input changes one part of the question. A change that the server made
  # comes back from the page with the same value, so it changes nothing.
  shiny::observeEvent(input$instructions, {
    q <- question()
    q$instructions <- input$instructions
    question(q)
  })
  edit_answer <- function(i, part, value) {
    q <- question()
    if (i > length(q$answers) || (part == "name" && q$type == "noul")) return()
    if (part == "name") names(q$answers)[i] <- value else q$answers[[i]] <- value
    question(q)
  }
  purrr::walk(seq_len(max(unlist(ANSWER_RANGE))), \(i) {
    shiny::observeEvent(input[[paste0("answer_name_", i)]], {
      edit_answer(i, "name", input[[paste0("answer_name_", i)]])
    })
    shiny::observeEvent(input[[paste0("answer_desc_", i)]], {
      edit_answer(i, "desc", input[[paste0("answer_desc_", i)]])
    })
    shiny::observeEvent(input[[paste0("answer_remove_", i)]], {
      q <- question()
      if (i > length(q$answers) || length(q$answers) <= answer_range(q$type)[1]) return()
      q$answers <- q$answers[-i]
      question(q)
      refs(refs()[-i])
      rows_version(rows_version() + 1)
    })
  })
  shiny::observeEvent(input$answer_add, {
    q <- question()
    n <- length(q$answers)
    if (n >= answer_range(q$type)[2]) return()
    name <- utils::tail(make.unique(c(names(q$answers), paste("answer", n + 1)), sep = " "), 1)
    q$answers <- c(q$answers, stats::setNames("", name))
    question(q)
    rows_version(rows_version() + 1)
  })
  shiny::observeEvent(input$qtype, {
    q <- question()
    if (input$qtype == q$type) return()
    q$answers <- if (input$qtype == "noul") {
      c(true = "", false = "")
    } else {
      utils::head(q$answers, answer_range(input$qtype)[2])
    }
    q$type <- input$qtype
    question(q)
    rows_version(rows_version() + 1)
  })
  # The reference inputs: a new upload replaces the references of its answer
  seen <- character()
  shiny::observe({
    ids <- ref_input_id(shiny::req(layout())$answers)
    for (i in seq_along(ids)) {
      files <- input[[ids[i]]]
      if (is.null(files) || all(files$datapath %in% seen)) next
      seen <<- c(seen, files$datapath)
      keep_uploads(files)
      read <- read_references(files, dir = new_dir("references"))
      shiny::isolate({
        r <- refs()
        r[[i]] <- read$paths
        refs(r)
        notice(read$problems)
      })
    }
  })
  # Import restores the question and the references; it makes no call
  shiny::observeEvent(input$import_file, {
    file <- input$import_file
    keep_uploads(file)
    read <- read_import(file$datapath, dir = new_dir("import"))
    if (is.null(read$question)) return(notice(read$problems))
    question(read$question)
    refs(purrr::map(names(read$question$answers), \(a) read$references[[a]] %||% character()))
    if (length(read$references) > 0 && data_mode() == "text") {
      data_mode("image")
      data_source("upload")
    }
    notice(c("The setup is imported.", read$problems))
    show_setup()
  })
  shiny::observe({
    l <- shiny::req(layout())
    shiny::updateSelectInput(
      session, "target", choices = if (l$type == "noul") "true" else l$answers,
      selected = target_answer(shiny::isolate(question()), shiny::isolate(input$target))
    )
  })

  # The answers show only while the setup and the inputs are those of the
  # answers (table() checks the key). They are not deleted: the page can answer
  # an older update late, and the setup then comes back. A change stops a run.
  shiny::observe({
    k <- key()
    if (shiny::isolate(running()) && !identical(job$key, k)) job$stop <- TRUE
  })

  # --- Runs -------------------------------------------------------------------
  mean_for <- function(s) {
    m <- mean_tokens()
    if (!is.null(m) && identical(m$fingerprint, spec_fingerprint(s))) m$mean
  }
  # A run of an unchanged sample shows its saved run: it costs nothing, so no
  # limit stops it
  checks <- shiny::reactive({
    s <- spec()
    saved <- sample_unchanged(s)
    status <- spend_status(session_cost(), usage(), deps$now())
    purrr::map(c(try5 = 5, run_all = Inf), \(k) {
      n <- min(k, nrow(s$inputs))
      cost <- if (saved) 0 else estimate_cost(s, n, mean_for(s))
      allowed <- if (saved) list(ok = TRUE, reason = NA_character_) else run_allowed(cost, status)
      # No inputs: the buttons stay off, and no limit is the reason
      list(ok = allowed$ok && n > 0, reason = allowed$reason, n = n, cost = cost, saved = saved)
    })
  })
  shiny::observe({
    shiny::updateActionButton(session, "try5", disabled = !checks()$try5$ok)
    shiny::updateActionButton(session, "run_all", disabled = !checks()$run_all$ok)
  })

  # Each chunk runs in a mirai daemon (or here, when async is FALSE); its
  # accounts are kept in this process, also when the session has ended. The
  # files that waited for the chunk are deleted when it ends.
  task <- shiny::ExtendedTask$new(function(spec, rows) {
    job$in_flight <- TRUE
    chunk <- tryCatch(
      if (deps$async) {
        mirai::mirai(
          run_chunk(spec, rows, decide_fn),
          .args = list(spec = spec, rows = rows, decide_fn = deps$decide_fn)
        )
      } else {
        run_chunk(spec, rows, deps$decide_fn)
      },
      error = \(e) promises::promise_reject(e)
    )
    promises::then(promises::promise_resolve(chunk), \(answers) account_chunk(answers, deps)) |>
      promises::finally(\() {
        job$in_flight <- FALSE
        deletes <- job$deletes
        job$deletes <- NULL
        purrr::walk(deletes, \(delete) delete())
      }) |>
      # A closed session has no task to finish (it would fail on its destroyed
      # values): its chunk was counted above, and the promise stays open
      promises::then(
        onFulfilled = \(done) if (session$isClosed()) never_settles() else done,
        onRejected = \(e) if (session$isClosed()) never_settles() else stop(e)
      )
  })
  never_settles <- function() promises::promise(\(resolve, reject) NULL)
  # Before each chunk, its images and references must exist. A missing file
  # ends the run; decide() would abort, and that would stop the day.
  run_next <- function() {
    rows <- job$chunks[[job$done + 1L]]
    if (!all(file.exists(chunk_files(job$spec, rows)))) {
      notice("The files of this run are not available. Upload them again.")
      return(end_run())
    }
    task$invoke(job$spec, rows)
  }

  start_run <- function(n) {
    if (running()) return()
    s <- spec()
    n <- min(n, nrow(s$inputs))
    # Try on 5 takes the rows of try_rows(); Run all takes all rows (try_rows()
    # gives all rows when n is the number of inputs). The R code repeats the
    # rows of Try on 5 (`subset`).
    rows <- try_rows(s$inputs, n)
    subset <- if (n < nrow(s$inputs)) rows
    # An unchanged sample shows the saved answers of these rows at once: no
    # call, no cost and no write to the usage store
    if (sample_unchanged(s)) {
      notice(character())
      saved <- sample_data(s$source)$answers[rows, ]
      return(results(list(answers = saved, key = key(), saved = TRUE, rows = subset)))
    }
    problems <- run_problems(s)
    if (length(problems) > 0) return(notice(problems))
    if (!is.null(stop_reason(deps))) return(notice(error_message(stop_reason(deps))))
    # The latest totals of all sessions decide, not the copy of this session
    latest <- deps$store$read()
    usage(latest)
    allowed <- run_allowed(
      estimate_cost(s, n, mean_for(s)), spend_status(session_cost(), latest, deps$now())
    )
    if (!allowed$ok) return(notice(limit_message(allowed$reason)))
    notice(character())
    job$spec <- s
    job$key <- key()
    job$chunks <- purrr::map(chunk_ids(n), \(i) rows[i])
    job$rows <- subset
    job$done <- 0L
    job$stop <- FALSE
    job$answers <- NULL
    # The run writes its answers only while the card holds the answers that it
    # found or wrote
    job$token <- (job$token %||% 0L) + 1L
    results(utils::modifyList(results() %||% list(), list(token = job$token)))
    running(TRUE)
    session$sendCustomMessage("run-state", TRUE)
    run_next()
  }
  end_run <- function() {
    running(FALSE)
    session$sendCustomMessage("run-state", FALSE)
  }
  # After each chunk: show its answers, then run the next chunk or stop
  finish_chunk <- function(done) {
    if (!running()) return()
    job$done <- job$done + 1L
    if (is.null(done)) {
      notice("The run stopped. Try again.")
      return(end_run())
    }
    session_cost(session_cost() + done$cost)
    if (!is.null(done$usage)) usage(done$usage)
    job$answers <- dplyr::bind_rows(job$answers, done$answers)
    shown <- identical(results()$token, job$token)
    if (shown) {
      results(list(
        answers = job$answers, key = job$key, saved = FALSE, token = job$token, rows = job$rows
      ))
    }
    # Only calls with tokens count: a mean of 0 would make the next run look free
    tokens <- job$answers$.input_tokens
    tokens <- tokens[!is.na(tokens) & tokens > 0]
    if (length(tokens) > 0) {
      mean_tokens(list(fingerprint = spec_fingerprint(job$spec), mean = mean(tokens)))
    }
    reason <- stop_reason(deps)
    if (!is.null(reason)) notice(error_message(reason))
    # A run whose answers nobody sees stops (its chunk is counted all the same)
    if (job$stop || !shown || !is.null(reason) || job$done >= length(job$chunks)) return(end_run())
    run_next()
  }
  shiny::observe({
    if (!task$status() %in% c("success", "error")) return()
    done <- tryCatch(task$result(), error = \(e) NULL)
    shiny::isolate(finish_chunk(done))
  })
  shiny::observeEvent(input$try5, start_run(5))
  shiny::observeEvent(input$run_all, start_run(Inf))
  # Stop takes effect at the end of the current chunk
  shiny::observeEvent(input$stop, job$stop <- TRUE, priority = 10)

  # --- Outputs: sidebar -------------------------------------------------------
  output$answers_ui <- shiny::renderUI({
    rows_version()
    answer_rows(shiny::isolate(question()))
  })
  output$references_ui <- shiny::renderUI({
    l <- shiny::req(layout())
    r <- refs()
    srcs <- purrr::map(seq_along(l$answers), \(i) {
      if (i <= length(r)) thumb(r[[i]]) else character()
    })
    reference_rows(
      list(type = l$type, answers = stats::setNames(character(length(l$answers)), l$answers)),
      stats::setNames(srcs, l$answers)
    )
  })
  # A cost below one cent reads "< $0.01", not "$0.00": a run is never free.
  # Only the saved run of an unchanged sample costs nothing.
  output$run_estimate <- shiny::renderText({
    all <- checks()$run_all
    if (all$n == 0) return("")
    if (all$saved) return(paste(all$n, "\u00b7 saved run, no cost"))
    cost <- if (all$cost > 0 && all$cost < 0.005) "< $0.01" else sprintf("\u2248 $%.2f", all$cost)
    paste(all$n, "\u00b7", cost)
  })
  output$spend_meter <- shiny::renderUI(spend_meter_ui(session_cost()))
  output$limits_text <- shiny::renderText({
    all <- checks()$run_all
    refused <- if (!all$ok && !is.na(all$reason)) limit_message(all$reason)
    sentences <- unique(c(notice(), data()$problems, matched()$problems, refused))
    paste(stringr::str_replace(sentences, "([^.!?])$", "\\1."), collapse = " ")
  })

  # --- Outputs: answers -------------------------------------------------------
  target <- shiny::reactive(target_answer(question(), input$target))
  table <- shiny::reactive({
    r <- results()
    if (is.null(r$answers) || !identical(r$key, key())) return(NULL)
    answers_table(spec(), r$answers, target())
  })
  # The table of the downloads: no rows while the card shows no answers
  download_table <- shiny::reactive(table() %||% empty_table(spec()))
  stats <- shiny::reactive(if (!is.null(table())) answer_stats(table(), spec(), target()))
  output$stats <- shiny::renderUI({
    if (is.null(stats())) return(NULL)
    r <- results()
    note <- if (r$saved) {
      sprintf("Saved run of %s, %s", SAVED_RUN_DATE, MODEL)
    } else if (running()) {
      sprintf("%d of %d", nrow(r$answers), length(unlist(job$chunks)))
    }
    stats_ui(stats(), spec()$mode, note)
  })
  output$empty_answers <- shiny::renderUI({
    if (is.null(table())) {
      shiny::p(
        class = "empty",
        "No answers yet. Pick a sample from Samples, or add your own images or texts."
      )
    }
  })
  # The facts of a sample describe its full saved run, not Try on 5
  output$help_answers <- shiny::renderText({
    if (is.null(stats())) return(help_text("answers"))
    r <- results()
    help_note(spec(), stats(), target(), saved = r$saved, full = is.null(r$rows))
  })
  output$gallery <- shiny::renderUI({
    t <- table()
    if (is.null(t) || spec()$mode != "image") return(NULL)
    inputs <- spec()$inputs
    gallery_ui(t, target(), thumb(inputs$path[match(t$id, inputs$id)]))
  })
  output$table <- shiny::renderTable(
    if (!is.null(table())) table_view(table(), spec()),
    digits = 2, na = ""
  )
  output$download_answers <- shiny::downloadHandler(
    filename = \() paste0(run_name(spec()), "-answers.csv"),
    content = \(file) readr::write_file(answers_csv(download_table()), file)
  )

  # --- Outputs: R code and bundle ---------------------------------------------
  run_date <- function() as.Date(deps$now())
  zip_name <- function() sprintf("%s-%s.zip", run_name(spec()), format(run_date()))
  # The code repeats the run on show: after Try on 5, the same rows; with no
  # answers on show, or after Run all, all inputs
  code_rows <- shiny::reactive(if (!is.null(table())) results()$rows)
  code <- shiny::reactive(run_code(spec(), run_date(), code_rows()))
  output$code_summary <- shiny::renderText(
    sprintf("R code of this run \u00b7 %d lines of code", code_lines(code()))
  )
  output$code <- shiny::renderText(code())
  output$bundle_files <- shiny::renderUI(bundle_files_ui(bundle_names(spec()), zip_name()))
  for (id in c("code", "bundle_files", "code_summary")) {
    shiny::outputOptions(output, id, suspendWhenHidden = FALSE)
  }
  output$download_code <- shiny::downloadHandler(
    filename = \() paste0(run_name(spec()), ".R"),
    content = \(file) readr::write_file(paste0(code(), "\n"), file)
  )
  output$download_bundle <- shiny::downloadHandler(
    filename = zip_name,
    content = \(file) write_bundle(spec(), download_table(), file, run_date(), code_rows())
  )

  # --- Outputs: status ----------------------------------------------------------
  # The status file is read at the start and every hour
  status <- shiny::reactive({
    shiny::invalidateLater(3600000)
    status_summary(deps$status_fn(), deps$now())
  })
  output$status_chip <- shiny::renderText(status_chip(status()))
  output$record_table <- shiny::renderTable(
    {
      s <- status()
      s$last |>
        dplyr::arrange(dplyr::desc(time_utc)) |>
        dplyr::transmute(
          `Time (UTC)` = time_utc, HTTP = suppressWarnings(as.integer(http_status)),
          Seconds = suppressWarnings(as.numeric(seconds)),
          !!sprintf("Answered (%d of %d)", s$answered, s$checks) := answered,
          Version = app_version
        )
    },
    digits = 1, na = ""
  )
}