Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / src / replay.ts
web/src/replay.ts370 lines · 14925 bytes
1// Playback of a proposal that already finished, on a virtual block clock: offline mode plays the recorded run in a
2// loop; live mode plays recent proposals when the viewer presses P, Space replays the selected one, and a card of the
3// attack picker (lab.ts) replays the newest real proposal of that attack with its own timing (PlayOpts). Only data
4// already read is used (proposal block, readyBlock, each verdict's block and tx, ReasonAttached, Cleared, Executed).
5// The clock holds on the proposal, the verdict and the execution for a moment (dwellMs), so the screen can animate
6// them; the blocks in between (the cooldown) run at msPerBlock.
7// Pure logic: no DOM, no timers, no store; the caller passes the time in (tests drive it directly).
8import { GUARDS, type GuardId } from './runtime'
9import type { GuardAction, Proposal } from './store'
10
11export type StepKind = 'proposed' | 'veto' | 'reason' | 'clear' | 'ready' | 'executed'
12export interface TLStep {
13 block: number
14 kind: StepKind
15 guard?: GuardId
16}
17/** ejected: a guardian vetoed it; cleared: a clearance landed (executed or not) */
18export type ReplayType = 'ejected' | 'cleared'
19export interface Timeline {
20 id: string
21 type: ReplayType
22 /** virtual clock start: proposeBlock - LEAD_BLOCKS */
23 start: number
24 /** virtual clock end: the last step + TAIL_BLOCKS (at least readyBlock + TAIL_BLOCKS) */
25 end: number
26 proposeBlock: number
27 readyBlock: number
28 steps: TLStep[]
29}
30
31export const LEAD_BLOCKS = 2
32export const TAIL_BLOCKS = 3
33/** An ejected proposal is final at its veto: the playback ends this many blocks later, without the rest of the cooldown */
34export const EJECT_TAIL_BLOCKS = 5
35/** Events up to this many blocks after readyBlock stretch the timeline; later ones are pinned to its end (their text
36 * keeps the real block number) */
37const LATE_WINDOW = 40
38const ORDER: Record<StepKind, number> = { proposed: 0, veto: 1, reason: 2, clear: 3, ready: 4, executed: 5 }
39
40const vetoBlock = (a: GuardAction | undefined): number | null => (a?.verdict === 'veto' && typeof a.block === 'number' && a.block > 0 ? a.block : null)
41
42export function replayType(p: Proposal): ReplayType | null {
43 if (!p.proposeBlock || !p.readyBlock || p.readyBlock < p.proposeBlock) return null
44 if (GUARDS.some((g) => vetoBlock(p.guards[g]) !== null)) return 'ejected'
45 if (p.clearance?.block) return 'cleared'
46 return null
47}
48
49export function buildTimeline(p: Proposal): Timeline | null {
50 const type = replayType(p)
51 if (!type) return null
52 const pb = p.proposeBlock!
53 const rb = p.readyBlock!
54 const raw: TLStep[] = [{ block: pb, kind: 'proposed' }]
55 for (const g of GUARDS) {
56 const b = vetoBlock(p.guards[g])
57 if (b !== null) raw.push({ block: b, kind: 'veto', guard: g })
58 }
59 if (p.reason) raw.push({ block: p.reason.block, kind: 'reason' })
60 // a vetoed proposal can not be cleared afterwards; a clearance before the veto is still shown
61 if (p.clearance?.block) raw.push({ block: p.clearance.block, kind: 'clear' })
62 if (type === 'cleared') raw.push({ block: rb, kind: 'ready' })
63 if (type === 'cleared' && p.execute) raw.push({ block: p.execute.block, kind: 'executed' })
64 const inWindow = raw.filter((s) => s.block <= rb + LATE_WINDOW).map((s) => s.block)
65 const end = type === 'ejected' ? Math.min(rb + TAIL_BLOCKS, Math.max(...inWindow) + EJECT_TAIL_BLOCKS) : Math.max(rb, ...inWindow) + TAIL_BLOCKS
66 const steps = raw.map((s) => ({ ...s, block: Math.min(end, Math.max(pb, s.block)) }))
67 steps.sort((a, b) => a.block - b.block || ORDER[a.kind] - ORDER[b.kind] || String(a.guard).localeCompare(String(b.guard)))
68 return { id: p.id, type, start: pb - LEAD_BLOCKS, end, proposeBlock: pb, readyBlock: rb, steps }
69}
70
71/** Proposals that can be played, oldest first */
72export function playable(proposals: Iterable<Proposal>): Proposal[] {
73 return [...proposals].filter((p) => replayType(p) !== null).sort((a, b) => Number(a.id) - Number(b.id))
74}
75
76/** What the proposal looked like at virtual block v (only steps already reached) */
77export function snapshotAt(src: Proposal, tl: Timeline, v: number): Proposal {
78 const reached = (kind: StepKind, g?: GuardId) => tl.steps.some((st) => st.kind === kind && (g === undefined || st.guard === g) && st.block <= v)
79 const proposed = v >= tl.proposeBlock
80 const guards = {} as Record<GuardId, GuardAction>
81 let vetoes = 0
82 for (const g of GUARDS) {
83 const a = src.guards[g]
84 if (vetoBlock(a) !== null && reached('veto', g)) {
85 guards[g] = { ...a }
86 vetoes++
87 } else if (g === 'cre' && a?.verdict === 'pass' && reached('clear')) guards[g] = { ...a }
88 else guards[g] = {}
89 }
90 const cleared = reached('clear') && !!src.clearance
91 const executed = tl.type === 'cleared' && reached('executed')
92 const status = !proposed ? null : vetoes > 0 ? 2 : executed ? 3 : 1
93 return {
94 ...src,
95 proposeTx: proposed ? src.proposeTx : undefined,
96 guards,
97 clearance: cleared ? src.clearance : undefined,
98 reason: reached('reason') ? src.reason : undefined,
99 execute: executed ? src.execute : undefined,
100 chain:
101 status === null
102 ? undefined
103 : { status, vetoCount: vetoes, proposer: src.chain?.proposer ?? '', clearanceOf: cleared ? (src.clearance?.evidenceHash ?? null) : null },
104 }
105}
106
107export interface ReplayFrame {
108 tl: Timeline
109 /** virtual block number (not the chain head) */
110 vblock: number
111 p: Proposal
112 /** the clock is frozen (P while a playback runs) */
113 paused: boolean
114 /** played to the end; holding the final state */
115 hold: boolean
116}
117
118export interface ReplayCtx {
119 proposals: Iterable<Proposal>
120 get(id: string): Proposal | undefined
121 reduced: boolean
122 /** live mode: a live proposal is in its cooldown, so nothing is replayed (live always wins) */
123 liveBusy: boolean
124}
125
126export interface ReplayOptions {
127 msPerBlock: number
128 /** hold the clock on these steps (ms); omitted = no hold. judging: on the block before the verdict (the CRE is at
129 * work), so a verdict one block after the proposal still shows the workflow running */
130 dwellMs?: { lead?: number; proposed?: number; judging?: number; verdict?: number; executed?: number }
131 /** wait this long after page load before the first playback */
132 firstDelayMs: number
133 /** wait this long after a live event (or a manual pick) before playing again */
134 idleMs: number
135 gapMs: number
136 reducedHoldMs: number
137}
138
139/** Timing for one playOne() call (the attack picker): a slower lead-in, longer holds and a fast-forwarded cooldown */
140export interface PlayOpts {
141 msPerBlock?: number
142 /** ms per block once the verdict has landed (the rest of the cooldown); omitted = msPerBlock */
143 fastMsPerBlock?: number
144 /** lead: an extra hold at the start, while the proposal approaches the outer door */
145 dwellMs?: { lead?: number; proposed?: number; judging?: number; verdict?: number; executed?: number }
146 /** hold the final state this long after the last block */
147 gapMs?: number
148}
149
150interface Seg {
151 tl: Timeline
152 t0: number
153 reduced: boolean
154 logged: number
155 /** block -> hold (ms) when the clock reaches it */
156 dwell: Map<number, number>
157 /** ms per block before / from fastFrom on */
158 ms: number
159 fastMs: number
160 fastFrom: number
161 gap: number
162 /** Space: one proposal on demand; it plays even while the loop is paused, and the loop does not follow it */
163 once?: boolean
164}
165
166/** Playback controller: tick(now) advances; onLive(now) interrupts; toggle(now) pauses and resumes (P key) */
167export class AutoReplay {
168 paused = false
169 private seg: Seg | null = null
170 private frozen: number | null = null
171 private lastLive = 0
172 private lastId: string | null = null
173 private force = false
174
175 private readonly loadTs: number
176 private readonly opt: ReplayOptions
177 private readonly onStep: (tl: Timeline, step: TLStep, src: Proposal) => void
178
179 constructor(loadTs: number, opt: ReplayOptions, onStep: (tl: Timeline, step: TLStep, src: Proposal) => void = () => {}) {
180 this.loadTs = loadTs
181 this.opt = opt
182 this.onStep = onStep
183 }
184
185 get active(): boolean {
186 return this.seg !== null
187 }
188
189 /** A live event: stop playing now; the idle time counts from here */
190 onLive(now: number) {
191 this.lastLive = now
192 this.seg = null
193 this.frozen = null
194 this.force = false
195 }
196
197 /** The viewer picked a proposal: same as a live event, and the next playback starts after that proposal */
198 stop(now: number, pickedId?: string) {
199 this.onLive(now)
200 if (pickedId) this.lastId = pickedId
201 }
202
203 private idleEnough(now: number): boolean {
204 return this.lastLive ? now - this.lastLive >= this.opt.idleMs : now - this.loadTs >= this.opt.firstDelayMs
205 }
206
207 /** The next playback starts with this proposal (offline ?p=) */
208 startAt(id: string) {
209 this.lastId = String(Number(id) - 1)
210 }
211
212 /** Space or a picker card: play this proposal now, whether or not the loop is paused; a live event still
213 * interrupts it. opts overrides the timing for this one playback only. */
214 playOne(now: number, p: Proposal, reduced: boolean, opts?: PlayOpts): boolean {
215 const tl = buildTimeline(p)
216 if (!tl) return false
217 this.seg = this.newSeg(tl, now, reduced, opts)
218 this.seg.once = true
219 this.frozen = null
220 this.force = false
221 return true
222 }
223
224 private newSeg(tl: Timeline, now: number, reduced: boolean, opts?: PlayOpts): Seg {
225 const d = opts?.dwellMs ?? this.opt.dwellMs ?? {}
226 const dwell = new Map<number, number>()
227 const add = (b: number, ms: number | undefined) => {
228 if (ms && ms > 0) dwell.set(b, (dwell.get(b) ?? 0) + ms)
229 }
230 add(tl.start, d.lead)
231 add(tl.proposeBlock, d.proposed)
232 const verdict = tl.steps.find((s) => s.kind === 'veto' || s.kind === 'clear')
233 if (verdict) {
234 add(Math.max(tl.proposeBlock, verdict.block - 1), d.judging)
235 add(verdict.block, d.verdict)
236 }
237 const exec = tl.steps.find((s) => s.kind === 'executed')
238 if (exec) add(exec.block, d.executed)
239 const ms = opts?.msPerBlock ?? this.opt.msPerBlock
240 // the fast clock starts on the block after the verdict, so the verdict itself plays at the normal pace
241 const fastFrom = verdict && opts?.fastMsPerBlock ? verdict.block + 1 : Infinity
242 return { tl, t0: now, reduced, logged: 0, dwell, ms, fastMs: opts?.fastMsPerBlock ?? ms, fastFrom, gap: opts?.gapMs ?? this.opt.gapMs }
243 }
244
245 /** Pause: freeze the virtual block and do not start on its own; resume: continue, or start right away */
246 toggle(now: number) {
247 if (!this.paused) {
248 this.paused = true
249 if (this.seg) this.frozen = now - this.seg.t0
250 return
251 }
252 this.paused = false
253 if (this.seg && this.frozen !== null) this.seg.t0 = now - this.frozen
254 else this.force = true
255 this.frozen = null
256 }
257
258 private vblockAt(now: number): number {
259 const s = this.seg!
260 if (s.reduced) return s.tl.end
261 let t = this.frozen ?? now - s.t0
262 // each block: first its hold (if any), then msPerBlock to the next one
263 for (let b = s.tl.start; b < s.tl.end; b++) {
264 t -= s.dwell.get(b) ?? 0
265 if (t < 0) return b
266 t -= b >= s.fastFrom ? s.fastMs : s.ms
267 if (t < 0) return b
268 }
269 return s.tl.end
270 }
271
272 private segDurationMs(s: Seg): number {
273 if (s.reduced) return this.opt.reducedHoldMs
274 let ms = 0
275 for (let b = s.tl.start; b < s.tl.end; b++) ms += (s.dwell.get(b) ?? 0) + (b >= s.fastFrom ? s.fastMs : s.ms)
276 return ms + s.gap
277 }
278
279 /** ms from the playback's start until the clock reaches block b (before that block's hold) */
280 private timeTo(s: Seg, b: number): number {
281 let ms = 0
282 for (let x = s.tl.start; x < Math.min(b, s.tl.end); x++) ms += (s.dwell.get(x) ?? 0) + (x >= s.fastFrom ? s.fastMs : s.ms)
283 return ms
284 }
285
286 /** How far the current playback is from the proposal's arrival to its verdict (0..1): the verdict card walks
287 * through the workflow's steps meanwhile. null when nothing plays, the timeline has no verdict, or motion is
288 * reduced. */
289 judgeProgress(now: number): number | null {
290 const s = this.seg
291 if (!s || s.reduced) return null
292 const verdict = s.tl.steps.find((x) => x.kind === 'veto' || x.kind === 'clear')
293 if (!verdict) return null
294 const a = this.timeTo(s, s.tl.proposeBlock)
295 const b = this.timeTo(s, verdict.block)
296 if (b <= a) return 1
297 const t = this.frozen ?? now - s.t0
298 return Math.max(0, Math.min(1, (t - a) / (b - a)))
299 }
300
301 /** How far the current playback is (0..1, the final hold included); null when nothing plays */
302 progress(now: number): { id: string; f: number } | null {
303 const s = this.seg
304 if (!s) return null
305 const t = this.frozen ?? now - s.t0
306 return { id: s.tl.id, f: Math.max(0, Math.min(1, t / this.segDurationMs(s))) }
307 }
308
309 /** The current playback was started on demand (Space or a picker card), not by the loop */
310 get onDemand(): boolean {
311 return !!this.seg?.once
312 }
313
314 private startNext(now: number, ctx: ReplayCtx): boolean {
315 const list = playable(ctx.proposals)
316 if (!list.length) return false
317 const i = this.lastId === null ? -1 : list.findIndex((p) => Number(p.id) > Number(this.lastId))
318 const pick = i >= 0 ? list[i] : list[0]
319 const tl = buildTimeline(pick)
320 if (!tl) return false
321 this.lastId = pick.id
322 this.seg = this.newSeg(tl, now, ctx.reduced)
323 this.frozen = null
324 this.force = false
325 return true
326 }
327
328 /** Advance the clock; returns whether the page must redraw */
329 tick(now: number, ctx: ReplayCtx): boolean {
330 const once = !!this.seg?.once
331 if (ctx.liveBusy && !once) {
332 if (!this.seg) return false
333 this.seg = null
334 this.frozen = null
335 return true
336 }
337 if (this.paused && !once) return false
338 let changed = false
339 if (this.seg && this.frozen === null && now - this.seg.t0 >= this.segDurationMs(this.seg)) {
340 // after a Space replay the loop waits its idle time again before it carries on
341 if (this.seg.once) this.lastLive = now
342 this.seg = null
343 changed = true
344 }
345 if (!this.seg) {
346 if (this.paused) return changed
347 if (!this.force && !this.idleEnough(now)) return changed
348 if (!this.startNext(now, ctx)) return changed
349 changed = true
350 }
351 const s = this.seg!
352 const v = this.vblockAt(now)
353 const src = ctx.get(s.tl.id)
354 while (s.logged < s.tl.steps.length && s.tl.steps[s.logged].block <= v) {
355 if (src) this.onStep(s.tl, s.tl.steps[s.logged], src)
356 s.logged++
357 changed = true
358 }
359 return changed
360 }
361
362 /** What the page shows while playing; null when not playing */
363 frame(now: number, get: (id: string) => Proposal | undefined): ReplayFrame | null {
364 if (!this.seg) return null
365 const src = get(this.seg.tl.id)
366 if (!src) return null
367 const vblock = this.vblockAt(now)
368 return { tl: this.seg.tl, vblock, p: snapshotAt(src, this.seg.tl, vblock), paused: this.frozen !== null, hold: vblock >= this.seg.tl.end }
369 }
370}