harn-stdlib 0.10.180

Embedded Harn standard library source catalog
Documentation
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
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
import { agent_session_inject_reminder } from "std/agent/state"
import { filter_nil } from "std/collections"
import { call } from "std/connectors/github"
import { triage_normalize } from "std/triage"

/**
 * std/signals - fire, route, and deliver ambient signals, plus the monitor pack.
 *
 * A monitor turns one observation into a `SignalDecision`: the condition holds
 * (fire) or it does not (resolve). `signal_apply` hands the decision to the
 * per-machine inbox, which turns repeated observations of the same state into
 * no reminder at all and each real state change into one reminder, delivered
 * at least once.
 *
 * A session receives reminders by checking in with
 * `harness.runtime.signal_check_in`, which leases what is pending now, and
 * acks each lease with `harness.runtime.signal_ack` and its `lease_token`
 * once the reminder reached the model. A lease nobody acks is leased again.
 * `signal_deliver` does both for an in-process agent session and renders the
 * leases as advisory system reminders. Alert text never travels as a user
 * message.
 *
 * Evaluators (`*_decision`) are pure so thresholds and wording can be tested
 * without a network. Monitors (`monitor_*`) fetch, evaluate, and apply. A
 * monitor whose read fails returns `unmeasured` and neither fires nor
 * resolves: a failed read is not a healthy reading.
 */
type SignalSeverity = "info" | "warning" | "urgent"

type SignalRouting = {repo?: string, cwd?: string, topic?: string}

/**
 * `local` for a condition observed on this machine; webhooks say whether their signature was checked.
 */
type SignalSignature = "verified" | "unverified" | "local"

type SignalProvenance = {producer: string, signature: SignalSignature}

type SignalTransition = {
  outcome: "changed" | "unchanged" | "unknown" | "unmeasured",
  state?: string,
  generation?: int,
  error?: string,
}

/** One condition's verdict. `event` is a normalized `harn.triage_event.v1`. */
type SignalDecision = {
  key: string,
  firing: bool,
  severity: SignalSeverity,
  routing: SignalRouting,
  provenance: SignalProvenance,
  event: dict,
  expires_in_seconds?: int,
}

type SignalApplyOptions = {dir?: string}

/** What a session says about itself at each check-in. */
type SignalSession = {
  id: string,
  harness: string,
  cwd: string,
  repo?: string,
  title?: string,
  role?: string,
  activity: "busy" | "idle",
}

type SignalCheckInOptions = {scope?: "all" | "urgent_only", limit?: int, dir?: string}

/** One generation of one signal leased to the session. */
type SignalReminder = {
  key: string,
  generation: int,
  state: string,
  severity: SignalSeverity,
  attempt: int,
  event: dict,
  routing: SignalRouting,
  provenance: SignalProvenance,
  lease_token: string,
  lease_expires_at: string,
}

type SignalCheckIn = {reminders: list<SignalReminder>, waiting: int}

type SignalReminderId = {key: string, generation: int}

type SignalDelivery = {
  check_in: SignalCheckIn,
  reminder_ids: list<string>,
  acked: list<SignalReminderId>,
}

/**
 * GitHub App credentials. Each poll mints a fresh installation token through
 * the GitHub connector, because a cached token can read a different
 * rate-limit counter than the one the installation is spending.
 */
type GitHubAppAuth = {
  app_id: int | string,
  installation_id: int | string,
  private_key_secret?: string,
  private_key_pem?: string,
  installation_scope?: dict,
}

/**
 * Credentials, most preferred first: `app` mints an installation token per
 * poll, `token` is a token the caller resolved (for example from
 * `harness.secrets`), and `token_env` names an environment variable
 * (default `GITHUB_TOKEN`).
 */
type GitHubMonitorOptions = {
  app?: GitHubAppAuth,
  token?: string,
  token_env?: string,
  api_url?: string,
  severity?: SignalSeverity,
  dir?: string,
}

/**
 * A self-hosted runner pool. `scope` is an organization or `owner/name`.
 * `group`, for an organization, counts only runners in that runner group,
 * named or by id; on a large organization the flat runner list is mostly
 * other pools' ephemeral runners. `required_labels` counts only runners that
 * carry every label.
 */
type GitHubRunnerPool = {
  scope: string,
  minimum: int,
  group?: string | int,
  required_labels?: list<string>,
}

