runsheets 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +98 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +277 -0
  5. data/bin/runsheets +12 -0
  6. data/examples/about-the-examples.md +40 -0
  7. data/examples/directory_based_runbooks/hello/rollback.md +11 -0
  8. data/examples/directory_based_runbooks/hello/runbook.md +40 -0
  9. data/examples/directory_based_runbooks/hello/steps/010-say-hello.md +33 -0
  10. data/examples/directory_based_runbooks/hello/steps/020-inspect-ruby.md +28 -0
  11. data/examples/directory_based_runbooks/hello/steps/030-take-a-breath.md +11 -0
  12. data/examples/directory_based_runbooks/hello/steps/035-keep-a-clock-running.md +21 -0
  13. data/examples/directory_based_runbooks/hello/steps/040-exercise-failure.md +26 -0
  14. data/examples/directory_based_runbooks/hello/steps/045-check-the-greeting.md +18 -0
  15. data/examples/directory_based_runbooks/hello/verify.md +18 -0
  16. data/examples/directory_based_runbooks/staging-teardown/rollback.md +28 -0
  17. data/examples/directory_based_runbooks/staging-teardown/runbook.md +57 -0
  18. data/examples/directory_based_runbooks/staging-teardown/steps/010-confirm-nothing-to-preserve.md +18 -0
  19. data/examples/directory_based_runbooks/staging-teardown/steps/020-snapshot-the-database.md +47 -0
  20. data/examples/directory_based_runbooks/staging-teardown/steps/030-stop-the-pipeline.md +32 -0
  21. data/examples/directory_based_runbooks/staging-teardown/steps/040-drain-the-ecs-services.md +35 -0
  22. data/examples/directory_based_runbooks/staging-teardown/steps/050-record-the-final-row-counts.md +41 -0
  23. data/examples/directory_based_runbooks/staging-teardown/steps/060-delete-the-stack.md +56 -0
  24. data/examples/directory_based_runbooks/staging-teardown/verify.md +40 -0
  25. data/examples/single_md_file_runbooks/db-maintenance.md +163 -0
  26. data/examples/single_md_file_runbooks/disk-space-triage.md +90 -0
  27. data/examples/single_md_file_runbooks/disk-usage-glossary.md +48 -0
  28. data/examples/single_md_file_runbooks/policies/cleanup-policy.md +33 -0
  29. data/examples/single_md_file_runbooks/policies/images/triage-flow.svg +44 -0
  30. data/examples/single_md_file_runbooks/runbooks_with_scripts/.loki +17 -0
  31. data/examples/single_md_file_runbooks/runbooks_with_scripts/asgard-tasks.md +135 -0
  32. data/examples/single_md_file_runbooks/runbooks_with_scripts/checks.loki +27 -0
  33. data/examples/single_md_file_runbooks/runbooks_with_scripts/greet.loki +6 -0
  34. data/lib/runsheets/assets.rb +780 -0
  35. data/lib/runsheets/block.rb +118 -0
  36. data/lib/runsheets/cli.rb +424 -0
  37. data/lib/runsheets/config/defaults.yml +44 -0
  38. data/lib/runsheets/config.rb +139 -0
  39. data/lib/runsheets/diff.rb +67 -0
  40. data/lib/runsheets/execution.rb +144 -0
  41. data/lib/runsheets/executor.rb +157 -0
  42. data/lib/runsheets/expectation.rb +101 -0
  43. data/lib/runsheets/fences.rb +172 -0
  44. data/lib/runsheets/front_matter.rb +29 -0
  45. data/lib/runsheets/library.rb +268 -0
  46. data/lib/runsheets/pages/chooser.rb +408 -0
  47. data/lib/runsheets/pages/search.rb +119 -0
  48. data/lib/runsheets/pages/tree.rb +88 -0
  49. data/lib/runsheets/pages.rb +947 -0
  50. data/lib/runsheets/redactor.rb +88 -0
  51. data/lib/runsheets/renderer.rb +229 -0
  52. data/lib/runsheets/run.rb +330 -0
  53. data/lib/runsheets/run_record.rb +371 -0
  54. data/lib/runsheets/runbook.rb +301 -0
  55. data/lib/runsheets/search.rb +129 -0
  56. data/lib/runsheets/session.rb +307 -0
  57. data/lib/runsheets/session_log.rb +114 -0
  58. data/lib/runsheets/single_file.rb +93 -0
  59. data/lib/runsheets/step.rb +91 -0
  60. data/lib/runsheets/version.rb +5 -0
  61. data/lib/runsheets/web.rb +537 -0
  62. data/lib/runsheets.rb +90 -0
  63. data/sig/runsheets.rbs +4 -0
  64. metadata +226 -0