const GITHUB_API = "https://api.github.com"

const CI_FAILED_CONCLUSIONS = ["failure", "timed_out", "startup_failure", "action_required"]

/**
 * A run that ended `cancelled`, `skipped`, `stale`, or `neutral` says nothing
 * about the branch; only these conclusions are a reading.
 */
const CI_DECISIVE_CONCLUSIONS = [
  "success",
  "failure",
  "timed_out",
  "startup_failure",
  "action_required",
]

/** How many recent completed runs per workflow to search for a decisive one. */
const CI_RUNS_PER_WORKFLOW = 20

/**
 * Installation tokens live an hour; a refresh skew past that makes the connector mint every time.
 */
const FRESH_TOKEN_SKEW_SECONDS = 86400

/**
 * Pagination cap: a pool larger than this many pages reads as unmeasured, never as a partial count.
 */
const MAX_PAGES = 50

const PAGE_SIZE = 100

fn __signal_event(
  key: string,
  provider: string,
  source_kind: string,
  source_url: string,
  summary: string,
  why: string,
  action: string,
) -> dict {
  return triage_normalize(
    {},
    {
      provider: provider,
      source_kind: source_kind,
      source_url: source_url,
      source_id: key,
      dedupe_key: key,
      summary: summary,
      why_it_matters: why,
      proposed_action: action,
    },
  )
}

fn __local(producer: string) -> SignalProvenance {
  return {producer: "std/signals." + producer, signature: "local"}
}

fn __decision(
  key: string,
  firing: bool,
  severity: SignalSeverity,
  routing: SignalRouting,
  provenance: SignalProvenance,
  event: dict,
) -> SignalDecision {
  return {
    key: key,
    firing: firing,
    severity: severity,
    routing: routing,
    provenance: provenance,
    event: event,
  }
}

/**
 * Fire the decision's signal when it is firing, otherwise resolve it.
 *
 * @effects: [state.write]
 * @errors: []
 */
pub fn signal_apply(
  runtime: HarnessRuntime,
  decision: SignalDecision,
  options: SignalApplyOptions = {},
) -> SignalTransition {
  if decision.firing {
    const fired = runtime.signal_fire(
      filter_nil(
        {
          key: decision.key,
          event: decision.event,
          severity: decision.severity,
          routing: decision.routing,
          provenance: decision.provenance,
          expires_in_seconds: decision.expires_in_seconds,
          dir: options.dir,
        },
      ),
    )
    return schema_expect(fired, schema_of(SignalTransition))
  }
  const resolved = runtime.signal_resolve(
    decision.key,
    filter_nil({event: decision.event, signature: decision.provenance.signature, dir: options.dir}),
  )
  return schema_expect(resolved, schema_of(SignalTransition))
}

/**
 * Check `session` in and deliver its leased reminders to the running agent
 * session `agent_session_id` as advisory system reminders, one per signal,
 * deduplicated by signal key so a redelivery replaces rather than repeats.
 * Each lease is acked once its reminder is injected; a lease left unacked
 * because injection failed is leased again when it lapses.
 *
 * @effects: [state.write, host]
 * @errors: []
 */
pub fn signal_deliver(
  harness: Harness,
  agent_session_id: string,
  session: SignalSession,
  options: SignalCheckInOptions = {},
) -> SignalDelivery {
  const check_in = schema_expect(
    harness.runtime.signal_check_in(session, options),
    schema_of(SignalCheckIn),
  )
  let reminder_ids = []
  let tokens = []
  for reminder in check_in.reminders {
    reminder_ids.push(
      agent_session_inject_reminder(
        harness.agent,
        agent_session_id,
        {
          body: harness.runtime.signal_render([reminder]),
          tags: ["signal"],
          dedupe_key: "harn.signal:" + reminder.key,
          ttl_turns: 1,
          authority: "advisory",
        },
      ),
    )
    tokens.push(reminder.lease_token)
  }
  const acked = len(tokens) == 0 ? [] : harness.runtime.signal_ack(
    session.id,
    tokens,
    filter_nil({dir: options.dir}),
  )
  return {check_in: check_in, reminder_ids: reminder_ids, acked: acked}
}

/**
 * Default-branch CI: firing while the newest decisive run of any workflow on
 * `branch` failed. `runs` is the GitHub `workflow_runs` list, newest first; a
 * run that was cancelled, skipped, stale, or neutral is passed over, so it
 * neither hides a failure nor reads as a recovery.
 *
 * @effects: []
 * @errors: []
 */
pub fn ci_branch_decision(
  repo: string,
  branch: string,
  runs: list,
  severity: SignalSeverity = "warning",
) -> SignalDecision {
  let seen = {}
  let failing = []
  for run in runs {
    const workflow = to_string(run?.workflow_id ?? run?.name ?? "")
    if run?.status != "completed" || seen[workflow] != nil
      || !contains(CI_DECISIVE_CONCLUSIONS, run?.conclusion ?? "") {
      continue
    }
    seen[workflow] = true
    if contains(CI_FAILED_CONCLUSIONS, run?.conclusion ?? "") {
      failing.push(run)
    }
  }
  const key = "github.ci:" + repo + "@" + branch
  const branch_url = "https://github.com/" + repo + "/actions?query=branch%3A" + branch
  const firing = len(failing) > 0
  const names = join(failing.map({ run -> to_string(run?.name ?? "workflow") }), ", ")
  const summary = firing ? "CI is failing on " + branch + " of " + repo + ": "
    + names : "CI recovered on " + branch + " of " + repo
  const source_url = firing ? to_string(failing[0]?.html_url ?? branch_url) : branch_url
  return __decision(
    key,
    firing,
    severity,
    {repo: repo},
    __local("monitor_github_ci"),
    __signal_event(
      key,
      "github",
      "ci.default_branch",
      source_url,
      summary,
      "A red default branch blocks merges and hides new breakage behind the old one.",
      "Open the failing run, find the first broken step, and fix forward or revert.",
    ),
  )
}

/**
 * Open pull requests: firing while `open_count` reaches `threshold`.
 *
 * @effects: []
 * @errors: []
 */
pub fn open_prs_decision(
  repo: string,
  open_count: int,
  threshold: int,
  severity: SignalSeverity = "info",
) -> SignalDecision {
  const key = "github.open_prs:" + repo
  const firing = open_count >= threshold
  const summary = firing ? repo + " has " + to_string(open_count)
    + " open pull requests (threshold "
    + to_string(threshold)
    + ")" : repo + " is back to " + to_string(open_count) + " open pull requests"
  return __decision(
    key,
    firing,
    severity,
    {repo: repo},
    __local("monitor_github_open_prs"),
    __signal_event(
      key,
      "github",
      "pulls.backlog",
      "https://github.com/" + repo + "/pulls",
      summary,
      "A growing review queue delays every change waiting in it.",
      "Review, merge, or close the oldest pull requests.",
    ),
  )
}

fn __pool_name(pool: GitHubRunnerPool) -> string {
  const labels = (pool.required_labels ?? []).sorted()
  const group = pool.group == nil ? "" : " group " + to_string(pool.group)
  const label_text = len(labels) == 0 ? "" : " [" + join(labels, ", ") + "]"
  return pool.scope + group + label_text
}

/**
 * Self-hosted runner pool: firing while fewer than `pool.minimum` matching
 * runners are online.
 *
 * @effects: []
 * @errors: []
 */
pub fn runner_pool_decision(
  pool: GitHubRunnerPool,
  online: int,
  severity: SignalSeverity = "warning",
) -> SignalDecision {
  const labels = (pool.required_labels ?? []).sorted()
  const key = "github.runners:" + pool.scope
    + (pool.group == nil ? "" : "/" + to_string(pool.group))
    + (len(labels) == 0 ? "" : ":" + join(labels, ","))
  const name = __pool_name(pool)
  const firing = online < pool.minimum
  const repo_scoped = contains(pool.scope, "/")
  const settings = repo_scoped ? "https://github.com/" + pool.scope
    + "/settings/actions/runners" : "https://github.com/organizations/" + pool.scope
    + "/settings/actions/runners"
  const summary = firing ? "Only " + to_string(online) + " of the minimum "
    + to_string(pool.minimum)
    + " self-hosted runners are online for "
    + name : to_string(online) + " self-hosted runners are online again for " + name
  return __decision(
    key,
    firing,
    severity,
    repo_scoped ? {repo: pool.scope} : {topic: "runners"},
    __local("monitor_github_runners"),
    __signal_event(
      key,
      "github",
      "runners.pool",
      settings,
      summary,
      "Jobs that need these runners queue until enough of them come back.",
      "Check the runner hosts and restart the offline runners.",
    ),
  )
}