@@ -0,0 +1,780 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # Inline CSS and JavaScript for the pages. Kept as strings so the gem has
5
+ # no static file handling and a page is one self-contained response.
6
+ module Assets
7
+ def self.stylesheet = @stylesheet ||= CSS + Renderer.code_stylesheet
8
+ def self.javascript = JS
9
+
10
+ CSS = <<~CSS
11
+ :root {
12
+ --bg: #0e1117; --panel: #151a23; --panel-2: #1b2130; --border: #273040;
13
+ --text: #d9dee8; --muted: #8b95a8; --accent: #5ab0ff; --accent-2: #a78bfa;
14
+ --ok: #3ddc97; --warn: #ffb454; --danger: #ff6b6b; --mark: #3b3200;
15
+ --header-h: 56px; --sidebar-w: 300px; --radius: 10px;
16
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
17
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
18
+ }
19
+ * { box-sizing: border-box; }
20
+ html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 var(--font); }
21
+ a { color: var(--accent); text-decoration: none; }
22
+ a:hover { text-decoration: underline; }
23
+ svg.icon { width: 1.1em; height: 1.1em; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; vertical-align: -0.2em; }
24
+ button { font: inherit; }
25
+
26
+ /* ---------- header ---------- */
27
+ .rs-header {
28
+ position: sticky; top: 0; z-index: 20; height: var(--header-h);
29
+ display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px;
30
+ padding: 0 16px; background: linear-gradient(180deg, #1a2130 0%, #121722 100%);
31
+ border-bottom: 1px solid var(--border); box-shadow: 0 1px 0 rgba(255,255,255,.03), 0 6px 20px rgba(0,0,0,.35);
32
+ }
33
+ .rs-header::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--ok)); }
34
+ .brand { display: flex; align-items: center; gap: 6px; }
35
+ .brand .home { display: flex; align-items: center; gap: 9px; padding: 6px 12px 6px 8px; border-radius: 999px; color: var(--text); font-weight: 600; letter-spacing: .3px; background: rgba(90,176,255,.10); border: 1px solid rgba(90,176,255,.25); }
36
+ .brand .home:hover { text-decoration: none; background: rgba(90,176,255,.22); border-color: var(--accent); }
37
+ .brand .home svg { width: 1.3em; height: 1.3em; color: var(--accent); }
38
+ .brand-name { font-weight: 800; background: linear-gradient(90deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
39
+ .brand-tag { color: var(--muted); font-weight: 500; font-size: 12px; max-width: 32vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
40
+ .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--muted); cursor: pointer; }
41
+ .icon-btn:hover { color: var(--text); background: var(--panel-2); border-color: var(--border); }
42
+ .crumbs { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; white-space: nowrap; font-size: 14px; }
43
+ .crumbs a, .crumbs .current { padding: 3px 8px; border-radius: 6px; }
44
+ .crumbs a { color: var(--muted); }
45
+ .crumbs a:hover { color: var(--text); background: var(--panel-2); text-decoration: none; }
46
+ .crumbs .current { color: var(--text); font-weight: 600; background: var(--panel-2); overflow: hidden; text-overflow: ellipsis; }
47
+ .crumbs .sep { color: #3d4759; }
48
+ .actions { display: flex; align-items: center; gap: 6px; }
49
+ .nav-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 8px; font-size: 13px; color: var(--text); border: 1px solid var(--border); background: var(--panel); cursor: pointer; }
50
+ .nav-btn:hover { text-decoration: none; background: var(--panel-2); border-color: var(--accent); }
51
+ .nav-btn.disabled { opacity: .35; cursor: default; }
52
+ .run-pill { display: inline-flex; align-items: center; gap: 8px; margin-left: 8px; padding: 5px 12px; border-radius: 999px; font: 600 12px var(--mono); border: 1px solid var(--border); color: var(--muted); background: var(--panel); }
53
+ .run-pill.active { color: var(--ok); border-color: rgba(61,220,151,.4); background: rgba(61,220,151,.08); }
54
+ .run-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
55
+ .run-pill.active .dot { animation: pulse 1.6s infinite; }
56
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
57
+
58
+ /* ---------- shell ---------- */
59
+ .rs-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: calc(100vh - var(--header-h) - 40px); }
60
+ body.no-sidebar .rs-shell { grid-template-columns: 1fr; }
61
+ body.no-sidebar .rs-sidebar { display: none; }
62
+ .rs-sidebar { position: sticky; top: var(--header-h); align-self: start; height: calc(100vh - var(--header-h)); overflow: auto; padding: 18px 12px 24px; border-right: 1px solid var(--border); background: var(--panel); font-size: 13.5px; }
63
+ .rs-sidebar h2 { display: flex; align-items: center; gap: 6px; margin: 0 8px 8px; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
64
+ .rs-sidebar section + section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
65
+ .rs-sidebar ol, .rs-sidebar ul { list-style: none; margin: 0; padding: 0; }
66
+ .rs-sidebar li a { display: grid; grid-template-columns: 1.6em 1fr auto; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 6px; color: var(--text); }
67
+ .rs-sidebar li a:hover { background: var(--panel-2); text-decoration: none; }
68
+ .rs-sidebar li.active > a { background: rgba(90,176,255,.14); color: #fff; box-shadow: inset 3px 0 0 var(--accent); }
69
+ .rs-sidebar .num { color: var(--muted); font: 600 11px var(--mono); }
70
+ .rs-sidebar .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
71
+ .rs-sidebar .mark { font: 700 11px var(--mono); }
72
+ .mark.done { color: var(--ok); } .mark.skipped { color: var(--warn); } .mark.failed { color: var(--danger); } .mark.ran { color: var(--accent); } .mark.pending { color: var(--muted); opacity: .6; }
73
+ .rs-sidebar details { margin: 0 8px; }
74
+ .rs-sidebar summary { cursor: pointer; color: var(--warn); font-weight: 600; }
75
+ .rs-sidebar .rollback-body { font-size: 13px; color: var(--muted); max-height: 40vh; overflow: auto; padding: 6px 0; }
76
+ .rs-sidebar .rollback-body pre { font-size: 11.5px; }
77
+ #outline a { grid-template-columns: 1fr; color: var(--muted); }
78
+ #outline a.h3 { padding-left: 24px; font-size: 12.5px; }
79
+ #outline li.active a { color: var(--text); background: var(--panel-2); }
80
+
81
+ .rs-main { min-width: 0; padding: 32px 40px 64px; }
82
+ .rs-footer span { min-width: 0; overflow-wrap: anywhere; }
83
+ .rs-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; background: var(--panel); }
84
+ kbd { display: inline-block; min-width: 1.4em; margin: 0 2px; padding: 0 5px; border-radius: 4px; border: 1px solid var(--border); background: var(--panel-2); font: 11px var(--mono); text-align: center; color: var(--text); }
85
+
86
+ /* ---------- landing + step chrome ---------- */
87
+ .page-head h1 { display: flex; align-items: center; gap: 12px; margin: 0 0 6px; font-size: 28px; line-height: 1.2; }
88
+ .page-head .sub { color: var(--muted); margin: 0 0 18px; }
89
+ .badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 16px; }
90
+ .badge { padding: 2px 9px; border-radius: 999px; font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: .05em; background: rgba(139,149,168,.14); color: var(--muted); }
91
+ .badge.automated { color: var(--accent); background: rgba(90,176,255,.12); }
92
+ .badge.manual { color: var(--warn); background: rgba(255,180,84,.12); }
93
+ .badge.verify { color: var(--accent-2); background: rgba(167,139,250,.14); }
94
+ .badge.destructive { color: var(--danger); background: rgba(255,107,107,.14); }
95
+ .badge.tag { text-transform: none; }
96
+ .meta-table { border-collapse: collapse; margin: 0 0 24px; width: 100%; max-width: 900px; }
97
+ .meta-table th { text-align: left; vertical-align: top; width: 150px; padding: 8px 12px 8px 0; color: var(--muted); font-weight: 600; font-size: 13px; }
98
+ .meta-table td { padding: 8px 0; border-top: 1px solid var(--border); }
99
+ .meta-table tr:first-child td, .meta-table tr:first-child th { border-top: 0; }
100
+ .meta-table th { border-top: 1px solid var(--border); }
101
+ .meta-table ul { margin: 0; padding-left: 1.2em; }
102
+ .banner { display: flex; gap: 12px; align-items: flex-start; padding: 12px 16px; margin: 0 0 20px; border-radius: var(--radius); border: 1px solid; }
103
+ .banner svg { margin-top: 3px; }
104
+ .banner.danger { border-color: rgba(255,107,107,.45); background: rgba(255,107,107,.08); color: #ffd3d3; }
105
+ .banner.warn { border-color: rgba(255,180,84,.45); background: rgba(255,180,84,.08); color: #ffe3b8; }
106
+ .banner.info { border-color: rgba(90,176,255,.35); background: rgba(90,176,255,.07); }
107
+ .banner ul { margin: 4px 0 0; padding-left: 1.2em; }
108
+ .panel { padding: 18px 20px; margin: 0 0 24px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--panel); max-width: 900px; }
109
+ .panel h2 { margin: 0 0 12px; font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
110
+ .panel form { display: flex; flex-direction: column; gap: 12px; }
111
+ .field { display: grid; grid-template-columns: 220px 1fr; gap: 12px; align-items: center; }
112
+ .field label { font-weight: 600; font-size: 14px; }
113
+ .field label code { display: block; font: 12px var(--mono); color: var(--muted); font-weight: 400; }
114
+ .field input[type=text], .field input[type=password], .field textarea, .field select { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; outline: 0; }
115
+ .field input:focus, .field textarea:focus { border-color: var(--accent); }
116
+ .btn { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-weight: 600; cursor: pointer; }
117
+ .btn:hover { border-color: var(--accent); }
118
+ .btn.primary { background: rgba(90,176,255,.18); border-color: var(--accent); }
119
+ .btn.ok { background: rgba(61,220,151,.14); border-color: rgba(61,220,151,.5); }
120
+ .btn.danger { background: rgba(255,107,107,.12); border-color: rgba(255,107,107,.5); }
121
+ .btn-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
122
+ .steps-list { list-style: none; margin: 0 0 24px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); max-width: 900px; }
123
+ .steps-list li { display: grid; grid-template-columns: 3em 1fr auto auto; align-items: center; gap: 14px; padding: 10px 16px; border-top: 1px solid var(--border); }
124
+ .steps-list li:first-child { border-top: 0; }
125
+ .steps-list li:hover { background: var(--panel-2); }
126
+ .steps-list .num { color: var(--muted); font: 600 12px var(--mono); }
127
+ .steps-list .title { color: var(--text); font-weight: 600; }
128
+ .steps-list .title small { display: block; color: var(--muted); font-weight: 400; font-size: 12px; }
129
+ .history { list-style: none; margin: 0; padding: 0; }
130
+ .history li { display: grid; grid-template-columns: 1fr auto auto auto; gap: 16px; padding: 8px 0; border-top: 1px solid var(--border); font-size: 13.5px; align-items: center; }
131
+ .history li:first-child { border-top: 0; }
132
+ .history .meta { color: var(--muted); font: 12px var(--mono); }
133
+ .empty { color: var(--muted); font-style: italic; }
134
+ .step-nav { display: flex; justify-content: space-between; gap: 12px; margin-top: 36px; padding-top: 20px; border-top: 1px solid var(--border); }
135
+
136
+ /* ---------- markdown ---------- */
137
+ .markdown-body { max-width: 900px; font-size: 16px; }
138
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { scroll-margin-top: calc(var(--header-h) + 16px); line-height: 1.25; margin: 1.6em 0 .6em; }
139
+ .markdown-body h1 { margin-top: 0; font-size: 1.8em; padding-bottom: .3em; border-bottom: 1px solid var(--border); }
140
+ .markdown-body h2 { font-size: 1.4em; padding-bottom: .25em; border-bottom: 1px solid var(--border); }
141
+ .markdown-body h3 { font-size: 1.15em; }
142
+ .markdown-body p, .markdown-body ul, .markdown-body ol { margin: 0 0 1em; }
143
+ .markdown-body li + li { margin-top: .25em; }
144
+ .markdown-body code { font: 85% var(--mono); background: var(--panel-2); padding: .15em .4em; border-radius: 5px; }
145
+ .markdown-body pre { margin: 0; padding: 14px 16px; border-radius: var(--radius); border: 1px solid var(--border); overflow: auto; line-height: 1.5; }
146
+ .markdown-body pre code { background: none; padding: 0; font-size: 13.5px; }
147
+ .markdown-body .highlight { background: #0b0e14 !important; }
148
+ .markdown-body > pre, .markdown-body > .highlighter-rouge, .markdown-body li > pre, .markdown-body li > .highlighter-rouge { margin: 0 0 1em; }
149
+ .markdown-body blockquote { margin: 0 0 1em; padding: .4em 1em; border-left: 4px solid var(--accent-2); background: var(--panel); color: var(--muted); border-radius: 0 8px 8px 0; }
150
+ .markdown-body table { border-collapse: collapse; margin: 0 0 1em; display: block; overflow: auto; }
151
+ .markdown-body th, .markdown-body td { padding: 6px 12px; border: 1px solid var(--border); }
152
+ .markdown-body th { background: var(--panel-2); text-align: left; }
153
+ .markdown-body img { max-width: 100%; height: auto; }
154
+ .markdown-body hr { border: 0; border-top: 1px solid var(--border); margin: 2em 0; }
155
+
156
+ /* ---------- executable blocks ---------- */
157
+ .rs-block { margin: 0 0 1.2em; border-radius: var(--radius); border: 1px solid var(--border); background: var(--panel); overflow: hidden; }
158
+ .rs-block > pre, .rs-block > .highlighter-rouge { margin: 0; }
159
+ .rs-block .highlighter-rouge > .highlight > pre, .rs-block > pre { border: 0; border-radius: 0; }
160
+ .rs-executable { border-color: rgba(90,176,255,.35); }
161
+ .rs-destructive { border-color: rgba(255,107,107,.45); }
162
+ .rs-block.rs-ok { border-color: rgba(61,220,151,.5); }
163
+ .rs-block.rs-failed { border-color: var(--danger); }
164
+ .rs-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-size: 12px; flex-wrap: wrap; }
165
+ .rs-spacer { flex: 1; }
166
+ .rs-badge { padding: 1px 8px; border-radius: 999px; font: 600 11px var(--mono); letter-spacing: .04em; background: rgba(139,149,168,.14); color: var(--muted); }
167
+ .rs-badge.rs-run { color: var(--accent); background: rgba(90,176,255,.12); }
168
+ .rs-badge.rs-destructive { color: var(--danger); background: rgba(255,107,107,.14); }
169
+ .rs-badge.rs-terminal { color: var(--warn); background: rgba(255,180,84,.14); }
170
+ .rs-badge.rs-expect { color: var(--accent-2); background: rgba(167,139,250,.14); }
171
+ .rs-badge.rs-background { color: var(--ok); background: rgba(61,220,151,.12); }
172
+ .rs-note { color: var(--muted); font-style: italic; }
173
+ .rs-warning { color: var(--warn); font-weight: 600; }
174
+ .rs-warning::before { content: "⚠ "; }
175
+ .rs-btn { padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 12px; font-weight: 600; cursor: pointer; }
176
+ .rs-btn:hover { border-color: var(--accent); }
177
+ .rs-btn:disabled { opacity: .5; cursor: progress; }
178
+ .rs-btn[data-locked] { cursor: not-allowed; }
179
+ .rs-run { background: rgba(90,176,255,.18); border-color: var(--accent); }
180
+ .rs-run.rs-danger { background: rgba(255,107,107,.14); border-color: var(--danger); }
181
+ .rs-status { font: 600 12px var(--mono); color: var(--muted); min-width: 6em; text-align: right; }
182
+ .rs-status.ok { color: var(--ok); } .rs-status.failed { color: var(--danger); } .rs-status.running { color: var(--accent); }
183
+ .rs-result { border-top: 1px solid var(--border); background: #0b0e14; }
184
+ .rs-output { margin: 0; padding: 12px 16px; overflow-x: auto; font: 12.5px/1.5 var(--mono); color: #c9d1d9; white-space: pre-wrap; word-break: break-word; border: 0 !important; border-radius: 0 !important; }
185
+ .rs-output:empty::before { content: "(no output)"; color: var(--muted); font-style: italic; }
186
+ .rs-exit { padding: 6px 16px; border-top: 1px solid var(--border); font: 11.5px var(--mono); color: var(--muted); }
187
+ .rs-block.rs-stopped { border-color: rgba(255,180,84,.5); }
188
+ .rs-block.rs-acked { border-color: rgba(61,220,151,.5); }
189
+ .rs-status.stopped { color: var(--warn); } .rs-status.acked { color: var(--ok); }
190
+ .rs-stop { background: rgba(255,180,84,.14); border-color: rgba(255,180,84,.6); }
191
+ .rs-ack { background: rgba(61,220,151,.14); border-color: rgba(61,220,151,.5); }
192
+ /* Output and expected side by side: one vertical scroll for both, the
193
+ same title row and padding on each, and no wrapping, so line N of
194
+ one pane always sits beside line N of the other. */
195
+ .rs-panes { display: grid; grid-template-columns: 1fr; max-height: 420px; overflow-y: auto; }
196
+ .rs-panes.has-expected { grid-template-columns: 1fr 1fr; align-items: start; }
197
+ .rs-pane { min-width: 0; }
198
+ .rs-pane-title { padding: 6px 16px 0; font: 600 10.5px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
199
+ .rs-panes:not(.has-expected) .rs-pane-title { display: none; }
200
+ .rs-expected { border-left: 1px solid rgba(255,180,84,.5); }
201
+ .rs-expected .rs-pane-title { color: var(--warn); }
202
+ .rs-expected pre { margin: 0; font: 12.5px/1.5 var(--mono); color: var(--muted); border: 0 !important; border-radius: 0 !important; }
203
+ .rs-panes.has-expected pre { padding: 6px 16px 12px; white-space: pre; word-break: normal; overflow-x: auto; }
204
+ .rs-row { display: block; min-height: 1.5em; min-width: 100%; width: max-content; }
205
+ .rs-row-changed, .rs-row-expected, .rs-row-actual { background: rgba(255,180,84,.08); }
206
+ .rs-row.rs-gap { background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(255,255,255,.04) 4px 8px); }
207
+ .rs-diff { background: rgba(255,180,84,.28); color: #ffe2b8; border-radius: 2px; }
208
+ .rs-any { color: var(--accent-2); background: rgba(167,139,250,.14); border-radius: 3px; }
209
+ .rs-sidebar .running h2 { color: var(--ok); }
210
+ .rs-sidebar .running li { display: grid; grid-template-columns: 1fr auto; gap: 4px 8px; align-items: center; padding: 6px 8px; border-radius: 6px; }
211
+ .rs-sidebar .running li a { display: inline; padding: 0; }
212
+ .rs-sidebar .running li code { font: 600 11.5px var(--mono); }
213
+ .rs-sidebar .running li .meta { grid-column: 1; color: var(--muted); font: 11px var(--mono); }
214
+ .rs-sidebar .running li button { grid-column: 2; grid-row: 1 / span 2; }
215
+ .rs-sidebar .running li.ended { opacity: .55; }
216
+ .meta code.secret { color: var(--warn); }
217
+ .history li { grid-template-columns: auto auto auto auto auto 1fr; }
218
+ .history li .verdict { font-weight: 700; }
219
+ .history li.completed .verdict { color: var(--ok); } .history li.abandoned .verdict, .history li.interrupted .verdict { color: var(--warn); } .history li.running .verdict { color: var(--accent); }
220
+ .history li.partial .verdict { color: var(--accent-2); } .history li.opened .verdict { color: var(--muted); }
221
+ .session-runs li { grid-template-columns: 1fr auto auto auto; }
222
+ .notes { list-style: none; margin: 0 0 14px; padding: 0; }
223
+ .notes li > div { min-width: 0; overflow-wrap: anywhere; }
224
+ .panel .meta code, .panel p code { overflow-wrap: anywhere; }
225
+ .panel[id] { scroll-margin-top: calc(var(--header-h) + 16px); }
226
+ .run-pill { max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
227
+ .error-head .btn-row { margin-top: 14px; }
228
+ .notes li { display: grid; grid-template-columns: 5.5em 1fr; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }
229
+ .notes li:first-child { border-top: 0; }
230
+ .notes time { color: var(--muted); font: 600 12px var(--mono); padding-top: 3px; }
231
+ .note-form { display: flex; gap: 8px; align-items: flex-start; }
232
+ .note-form textarea, .session-start textarea { flex: 1; width: 100%; min-width: 0; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; resize: vertical; }
233
+ .session-log { max-height: 420px; overflow: auto; margin: 0; padding: 12px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
234
+ .change-inputs { margin-top: 12px; } .change-inputs summary { cursor: pointer; color: var(--muted); }
235
+ .lib-inputs { flex: 1 1 100%; } .lib-open-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
236
+ .bare-main { max-width: 640px; margin: 8vh auto; padding: 0 16px; }
237
+ .session-start h1 { margin-top: 0; font-size: 22px; display: flex; align-items: center; gap: 8px; }
238
+ .badge.run { color: var(--accent); background: rgba(90,176,255,.12); }
239
+ .panel.drift { border-color: rgba(255,180,84,.5); max-width: none; }
240
+ .panel.drift h2 { color: var(--warn); display: flex; align-items: center; gap: 6px; }
241
+ .panel.drift ul { list-style: none; margin: 0; padding: 0; }
242
+ .panel.drift li + li { margin-top: 14px; }
243
+ pre.diff { margin: 6px 0 0; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--border); background: #0b0e14; font: 12.5px/1.5 var(--mono); overflow: auto; white-space: pre; }
244
+ pre.diff .add { color: var(--ok); display: block; background: rgba(61,220,151,.08); }
245
+ pre.diff .del { color: var(--danger); display: block; background: rgba(255,107,107,.08); }
246
+ pre.diff .ctx { color: var(--muted); display: block; }
247
+ .verify-toolbar { margin: 0 0 24px; }
248
+ .verify-toolbar .meta.ok { color: var(--ok); } .verify-toolbar .meta.failed { color: var(--danger); }
249
+ .check-doc { margin: 0 0 32px; padding: 0 0 8px; border-bottom: 1px solid var(--border); }
250
+ .check-doc > h2 { display: flex; align-items: center; gap: 10px; font-size: 20px; margin: 0 0 12px; }
251
+ @media (max-width: 700px) { .rs-panes.has-expected { grid-template-columns: 1fr; } .rs-expected { border-left: 0; border-top: 1px solid rgba(255,180,84,.5); } }
252
+
253
+ /* ---------- library: the tree ---------- */
254
+ .lib-tree { padding-top: 12px; }
255
+ .rs-search { position: relative; display: flex; align-items: center; }
256
+ .rs-search > svg { position: absolute; left: 9px; color: var(--muted); pointer-events: none; }
257
+ .rs-search > kbd { position: absolute; right: 7px; opacity: .7; }
258
+ .rs-search input { width: 200px; padding: 6px 28px 6px 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; font-size: 13px; outline: 0; }
259
+ .rs-search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(90,176,255,.15); }
260
+ .rs-search input:focus ~ kbd { display: none; }
261
+ .search-form { display: flex; gap: 8px; max-width: 900px; margin: 0 0 22px; }
262
+ .search-form input { flex: 1; min-width: 0; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; outline: 0; }
263
+ .search-form input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(90,176,255,.15); }
264
+ .search-results { list-style: none; margin: 0; padding: 0; max-width: 900px; }
265
+ .search-result { padding: 14px 18px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); }
266
+ .search-result h2 { margin: 0 0 2px; font-size: 17px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
267
+ .search-result > .meta { margin: 0 0 8px; }
268
+ .search-hits { list-style: none; margin: 0; padding: 0; }
269
+ .search-hits li { padding: 8px 0; border-top: 1px solid var(--border); }
270
+ .search-hits a { color: var(--text); font-weight: 600; }
271
+ .search-hits .num { color: var(--muted); font: 600 12px var(--mono); margin-right: 4px; }
272
+ .snippet { margin: 2px 0 0; color: var(--muted); font-size: 13.5px; overflow-wrap: anywhere; }
273
+ mark { background: var(--mark); color: var(--warn); border-radius: 3px; padding: 0 2px; }
274
+ .steps-list li:target { background: var(--mark); }
275
+ .lib-filter { position: relative; display: flex; align-items: center; margin: 0 4px 14px; }
276
+ .lib-filter > svg { position: absolute; left: 10px; color: var(--muted); pointer-events: none; }
277
+ .lib-filter > kbd { position: absolute; right: 8px; opacity: .7; }
278
+ .lib-filter input { width: 100%; padding: 8px 34px 8px 34px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; font-size: 13.5px; outline: 0; }
279
+ .lib-filter input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(90,176,255,.15); }
280
+ .lib-filter input:focus ~ kbd { display: none; }
281
+ .lib-filter input::-webkit-search-cancel-button { cursor: pointer; }
282
+ .tree ul { list-style: none; margin: 0; padding: 0; }
283
+ .tree ul ul { margin-left: 14px; padding-left: 8px; border-left: 1px solid var(--border); }
284
+ .tree li { margin: 1px 0; }
285
+ .tree li[hidden] { display: none; }
286
+ .tree .node, .tree summary { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px; min-height: 30px; padding: 3px 8px; border-radius: 6px; color: var(--text); cursor: pointer; }
287
+ .tree summary { grid-template-columns: auto auto 1fr auto; gap: 6px; list-style: none; user-select: none; }
288
+ .tree summary::-webkit-details-marker { display: none; }
289
+ .tree summary svg, .tree .node svg { color: var(--muted); }
290
+ .tree summary .name { color: var(--text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
291
+ .tree summary .name:hover { color: var(--accent); text-decoration: none; }
292
+ .tree .twisty { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; border-radius: 4px; color: var(--muted); transition: transform .15s ease; }
293
+ .tree .twisty svg { width: .9em; height: .9em; }
294
+ .tree details[open] > summary .twisty { transform: rotate(90deg); }
295
+ .tree .node:hover, .tree summary:hover { background: var(--panel-2); text-decoration: none; }
296
+ .tree .node .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
297
+ .tree li.active > .node, .tree li.active > details > summary { background: rgba(90,176,255,.14); color: #fff; box-shadow: inset 3px 0 0 var(--accent); }
298
+ .tree li.active > .node svg { color: var(--accent); }
299
+ .tree li.focus > .node, .tree li.focus > details > summary { outline: 1px solid var(--accent); outline-offset: -1px; }
300
+ .tree .root > .node { margin-bottom: 6px; font-weight: 600; }
301
+ .tree-mark { font: 600 11px var(--mono); color: var(--muted); }
302
+ .tree-mark.count { min-width: 1.6em; text-align: right; }
303
+ .tree-mark.broken { color: var(--danger); font-weight: 800; }
304
+ .tree-mark.current { display: inline-flex; color: var(--ok); }
305
+ .tree-mark.current .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; animation: pulse 1.6s infinite; }
306
+ .tree li.current > .node .name { color: var(--ok); }
307
+ .tree li.broken > .node .name { color: var(--muted); text-decoration: line-through; text-decoration-color: rgba(255,107,107,.6); }
308
+ .tree li.destructive > .node svg { color: var(--danger); }
309
+ .tree-empty { margin: 12px 8px; color: var(--muted); font-style: italic; }
310
+ body.filtering .tree details > summary .twisty { visibility: hidden; }
311
+
312
+ /* ---------- library: the main pane ---------- */
313
+ .lib-main .page-head h1 svg { color: var(--accent); }
314
+ .lib-main .page-head .sub code { font: 12.5px var(--mono); color: var(--muted); background: var(--panel-2); padding: 1px 6px; border-radius: 5px; }
315
+ .lib-readme { margin: 0 0 28px; padding: 4px 0 16px; border-bottom: 1px solid var(--border); color: var(--text); }
316
+ .lib-readme > :first-child { margin-top: 0; }
317
+ .lib-readme h1 { font-size: 1.35em; border-bottom: 0; padding-bottom: 0; }
318
+ .lib-readme h2 { font-size: 1.15em; border-bottom: 0; }
319
+ .lib-section { margin: 0 0 28px; max-width: 900px; }
320
+ .lib-section > h2 { display: flex; align-items: baseline; gap: 10px; margin: 0 0 12px; font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
321
+ .lib-section > h2 .meta { font: 12px var(--mono); text-transform: none; letter-spacing: 0; }
322
+ .lib-section .steps-list, .lib-section .meta-table { margin-bottom: 0; }
323
+ .lib-section > .meta { margin: 8px 0 0; color: var(--muted); font-size: 13px; }
324
+ .lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
325
+ .lib-card { display: flex; flex-direction: column; min-width: 0; border-radius: var(--radius); border: 1px solid var(--border); background: var(--panel); color: var(--text); transition: border-color .12s ease, transform .12s ease, box-shadow .12s ease; }
326
+ .lib-card:hover { border-color: var(--accent); text-decoration: none; transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,.3); }
327
+ .lib-card h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-size: 15.5px; line-height: 1.3; }
328
+ .lib-card h3 svg { color: var(--accent); }
329
+ .lib-card.folder { padding: 16px 18px; }
330
+ .lib-card.folder h3 svg { color: var(--warn); }
331
+ .lib-card .desc { margin: 0; color: var(--muted); font-size: 13.5px; line-height: 1.5; }
332
+ .lib-card .names { margin: 8px 0 0; font-size: 12.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
333
+ .lib-card .card-link { display: block; flex: 1; padding: 16px 18px 12px; color: inherit; }
334
+ .lib-card .card-link:hover { text-decoration: none; }
335
+ .lib-card .card-link .badges { margin: 12px 0 0; }
336
+ .lib-card .card-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 10px 18px; border-top: 1px solid var(--border); font-size: 12.5px; color: var(--muted); }
337
+ .lib-card .card-foot .btn { padding: 5px 12px; font-size: 13px; }
338
+ .lib-card.current { border-color: rgba(61,220,151,.5); }
339
+ .lib-card.broken { border-color: rgba(255,107,107,.45); }
340
+ .lib-card.broken h3 svg { color: var(--danger); }
341
+ .lib-card.broken .desc { color: #ffd3d3; font: 12.5px var(--mono); }
342
+ .lib-open { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: center; border-color: rgba(90,176,255,.35); background: linear-gradient(135deg, rgba(90,176,255,.08), rgba(167,139,250,.06)); }
343
+ .lib-open.current { border-color: rgba(61,220,151,.5); background: linear-gradient(135deg, rgba(61,220,151,.08), rgba(90,176,255,.05)); }
344
+ .lib-open.broken { border-color: rgba(255,107,107,.45); background: rgba(255,107,107,.05); grid-template-columns: 1fr; }
345
+ .lib-open .lib-open-text > :last-child { margin-bottom: 0; }
346
+ .lib-open .lib-open-text p { margin: 0; font-size: 15.5px; line-height: 1.55; }
347
+ .lib-open .banner { margin: 0; }
348
+ .lib-open .btn.primary { padding: 10px 22px; font-size: 15px; white-space: nowrap; }
349
+ .lib-open-form { display: inline; }
350
+ .lib-hint { max-width: 900px; margin: 0; color: var(--muted); font-size: 13.5px; }
351
+ .meta-table.inputs { width: 100%; }
352
+ .meta-table.inputs th { width: auto; white-space: nowrap; font: 600 13px var(--mono); color: var(--text); padding-right: 24px; }
353
+ .meta-table.inputs td:last-child { width: 30%; }
354
+ .meta-table.inputs thead th { font: 600 11px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); border-top: 0; padding: 0 24px 8px 0; }
355
+ .meta-table.inputs td { padding: 8px 24px 8px 0; }
356
+ .badge.current { color: var(--ok); background: rgba(61,220,151,.14); }
357
+ .steps-list .title { display: block; }
358
+ @media (max-width: 700px) { .lib-open { grid-template-columns: 1fr; } }
359
+
360
+ @media (max-width: 900px) {
361
+ .rs-header { grid-template-columns: auto 1fr; height: auto; padding: 8px 12px; row-gap: 6px; }
362
+ .brand-tag, .nav-btn span { display: none; }
363
+ .actions { grid-column: 1 / -1; flex-wrap: wrap; }
364
+ .rs-search { flex: 1 1 100%; } .rs-search input { width: 100%; }
365
+ .rs-shell { grid-template-columns: 1fr; }
366
+ .rs-sidebar { position: static; height: auto; max-height: none; border-right: 0; border-bottom: 1px solid var(--border); }
367
+ .rs-main { padding: 20px 16px 48px; }
368
+ .rs-footer .keys { display: none; }
369
+ .rs-footer { flex-direction: column; align-items: flex-start; }
370
+ .history li, .session-runs li { grid-template-columns: 1fr auto; gap: 4px 12px; }
371
+ .history li > .meta { grid-column: 1 / -1; }
372
+ .field { grid-template-columns: 1fr; }
373
+ }
374
+ CSS
375
+
376
+ JS = <<~'JS'
377
+ (() => {
378
+ const body = document.body;
379
+ const token = document.querySelector('meta[name="rs-token"]')?.content || '';
380
+ // Only elements stamped with this page's mark are acted on: raw HTML in
381
+ // a runbook can copy a Run button's markup, but not the mark.
382
+ const pageMark = document.querySelector('meta[name="rs-mark"]')?.content || '';
383
+ const ours = sel => [...document.querySelectorAll(sel)].filter(el => pageMark && el.dataset.rsMark === pageMark);
384
+ const LS = 'runsheets.sidebar';
385
+ const fmt = s => (s == null ? '' : s < 10 ? s.toFixed(2) + 's' : Math.round(s) + 's');
386
+
387
+ // Sidebar toggle (remembered across pages).
388
+ try { if (localStorage.getItem(LS) === 'off') body.classList.add('no-sidebar'); } catch (e) {}
389
+ const toggleSidebar = () => {
390
+ body.classList.toggle('no-sidebar');
391
+ try { localStorage.setItem(LS, body.classList.contains('no-sidebar') ? 'off' : 'on'); } catch (e) {}
392
+ };
393
+ document.querySelector('.sidebar-toggle')?.addEventListener('click', toggleSidebar);
394
+
395
+ // "On this page" outline.
396
+ const outline = document.getElementById('outline');
397
+ if (outline) {
398
+ const heads = [...document.querySelectorAll('.markdown-body :is(h2,h3)[id]')];
399
+ if (heads.length === 0) outline.closest('section')?.remove();
400
+ heads.forEach(h => {
401
+ const li = document.createElement('li');
402
+ const a = document.createElement('a');
403
+ a.href = '#' + h.id; a.textContent = h.textContent; a.className = h.tagName.toLowerCase();
404
+ li.appendChild(a); outline.appendChild(li);
405
+ });
406
+ const items = [...outline.children];
407
+ const mark = () => {
408
+ const y = window.scrollY + 90;
409
+ let idx = heads.findIndex(h => h.offsetTop > y) - 1;
410
+ if (idx < -1) idx = heads.length - 1;
411
+ items.forEach((li, i) => li.classList.toggle('active', i === idx));
412
+ };
413
+ window.addEventListener('scroll', mark, { passive: true }); mark();
414
+ }
415
+
416
+ // Executable blocks.
417
+ const prior = (() => {
418
+ try { const p = JSON.parse(ours('#rs-prior')[0]?.textContent || '{}'); return { executions: p.executions || {}, acks: p.acks || {} }; }
419
+ catch (e) { return { executions: {}, acks: {} }; }
420
+ })();
421
+ const headers = { 'X-Runsheets-Token': token, 'Accept': 'application/json' };
422
+ const runbookSlug = document.querySelector('meta[name="rs-runbook"]')?.content || '';
423
+ const post = async (path, fields) => {
424
+ const body = new URLSearchParams(Object.assign({}, fields || {}, runbookSlug ? { runbook: runbookSlug } : {}));
425
+ const res = await fetch(path, { method: 'POST', headers, body });
426
+ const data = await res.json();
427
+ return { res, data };
428
+ };
429
+ const TRUNCATED = '[… earlier output omitted; the .out file in the run directory is complete]\n';
430
+
431
+ // One row of the side-by-side view: [text, mark] segments, or a gap
432
+ // where the other pane has a line this one lacks.
433
+ const drawRow = (pre, segments, tag) => {
434
+ const row = document.createElement('span');
435
+ row.className = 'rs-row rs-row-' + tag + (segments ? '' : ' rs-gap');
436
+ (segments || []).forEach(([text, mark]) => {
437
+ const part = document.createElement('span');
438
+ part.textContent = text;
439
+ if (mark) part.className = 'rs-' + mark;
440
+ row.append(part);
441
+ });
442
+ pre.append(row);
443
+ };
444
+
445
+ // Show the output against its expect block, which the server has
446
+ // compared (Runsheets::Expectation). A match only says so; otherwise
447
+ // both panes are drawn row by row with the differences marked. The
448
+ // expect block itself is hidden, since the result now shows it.
449
+ // A hint, not a verdict.
450
+ const compare = (block, id, data) => {
451
+ const expected = data.expected;
452
+ if (!expected) return null;
453
+ const source = ours('.rs-expect[data-expect-for="' + CSS.escape(id) + '"]')[0];
454
+ if (source) source.hidden = true;
455
+ const pane = block.querySelector('[data-role="expected"]');
456
+ const panes = block.querySelector('.rs-panes');
457
+ const output = block.querySelector('[data-role="output"]');
458
+ pane.hidden = expected.match; panes.classList.toggle('has-expected', !expected.match);
459
+ if (expected.match) return '✓ matches expected';
460
+ const want = pane.querySelector('pre');
461
+ want.replaceChildren(); output.replaceChildren();
462
+ if (data.output_truncated) { drawRow(output, [[TRUNCATED.trim(), null]], 'note'); drawRow(want, null, 'note'); }
463
+ expected.rows.forEach(row => { drawRow(want, row.expected, row.tag); drawRow(output, row.actual, row.tag); });
464
+ return 'differs from expected';
465
+ };
466
+
467
+ // The sidebar mark of the step this page shows, unless the operator marked it.
468
+ const markStep = state => {
469
+ const mark = document.querySelector('.steps li.active .mark');
470
+ if (!state || !mark || mark.classList.contains('done') || mark.classList.contains('skipped')) return;
471
+ if (state === 'ran' && mark.classList.contains('failed')) return;
472
+ mark.className = 'mark ' + state; mark.title = state;
473
+ mark.textContent = state === 'ran' ? '•' : '✗';
474
+ };
475
+ const show = (block, data) => {
476
+ const status = block.querySelector('[data-role="status"]');
477
+ const result = block.querySelector('.rs-result');
478
+ const output = block.querySelector('[data-role="output"]');
479
+ const exit = block.querySelector('[data-role="exit"]');
480
+ const button = block.querySelector('[data-action="execute"]');
481
+ const stop = block.querySelector('[data-action="stop"]');
482
+ const id = block.dataset.block;
483
+ const panes = block.querySelector('.rs-panes');
484
+ result.hidden = false;
485
+ block.querySelector('[data-role="expected"]').hidden = true;
486
+ panes.classList.remove('has-expected');
487
+ output.textContent = (data.output_truncated ? TRUNCATED : '') + (data.output || '');
488
+ panes.scrollTop = panes.scrollHeight;
489
+ block.classList.remove('rs-ok', 'rs-failed', 'rs-stopped');
490
+ status.className = 'rs-status';
491
+ if (data.state === 'running') {
492
+ const elapsed = data.started_at ? (Date.now() - Date.parse(data.started_at)) / 1000 : null;
493
+ status.classList.add('running'); status.textContent = 'running ' + (elapsed != null ? fmt(elapsed) : '');
494
+ exit.textContent = 'pid ' + data.pid + ' · ' + data.command.join(' ');
495
+ if (button) button.disabled = true;
496
+ if (stop) { stop.hidden = false; stop.disabled = false; stop.dataset.execution = data.id; }
497
+ return true;
498
+ }
499
+ const ok = data.state === 'finished' && data.exit_status === 0;
500
+ const stopped = data.state === 'stopped';
501
+ block.classList.add(ok ? 'rs-ok' : stopped ? 'rs-stopped' : 'rs-failed');
502
+ status.classList.add(ok ? 'ok' : stopped ? 'stopped' : 'failed');
503
+ status.textContent = data.state === 'timed_out' ? 'timed out' : data.state === 'failed' ? 'failed to start' : stopped ? 'stopped' : (ok ? 'ok' : 'exit ' + data.exit_status);
504
+ const verdict = compare(block, id, data);
505
+ exit.textContent = [
506
+ data.state === 'failed' ? data.error : (stopped ? 'stopped' : 'exit ' + data.exit_status),
507
+ data.duration != null ? fmt(data.duration) : null,
508
+ data.finished_at ? 'finished ' + new Date(data.finished_at).toLocaleTimeString() : null,
509
+ data.log ? 'log ' + data.log : null,
510
+ verdict
511
+ ].filter(Boolean).join(' · ');
512
+ enable(button);
513
+ if (stop) stop.hidden = true;
514
+ markStep(ok ? 'ran' : stopped ? null : 'failed');
515
+ return false;
516
+ };
517
+
518
+ const showAck = (block, ack) => {
519
+ const status = block.querySelector('[data-role="status"]');
520
+ const result = block.querySelector('.rs-result');
521
+ const exit = block.querySelector('[data-role="exit"]');
522
+ const button = block.querySelector('[data-action="acknowledge"]');
523
+ block.classList.add('rs-acked');
524
+ status.className = 'rs-status acked'; status.textContent = 'confirmed';
525
+ result.hidden = false;
526
+ exit.textContent = ['confirmed ' + new Date(ack.at).toLocaleTimeString(), ack.note ? 'note: ' + ack.note : null].filter(Boolean).join(' · ');
527
+ if (button) button.textContent = 'Confirm again';
528
+ };
529
+
530
+ const fail = (block, label, err) => {
531
+ const status = block.querySelector('[data-role="status"]');
532
+ status.className = 'rs-status failed'; status.textContent = label;
533
+ block.querySelector('.rs-result').hidden = false;
534
+ block.querySelector('[data-role="exit"]').textContent = String(err.message || err);
535
+ block.querySelectorAll('[data-action="execute"], [data-action="acknowledge"]').forEach(enable);
536
+ };
537
+
538
+ // Re-enable a button unless the server locked it (no run, or not allowed in this run).
539
+ const enable = b => { if (b) b.disabled = b.hasAttribute('data-locked'); };
540
+
541
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
542
+
543
+ // Poll until the execution ends. Resolves with its final state, or null if polling failed.
544
+ const poll = async (block, id) => {
545
+ try {
546
+ for (;;) {
547
+ const res = await fetch('/executions/' + encodeURIComponent(id), { headers });
548
+ const data = await res.json();
549
+ if (!res.ok) throw new Error(data.error || res.statusText);
550
+ if (!show(block, data)) return data;
551
+ await sleep(document.hidden ? 3000 : 500);
552
+ }
553
+ } catch (err) {
554
+ fail(block, 'poll failed', err);
555
+ return null;
556
+ }
557
+ };
558
+
559
+ // Run the block; a destructive one needs the server's confirmation code typed back.
560
+ // Resolves with the final execution state, or null if it did not run.
561
+ const execute = async (block, id, button) => {
562
+ button.disabled = true;
563
+ const status = block.querySelector('[data-role="status"]');
564
+ status.className = 'rs-status running'; status.textContent = 'starting';
565
+ try {
566
+ const digest = block.dataset.digest;
567
+ let { res, data } = await post('/blocks/' + encodeURIComponent(id) + '/execute', { digest });
568
+ if (res.status === 428 && data.challenge) {
569
+ const typed = prompt('This block is destructive. Type the code ' + data.challenge + ' to run it.');
570
+ if (typed === null || typed.trim() !== data.challenge) { status.className = 'rs-status'; status.textContent = typed === null ? 'cancelled' : 'code did not match'; enable(button); return null; }
571
+ ({ res, data } = await post('/blocks/' + encodeURIComponent(id) + '/execute', { confirm: typed.trim(), digest }));
572
+ }
573
+ if (!res.ok) throw new Error(data.error || res.statusText);
574
+ return show(block, data) ? poll(block, data.id) : data;
575
+ } catch (err) {
576
+ fail(block, 'not run', err);
577
+ return null;
578
+ }
579
+ };
580
+
581
+ // Checks page: run every plain `run` block on the page, in order, one at a time.
582
+ const runAll = ours('[data-action="run-all"]')[0];
583
+ if (runAll) runAll.addEventListener('click', async () => {
584
+ const status = document.querySelector('[data-role="run-all-status"]');
585
+ const blocks = ours('.rs-block.rs-executable[data-kind="run"]');
586
+ runAll.disabled = true;
587
+ let ok = 0, failed = 0, skipped = 0;
588
+ for (const [i, block] of blocks.entries()) {
589
+ const button = block.querySelector('[data-action="execute"]');
590
+ if (!button) { skipped++; continue; }
591
+ status.textContent = 'running check ' + (i + 1) + ' of ' + blocks.length + '…';
592
+ block.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
593
+ const data = await execute(block, block.dataset.block, button);
594
+ if (data && data.state === 'finished' && data.exit_status === 0) ok++; else failed++;
595
+ }
596
+ status.textContent = blocks.length + ' check' + (blocks.length === 1 ? '' : 's') + ': ' + ok + ' ok, ' + failed + ' failed' + (skipped ? ', ' + skipped + ' skipped' : '');
597
+ status.className = 'meta ' + (failed ? 'failed' : 'ok');
598
+ runAll.disabled = false;
599
+ });
600
+
601
+ const stopExecution = async (executionId, button) => {
602
+ button.disabled = true;
603
+ try {
604
+ const { res, data } = await post('/executions/' + encodeURIComponent(executionId) + '/stop');
605
+ if (!res.ok) throw new Error(data.error || res.statusText);
606
+ button.textContent = 'Stopping…';
607
+ } catch (err) {
608
+ button.disabled = false; button.textContent = String(err.message || err);
609
+ }
610
+ };
611
+
612
+ const acknowledge = async (block, id, button) => {
613
+ const note = prompt('Confirm that you ran this in your terminal. Add a note if you like, or leave it blank.', '');
614
+ if (note === null) return;
615
+ button.disabled = true;
616
+ try {
617
+ const { res, data } = await post('/blocks/' + encodeURIComponent(id) + '/acknowledge', { note, digest: block.dataset.digest });
618
+ if (!res.ok) throw new Error(data.error || res.statusText);
619
+ showAck(block, data);
620
+ enable(button);
621
+ } catch (err) {
622
+ fail(block, 'not confirmed', err);
623
+ }
624
+ };
625
+
626
+ ours('.rs-block').forEach(block => {
627
+ const id = block.dataset.block;
628
+ const code = block.querySelector('pre');
629
+ block.querySelector('[data-action="copy"]')?.addEventListener('click', async e => {
630
+ try { await navigator.clipboard.writeText(code ? code.innerText : ''); e.target.textContent = 'Copied'; setTimeout(() => e.target.textContent = 'Copy', 1200); }
631
+ catch (err) { e.target.textContent = 'Copy failed'; }
632
+ });
633
+ const run = block.querySelector('[data-action="execute"]');
634
+ if (run) run.addEventListener('click', () => execute(block, id, run));
635
+ const stop = block.querySelector('[data-action="stop"]');
636
+ if (stop) stop.addEventListener('click', () => stopExecution(stop.dataset.execution, stop));
637
+ const ack = block.querySelector('[data-action="acknowledge"]');
638
+ if (ack) ack.addEventListener('click', () => acknowledge(block, id, ack));
639
+ const last = prior.executions[id];
640
+ if (last) { if (show(block, last)) poll(block, last.id); }
641
+ if (prior.acks[id]) showAck(block, prior.acks[id]);
642
+ });
643
+
644
+ // Running-processes panel in the sidebar.
645
+ ours('#rs-running li[data-execution]').forEach(li => {
646
+ const executionId = li.dataset.execution;
647
+ const stop = li.querySelector('[data-action="stop"]');
648
+ const status = li.querySelector('[data-role="running-status"]');
649
+ stop?.addEventListener('click', () => stopExecution(executionId, stop));
650
+ const tick = async () => {
651
+ try {
652
+ const res = await fetch('/executions/' + encodeURIComponent(executionId), { headers });
653
+ const data = await res.json();
654
+ if (!res.ok) throw new Error(data.error || res.statusText);
655
+ if (data.state === 'running') {
656
+ const elapsed = data.started_at ? (Date.now() - Date.parse(data.started_at)) / 1000 : null;
657
+ status.textContent = (data.background ? 'background' : 'running') + (elapsed != null ? ' · ' + fmt(elapsed) : '') + ' · pid ' + data.pid;
658
+ setTimeout(tick, 1000);
659
+ } else {
660
+ li.classList.add('ended');
661
+ status.textContent = data.state === 'stopped' ? 'stopped' : data.state === 'finished' ? 'exit ' + data.exit_status : data.state.replace('_', ' ');
662
+ if (stop) stop.remove();
663
+ }
664
+ } catch (err) { status.textContent = String(err.message || err); }
665
+ };
666
+ tick();
667
+ });
668
+
669
+ // Library tree: filter box, and a keyboard cursor over the visible
670
+ // nodes. Filtering opens every folder and hides what does not
671
+ // match; clearing it puts the folders back the way they were.
672
+ const tree = document.getElementById('lib-tree-nav');
673
+ const lib = tree ? (() => {
674
+ const filter = document.getElementById('lib-filter');
675
+ const empty = document.getElementById('lib-tree-empty');
676
+ const items = [...tree.querySelectorAll('li')];
677
+ const folders = items.filter(li => li.querySelector(':scope > details'));
678
+ let saved = null, cursor = -1;
679
+ const visible = () => items.filter(li => !li.hidden && li.offsetParent !== null);
680
+ const node = li => li.querySelector(':scope > .node, :scope > details > summary');
681
+ const apply = () => {
682
+ const q = filter.value.trim().toLowerCase();
683
+ if (q && !saved) saved = new Map(folders.map(li => [li, li.querySelector(':scope > details').open]));
684
+ body.classList.toggle('filtering', !!q);
685
+ let shown = 0;
686
+ items.filter(li => li.classList.contains('tree-runbook')).forEach(li => {
687
+ const hit = !q || (li.dataset.search || '').includes(q);
688
+ li.hidden = !hit; if (hit) shown++;
689
+ });
690
+ [...folders].reverse().forEach(li => {
691
+ const details = li.querySelector(':scope > details');
692
+ if (q) { details.open = true; li.hidden = !li.querySelector('ul li.tree-runbook:not([hidden])'); }
693
+ else { li.hidden = false; if (saved) details.open = saved.get(li); }
694
+ });
695
+ if (!q) saved = null;
696
+ if (empty) empty.hidden = !(q && shown === 0);
697
+ setCursor(-1);
698
+ };
699
+ const setCursor = i => {
700
+ items.forEach(li => li.classList.remove('focus'));
701
+ const list = visible();
702
+ cursor = list.length ? Math.max(-1, Math.min(i, list.length - 1)) : -1;
703
+ if (cursor >= 0) { list[cursor].classList.add('focus'); node(list[cursor])?.scrollIntoView({ block: 'nearest' }); }
704
+ };
705
+ const move = delta => {
706
+ const list = visible();
707
+ const from = cursor >= 0 ? cursor : list.findIndex(li => li.classList.contains('active'));
708
+ setCursor(from + delta);
709
+ };
710
+ const select = () => {
711
+ const list = visible();
712
+ const li = cursor >= 0 ? list[cursor] : null;
713
+ if (!li) return;
714
+ const a = li.querySelector(':scope > .node, :scope > details > summary > .name');
715
+ if (a) location.href = a.href;
716
+ };
717
+ const toggle = open => {
718
+ const li = visible()[cursor];
719
+ const details = li?.querySelector(':scope > details');
720
+ if (details) details.open = open;
721
+ };
722
+ filter.addEventListener('input', apply);
723
+ filter.addEventListener('keydown', e => {
724
+ if (e.key === 'ArrowDown') { e.preventDefault(); filter.blur(); move(1); }
725
+ if (e.key === 'Escape') { filter.value = ''; apply(); }
726
+ });
727
+ return { filter, move, select, toggle };
728
+ })() : null;
729
+
730
+ // Keyboard shortcuts.
731
+ const go = key => { const a = document.querySelector(`a[data-key="${key}"]`); if (a) location.href = a.href; };
732
+ const press = key => { const b = document.querySelector(`button[data-key="${key}"]`); if (b) b.click(); };
733
+ document.addEventListener('keydown', e => {
734
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
735
+ const tag = e.target.tagName;
736
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable) { if (e.key === 'Escape') e.target.blur(); return; }
737
+ const free = e.target === body || e.target === document.documentElement;
738
+ if (lib && (free || e.target.closest?.('.lib-tree'))) {
739
+ switch (e.key) {
740
+ case '/': e.preventDefault(); lib.filter.focus(); lib.filter.select(); return;
741
+ case 'ArrowDown': case 'j': e.preventDefault(); lib.move(1); return;
742
+ case 'ArrowUp': case 'k': e.preventDefault(); lib.move(-1); return;
743
+ case 'ArrowRight': lib.toggle(true); return;
744
+ case 'ArrowLeft': lib.toggle(false); return;
745
+ case 'Enter': if (e.target.closest?.('a, button')) return; lib.select(); return;
746
+ case 'o': press('o'); return;
747
+ case 'b': go('b'); return;
748
+ }
749
+ }
750
+ switch (e.key) {
751
+ case 'f': { const box = document.getElementById('rs-search'); if (box) { e.preventDefault(); box.focus(); box.select(); } break; }
752
+ case 'h': go('h'); break;
753
+ case 'r': go('r'); break;
754
+ case 'ArrowLeft': if (free) go('←'); break;
755
+ case 'ArrowRight': if (free) go('→'); break;
756
+ case 's': if (document.querySelector('.rs-sidebar')) toggleSidebar(); break;
757
+ case 'n': e.preventDefault(); location.href = '/session#notes'; break;
758
+ }
759
+ });
760
+
761
+ // Confirm before a form that cannot be undone (End session).
762
+ document.querySelectorAll('form[data-confirm]').forEach(form => form.addEventListener('submit', e => {
763
+ if (!confirm(form.dataset.confirm)) e.preventDefault();
764
+ }));
765
+
766
+ // Elapsed times keep counting.
767
+ const since = [...document.querySelectorAll('[data-since]')];
768
+ const span = s => s < 10 ? s.toFixed(1) + 's' : s < 60 ? Math.round(s) + 's'
769
+ : s < 3600 ? Math.floor(s / 60) + 'm ' + String(Math.floor(s % 60)).padStart(2, '0') + 's'
770
+ : Math.floor(s / 3600) + 'h ' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm';
771
+ const tick = () => since.forEach(el => { el.textContent = span((Date.now() - Date.parse(el.dataset.since)) / 1000); });
772
+ if (since.length) { tick(); setInterval(tick, 15000); }
773
+
774
+ // The session log tail opens at its newest line; #notes focuses the note box.
775
+ document.querySelectorAll('.session-log').forEach(pre => { pre.scrollTop = pre.scrollHeight; });
776
+ if (location.hash === '#notes') document.getElementById('note-text')?.focus();
777
+ })();
778
+ JS
779
+ end
780
+ end