/**
 * Count the online runners in a runner list page that carry every required
 * label.
 *
 * @effects: []
 * @errors: []
 */
pub fn runner_pool_online(runners: list, required_labels: list<string> = []) -> int {
  let online = 0
  for runner in runners {
    const labels = (runner?.labels ?? []).map({ entry -> to_string(entry?.name ?? "") })
    if runner?.status == "online" && required_labels.all({ label -> contains(labels, label) }) {
      online = online + 1
    }
  }
  return online
}

/**
 * API rate limit: firing while `remaining` is under `min_percent` of `limit`.
 *
 * @effects: []
 * @errors: []
 */
pub fn rate_limit_decision(
  subject: string,
  remaining: int,
  limit: int,
  min_percent: float,
  source_url: string = "https://docs.github.com/rest/rate-limit",
  severity: SignalSeverity = "warning",
) -> SignalDecision {
  const key = "github.rate_limit:" + subject
  const percent = limit > 0 ? to_float(remaining) * 100.0 / to_float(limit) : 0.0
  const firing = percent < min_percent
  const summary = firing ? subject + " has " + to_string(remaining) + " of " + to_string(limit)
    + " API requests left this window" : subject + " API rate limit has recovered"
  return __decision(
    key,
    firing,
    severity,
    {topic: "rate_limit"},
    __local("monitor_github_rate_limit"),
    __signal_event(
      key,
      "github",
      "rate_limit.low",
      source_url,
      summary,
      "Automation that shares this limit starts failing when it runs out.",
      "Pause bulk API work until the window resets.",
    ),
  )
}

fn __alert_severity(text: string) -> SignalSeverity {
  const level = lowercase(text)
  if level == "critical" || level == "fatal" || level == "page" || level == "urgent" {
    return "urgent"
  }
  if level == "info" || level == "low" {
    return "info"
  }
  return "warning"
}

fn __sentry_decisions(
  payload: dict,
  routing: SignalRouting,
  provenance: SignalProvenance,
) -> list<SignalDecision> {
  const data = payload?.data ?? {}
  const issue = data?.issue ?? data?.event ?? {}
  const issue_id = to_string(issue?.issue_id ?? issue?.id ?? "")
  if issue_id == "" {
    return []
  }
  const key = "sentry:" + issue_id
  const action = to_string(payload?.action ?? "triggered")
  const firing = action != "resolved" && action != "ignored" && action != "archived"
  const url = to_string(issue?.web_url ?? issue?.permalink ?? issue?.url ?? "")
  const project_field = issue?.project
  const project = to_string(
    (type_of(project_field) == "dict" ? project_field.slug : project_field) ?? "sentry",
  )
  const title = to_string(issue?.title ?? "Sentry issue " + issue_id)
  return [
    __decision(
      key,
      firing,
      __alert_severity(to_string(issue?.level ?? "error") == "fatal" ? "critical" : "warning"),
      routing,
      provenance,
      __signal_event(
        key,
        "sentry",
        "issue." + action,
        url == "" ? "https://sentry.io/issues/" + issue_id + "/" : url,
        firing ? project + ": " + title : project + ": resolved " + title,
        "A production error is reaching users.",
        "Read the stack trace and recent deploys, then decide whether it belongs to current work.",
      ),
    ),
  ]
}

fn __grafana_decisions(
  payload: dict,
  routing: SignalRouting,
  provenance: SignalProvenance,
) -> list<SignalDecision> {
  let out = []
  for alert in payload?.alerts ?? [] {
    const labels = alert?.labels ?? {}
    const name = to_string(labels?.alertname ?? payload?.title ?? "Grafana alert")
    const fingerprint = to_string(alert?.fingerprint ?? name)
    const key = "grafana:" + fingerprint
    const firing = to_string(alert?.status ?? payload?.status ?? "firing") == "firing"
    const annotations = alert?.annotations ?? {}
    const detail = to_string(annotations?.summary ?? annotations?.description ?? "")
    const url = to_string(alert?.panelURL ?? alert?.generatorURL ?? payload?.externalURL ?? "")
    out.push(
      __decision(
        key,
        firing,
        __alert_severity(to_string(labels?.severity ?? "warning")),
        routing,
        provenance,
        __signal_event(
          key,
          "grafana",
          firing ? "alert.firing" : "alert.resolved",
          url == "" ? "https://grafana.com/" : url,
          detail == "" ? name : name + ": " + detail,
          "A monitored service crossed an alert threshold.",
          "Open the panel, confirm the impact, and decide whether it belongs to current work.",
        ),
      ),
    )
  }
  return out
}

fn __honeycomb_decisions(
  payload: dict,
  routing: SignalRouting,
  provenance: SignalProvenance,
) -> list<SignalDecision> {
  const id = to_string(payload?.id ?? payload?.name ?? "")
  if id == "" {
    return []
  }
  const key = "honeycomb:" + id
  const firing = uppercase(to_string(payload?.status ?? "TRIGGERED")) == "TRIGGERED"
  const name = to_string(payload?.name ?? "Honeycomb trigger")
  const summary = to_string(payload?.summary ?? payload?.trigger_description ?? "")
  return [
    __decision(
      key,
      firing,
      __alert_severity(to_string(payload?.severity ?? "warning")),
      routing,
      provenance,
      __signal_event(
        key,
        "honeycomb",
        firing ? "trigger.triggered" : "trigger.ok",
        to_string(payload?.result_url ?? payload?.trigger_url ?? "https://ui.honeycomb.io/"),
        summary == "" ? name : name + ": " + summary,
        "A query over production telemetry crossed its trigger threshold.",
        "Open the query result, confirm the impact, and decide whether it belongs to current work.",
      ),
    ),
  ]
}

/**
 * Normalize a Sentry, Grafana, or Honeycomb alert webhook body into
 * decisions. One Grafana delivery can carry several alerts. Unrecognized
 * payloads return an empty list. `signature` reports whether the receiving
 * trigger verified the delivery's signature; it is shown beside the reminder.
 * Payload text fills only the summary and source link: why it matters and the
 * proposed action are written here, never copied from the payload.
 *
 * @effects: []
 * @errors: []
 */
pub fn alert_webhook_decisions(
  provider: string,
  payload: dict,
  signature: "verified" | "unverified",
  routing: SignalRouting = {},
) -> list<SignalDecision> {
  const name = lowercase(provider)
  const provenance: SignalProvenance = {
    producer: "std/signals.alert_webhook_decisions(" + name + ")",
    signature: signature,
  }
  if name == "sentry" {
    return __sentry_decisions(payload, routing, provenance)
  }
  if name == "grafana" {
    return __grafana_decisions(payload, routing, provenance)
  }
  if name == "honeycomb" {
    return __honeycomb_decisions(payload, routing, provenance)
  }
  return []
}

type GitHubAccess = {env: HarnessEnv, net: HarnessNet}

type GitHubRateLimit = {resources: {core: {remaining: int, limit: int}}}

/**
 * The bearer token for one poll: a freshly minted installation token when
 * `options.app` is set, otherwise `options.token` or `options.token_env`.
 */
fn __github_token(github: GitHubAccess, options: GitHubMonitorOptions) -> string {
  if options.app != nil {
    const minted = call(
      github.net,
      "github.app.installation_token",
      filter_nil(options.app + {token_refresh_skew_seconds: FRESH_TOKEN_SKEW_SECONDS}),
      filter_nil({api_base_url: options.api_url}),
    )
    const token = to_string(minted?.token ?? "")
    if token == "" {
      throw "std/signals: the GitHub connector minted no installation token"
    }
    return token
  }
  const supplied = options.token ?? ""
  if supplied != "" {
    return supplied
  }
  const env_name = options.token_env ?? "GITHUB_TOKEN"
  const token = github.env.get(env_name) ?? ""
  if token == "" {
    throw "std/signals: set " + env_name + " to a GitHub token, or pass `app` credentials"
  }
  return token
}

fn __github_get(
  net: HarnessNet,
  token: string,
  path: string,
  options: GitHubMonitorOptions,
) -> dict {
  const response = net.get(
    (options.api_url ?? GITHUB_API) + path,
    {
      headers: {
        Authorization: "Bearer " + token,
        Accept: "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": "harn-signals",
      },
      timeout_ms: 20000,
    },
  )
  if !response.ok {
    throw "std/signals: GET " + path + " returned " + to_string(response.status) + ": "
      + substring(to_string(response.body), 0, 200)
  }
  return json_parse(response.body)
}

/**
 * Every item of a paginated list endpoint, or a throw. A page without the
 * list field throws. With `total_count`, the listing ends only once it is
 * reached; without one, only at a short page. A listing that is cut short or
 * runs past `MAX_PAGES` throws rather than return a partial list that would
 * read as a smaller pool.
 */
fn __github_all(
  net: HarnessNet,
  token: string,
  path: string,
  field: string,
  options: GitHubMonitorOptions,
) -> list {
  const separator = contains(path, "?") ? "&" : "?"
  let items = []
  let page = 1
  while page <= MAX_PAGES {
    const body = __github_get(
      net,
      token,
      path + separator + "per_page=" + to_string(PAGE_SIZE) + "&page=" + to_string(page),
      options,
    )
    const batch = body[field]
    if type_of(batch) != "list" {
      throw "std/signals: " + path + " returned no `" + field + "` list"
    }
    items = items + batch
    const reported = body?.total_count
    if reported != nil {
      const total = to_int(reported)
      if len(items) >= total {
        return items
      }
      if len(batch) < PAGE_SIZE {
        throw "std/signals: " + path + " ended at " + to_string(len(items)) + " of "
          + to_string(total)
          + " entries"
      }
    } else if len(batch) < PAGE_SIZE {
      return items
    }
    page = page + 1
  }
  throw "std/signals: " + path + " has more than " + to_string(MAX_PAGES * PAGE_SIZE) + " entries"
}

fn __unmeasured(error: any) -> SignalTransition {
  return {outcome: "unmeasured", error: to_string(error)}
}

/** Apply a decision only when its observation succeeded. */
fn __observe(
  runtime: HarnessRuntime,
  options: GitHubMonitorOptions,
  observe: fn() -> SignalDecision,
) -> SignalTransition {
  const observed = try {
    observe()
  }
  if is_err(observed) {
    return __unmeasured(unwrap_err(observed))
  }
  return signal_apply(runtime, unwrap(observed), filter_nil({dir: options.dir}))
}

/**
 * The newest decisive push run of every active workflow on `branch`, searched
 * among its recent completed runs. One query per workflow, so a busy workflow
 * cannot push another's runs out of a shared page. A workflow with recent
 * runs but no decisive one throws, so its last real result, possibly a
 * failure, is never silently replaced by the other workflows' readings. A
 * workflow that never ran on the branch contributes nothing.
 */
fn __latest_decisive_runs(
  net: HarnessNet,
  token: string,
  repo: string,
  branch: string,
  options: GitHubMonitorOptions,
) -> list {
  const workflows = __github_all(
    net,
    token,
    "/repos/" + repo + "/actions/workflows",
    "workflows",
    options,
  )
  let latest = []
  for workflow in workflows {
    if (workflow?.state ?? "active") != "active" {
      continue
    }
    if workflow?.id == nil {
      throw "std/signals: a workflow of " + repo + " has no id"
    }
    const page = __github_get(
      net,
      token,
      "/repos/" + repo + "/actions/workflows/" + to_string(workflow.id) + "/runs?branch="
        + branch
        + "&event=push&status=completed&per_page="
        + to_string(CI_RUNS_PER_WORKFLOW),
      options,
    )
    const runs = page?.workflow_runs
    if type_of(runs) != "list" {
      throw "std/signals: the runs of workflow " + to_string(workflow.id)
        + " came back without `workflow_runs`"
    }
    const decisive = runs.filter(
      { run -> contains(CI_DECISIVE_CONCLUSIONS, run?.conclusion ?? "") },
    )
    if len(decisive) > 0 {
      latest.push(decisive[0])
    } else if len(runs) > 0 {
      throw "std/signals: workflow " + to_string(workflow.id) + " has no decisive run among its "
        + to_string(len(runs))
        + " most recent on "
        + branch
    }
  }
  return latest
}

/**
 * Poll default-branch CI for `repo` and fire or resolve its signal. A branch
 * where no workflow has a decisive recent run is unmeasured, not recovered.
 *
 * @effects: [net, env, state.write]
 * @errors: []
 */
pub fn monitor_github_ci(
  harness: Harness,
  repo: string,
  branch: string = "main",
  options: GitHubMonitorOptions = {},
) -> SignalTransition {
  return __observe(
    harness.runtime,
    options,
    { ->
      const runs = __latest_decisive_runs(
        harness.net,
        __github_token(pick(harness, ["env", "net"]), options),
        repo,
        branch,
        options,
      )
      if len(runs) == 0 {
        throw "std/signals: no workflow has a decisive recent push run on " + branch + " of "
          + repo
      }
      ci_branch_decision(repo, branch, runs, options.severity ?? "warning")
    },
  )
}

/**
 * Poll the open pull request count for `repo`; fire at `threshold` or more.
 *
 * @effects: [net, env, state.write]
 * @errors: []
 */
pub fn monitor_github_open_prs(
  harness: Harness,
  repo: string,
  threshold: int,
  options: GitHubMonitorOptions = {},
) -> SignalTransition {
  return __observe(
    harness.runtime,
    options,
    { ->
      const page = __github_get(
        harness.net,
        __github_token(pick(harness, ["env", "net"]), options),
        "/search/issues?q=repo:" + repo + "+is:pr+is:open&per_page=1",
        options,
      )
      if page?.total_count == nil {
        throw "std/signals: the pull request search returned no total_count"
      }
      if page?.incomplete_results ?? false {
        throw "std/signals: the pull request search timed out and reported a partial count"
      }
      open_prs_decision(repo, to_int(page.total_count), threshold, options.severity ?? "info")
    },
  )
}

fn __runner_group_id(
  net: HarnessNet,
  token: string,
  org: string,
  group: string | int,
  options: GitHubMonitorOptions,
) -> int {
  if type_of(group) == "int" {
    return group
  }
  const groups = __github_all(
    net,
    token,
    "/orgs/" + org + "/actions/runner-groups",
    "runner_groups",
    options,
  )
  for candidate in groups {
    if to_string(candidate?.name ?? "") == group {
      return to_int(candidate.id)
    }
  }
  throw "std/signals: " + org + " has no runner group named " + to_string(group)
}

/**
 * Poll a self-hosted runner pool and fire while too few matching runners are
 * online. With `pool.group`, only that organization runner group is listed.
 * Every page is read; a failed or incomplete read is `unmeasured`.
 *
 * @effects: [net, env, state.write]
 * @errors: []
 */
pub fn monitor_github_runners(
  harness: Harness,
  pool: GitHubRunnerPool,
  options: GitHubMonitorOptions = {},
) -> SignalTransition {
  return __observe(
    harness.runtime,
    options,
    { ->
      const token = __github_token(pick(harness, ["env", "net"]), options)
      let path = ""
      if contains(pool.scope, "/") {
        if pool.group != nil {
          throw "std/signals: runner groups belong to organizations, not " + pool.scope
        }
        path = "/repos/" + pool.scope + "/actions/runners"
      } else if pool.group != nil {
        path = "/orgs/" + pool.scope + "/actions/runner-groups/"
          + to_string(__runner_group_id(harness.net, token, pool.scope, pool.group, options))
          + "/runners"
      } else {
        path = "/orgs/" + pool.scope + "/actions/runners"
      }
      const runners = __github_all(harness.net, token, path, "runners", options)
      runner_pool_decision(
        pool,
        runner_pool_online(runners, pool.required_labels ?? []),
        options.severity ?? "warning",
      )
    },
  )
}

/**
 * Poll the REST rate limit of the polling credential and fire under
 * `min_percent` remaining. With `options.app`, the token is minted fresh for
 * this poll, so the reading is the installation's current counter.
 *
 * @effects: [net, env, state.write]
 * @errors: []
 */
pub fn monitor_github_rate_limit(
  harness: Harness,
  subject: string,
  min_percent: float,
  options: GitHubMonitorOptions = {},
) -> SignalTransition {
  return __observe(
    harness.runtime,
    options,
    { ->
      const page = schema_expect(
        __github_get(
          harness.net,
          __github_token(pick(harness, ["env", "net"]), options),
          "/rate_limit",
          options,
        ),
        schema_of(GitHubRateLimit),
      )
      rate_limit_decision(
        subject,
        page.resources.core.remaining,
        page.resources.core.limit,
        min_percent,
        "https://docs.github.com/rest/rate-limit",
        options.severity ?? "warning",
      )
    },
  )
}