@erclx/canon 5.77.0 → 5.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +7 -0
- package/claude/skills/code-craft/references/adopted.md +8 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/pr-evidence/SKILL.md +6 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -1
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-craft/references/ui.md +13 -1
- package/claude/skills/review-pr/references/body-variants.md +2 -0
- package/claude/skills/review-pr/references/close-out.md +2 -0
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-pr/references/post.md +1 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +2 -2
- package/claude/skills/review-ui/references/driving.md +32 -4
- package/claude/skills/review-ui/references/post.md +6 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/test-craft/SKILL.md +1 -0
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +2 -1
- package/claude/skills/ui-craft/references/capture.md +16 -0
- package/claude/skills/ui-film/REQUIREMENT.md +2 -2
- package/claude/skills/ui-film/SKILL.md +6 -1
- package/claude/skills/ui-film/references/compose-craft.md +68 -11
- package/claude/skills/ui-film/references/config.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +1 -1
- package/claude/skills/ui-film/references/harness.md +71 -0
- package/claude/skills/ui-film/references/take-craft.md +42 -17
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/operating-model.md +6 -5
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/skills/code-craft/SKILL.md +9 -0
- package/governance/ledger/skills/test-craft/SKILL.md +1 -0
- package/governance/ledger/skills/ui-craft/capture.md +18 -5
- package/package.json +4 -3
- package/src/commands/pr.ts +255 -46
- package/src/commands/sessions.ts +88 -6
- package/src/commands/take.ts +12 -1
- package/src/commands/tasks.ts +41 -2
- package/src/design/components.ts +5 -107
- package/src/design/favicon.ts +21 -17
- package/src/design/render.ts +9 -27
- package/src/design/template.ts +42 -0
- package/src/design/templates/favicon.svg +17 -0
- package/src/design/templates/hand-drawn-figure.css +54 -0
- package/src/design/templates/preview.html +27 -0
- package/src/design/templates/scrollbar.css +29 -0
- package/src/design/templates/status.css +24 -0
- package/src/gate/embedded.ts +400 -0
- package/src/gate/stages.ts +8 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +81 -13
- package/src/pr/frames.ts +1 -6
- package/src/pr/health.ts +63 -15
- package/src/pr/lift.ts +77 -9
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/pr/rest.ts +2 -0
- package/src/pr/review-scope.ts +28 -0
- package/src/pr/review-state.ts +5 -5
- package/src/pr/waiver.ts +45 -0
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/camera.ts +337 -208
- package/src/take/encode.ts +16 -42
- package/src/take/film.ts +7 -1
- package/src/take/frame-clock.ts +46 -0
- package/src/take/log.ts +43 -2
- package/src/take/perform.ts +85 -13
- package/src/take/plan.ts +26 -5
- package/src/take/reel.ts +30 -5
- package/src/take/screencast.ts +223 -13
- package/src/take/stepper.ts +24 -4
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/commands/fixtures/gh.sh +0 -64
- package/src/take/fixtures/busy.html +0 -107
- package/src/take/fixtures/fade.html +0 -49
- package/src/take/fixtures/fetch.html +0 -38
- package/src/take/fixtures/input.html +0 -138
- package/src/take/zoom/NOTICE.md +0 -45
- package/src/take/zoom/math-utils.ts +0 -69
- package/src/take/zoom/region-utils.ts +0 -55
- package/src/take/zoom/spring.ts +0 -133
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/take/screencast.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path'
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
4
3
|
import { chromium } from 'playwright-core'
|
|
5
|
-
import { lastFrame,
|
|
4
|
+
import { lastFrame, openFrameEncoder, probeFrames } from '@/take/encode'
|
|
5
|
+
import { frameStamp, slotFrames } from '@/take/frame-clock'
|
|
6
6
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
7
7
|
import {
|
|
8
8
|
type PointerInput,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
performSteps,
|
|
12
12
|
} from '@/take/perform'
|
|
13
13
|
import type { Beat } from '@/take/plan'
|
|
14
|
-
import { HOLD_MS } from '@/take/stepper'
|
|
14
|
+
import { HOLD_MS, withinCap } from '@/take/stepper'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Films a beat in real time through Playwright's screencast, for a page frame
|
|
@@ -27,9 +27,21 @@ const SETTLE_MS = 2500
|
|
|
27
27
|
/** How long a refusal waits on the screenshot it shows. */
|
|
28
28
|
const STILL_CAP_MS = 2000
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How long a move waits on the page's next animation frame before it goes
|
|
32
|
+
* anyway, since a page whose frames stall is the stepper's case and this route
|
|
33
|
+
* must still film.
|
|
34
|
+
*/
|
|
35
|
+
const ALIGN_CAP_MS = 100
|
|
36
|
+
|
|
37
|
+
/** How long the calibration waits on the page to paint its move. */
|
|
38
|
+
const CALIBRATE_CAP_MS = 500
|
|
39
|
+
|
|
30
40
|
export interface ScreencastTake {
|
|
31
41
|
readonly frames: number
|
|
32
42
|
readonly events: readonly LogEvent[]
|
|
43
|
+
/** What `events` were shifted by, absent when the page gave no frame to read. */
|
|
44
|
+
readonly lag?: number
|
|
33
45
|
/** The clip's closing frame as a JPEG. */
|
|
34
46
|
readonly still: Buffer
|
|
35
47
|
}
|
|
@@ -39,8 +51,13 @@ export async function filmScreencast(request: {
|
|
|
39
51
|
readonly beat: Beat
|
|
40
52
|
readonly clip: string
|
|
41
53
|
}): Promise<ScreencastTake> {
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
// Ahead of the launch, so a folder that refuses leaves no browser holding the
|
|
55
|
+
// process open past its refusal.
|
|
56
|
+
const scratch = openSpool(request.clip)
|
|
57
|
+
const browser = await chromium.launch().catch((error: unknown) => {
|
|
58
|
+
scratch.remove()
|
|
59
|
+
throw error
|
|
60
|
+
})
|
|
44
61
|
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
45
62
|
try {
|
|
46
63
|
const page = await browser.newPage({ viewport: VIEWPORT })
|
|
@@ -56,7 +73,11 @@ export async function filmScreencast(request: {
|
|
|
56
73
|
const stageOn = (clock: () => number): Stage => ({
|
|
57
74
|
now: clock,
|
|
58
75
|
hold: (ms) => page.waitForTimeout(ms),
|
|
76
|
+
align: () => nextFrame(page, ALIGN_CAP_MS).then(() => undefined),
|
|
59
77
|
pointer: async (input) => {
|
|
78
|
+
// A move waited on its frame already, so padding it would land the
|
|
79
|
+
// next one a frame late.
|
|
80
|
+
if (input.type === 'move') return send(page.mouse, input)
|
|
60
81
|
const sent = clock()
|
|
61
82
|
await send(page.mouse, input)
|
|
62
83
|
const spent = clock() - sent
|
|
@@ -79,11 +100,35 @@ export async function filmScreencast(request: {
|
|
|
79
100
|
const live = stageOn(() => performance.now())
|
|
80
101
|
await performSteps(live, request.beat.setup, { pointer: START })
|
|
81
102
|
if (request.beat.setup.length > 0) await live.hold(1000)
|
|
82
|
-
await page
|
|
103
|
+
const lag = await calibrate(page)
|
|
83
104
|
|
|
84
|
-
const webm = join(scratch, 'take.webm')
|
|
85
105
|
const events: LogEvent[] = []
|
|
86
|
-
|
|
106
|
+
const spool: SpooledFrame[] = []
|
|
107
|
+
let spoolFailure: unknown
|
|
108
|
+
await page.screencast.start({
|
|
109
|
+
size: VIEWPORT,
|
|
110
|
+
// The docs tie this to `path`, but 1.64.1 still reads it here, where it
|
|
111
|
+
// doubles the frames Chromium may hold unacknowledged.
|
|
112
|
+
fps: FPS,
|
|
113
|
+
// Spooled rather than held, since a busy page sends about 19 MB a second.
|
|
114
|
+
// The write stays synchronous, so the acknowledgement Chromium paces its
|
|
115
|
+
// frames on waits for no encoder. Playwright calls this from a listener
|
|
116
|
+
// nothing awaits, so a throw here would surface as an unhandled rejection.
|
|
117
|
+
onFrame: ({ data, timestamp }) => {
|
|
118
|
+
const stamp = frameStamp({
|
|
119
|
+
presentedWall: timestamp,
|
|
120
|
+
wallNow: Date.now(),
|
|
121
|
+
monoNow: performance.now(),
|
|
122
|
+
})
|
|
123
|
+
const path = join(scratch.folder, `${spool.length}.jpg`)
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(path, data)
|
|
126
|
+
spool.push({ path, stamp })
|
|
127
|
+
} catch (error) {
|
|
128
|
+
spoolFailure ??= error
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
})
|
|
87
132
|
const started = performance.now()
|
|
88
133
|
const recording = stageOn(() => performance.now() - started)
|
|
89
134
|
await recording.hold(HOLD_MS)
|
|
@@ -94,21 +139,186 @@ export async function filmScreencast(request: {
|
|
|
94
139
|
await recording.hold(HOLD_MS)
|
|
95
140
|
const filmedMs = recording.now()
|
|
96
141
|
await page.screencast.stop()
|
|
142
|
+
if (spoolFailure !== undefined) throw spoolFailure
|
|
97
143
|
|
|
98
|
-
await
|
|
144
|
+
await encodeSpool(spool, request.clip, started, filmedMs)
|
|
99
145
|
const frames = await probeFrames(request.clip)
|
|
100
|
-
|
|
146
|
+
const closing = await lastFrame(request.clip, frames)
|
|
147
|
+
if (lag === undefined) return { frames, events, still: closing }
|
|
148
|
+
return {
|
|
149
|
+
frames,
|
|
150
|
+
events: events.map((event) => shifted(event, lag)),
|
|
151
|
+
lag: Number(lag.toFixed(1)),
|
|
152
|
+
still: closing,
|
|
153
|
+
}
|
|
101
154
|
} catch (error) {
|
|
102
155
|
if (error instanceof TargetRefused && still) error.still = await still()
|
|
103
156
|
throw error
|
|
104
157
|
} finally {
|
|
105
158
|
await browser.close()
|
|
106
|
-
|
|
159
|
+
scratch.remove()
|
|
107
160
|
}
|
|
108
161
|
}
|
|
109
162
|
|
|
163
|
+
type Page = import('playwright-core').Page
|
|
110
164
|
type Mouse = import('playwright-core').Mouse
|
|
111
165
|
|
|
166
|
+
interface SpooledFrame {
|
|
167
|
+
readonly path: string
|
|
168
|
+
/** When the frame was presented, on the monotonic clock. */
|
|
169
|
+
readonly stamp: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Opens the folder holding one take's frames, beside its clip rather than in a
|
|
174
|
+
* temp folder nothing sweeps, since a 34 s beat on a busy page spools about
|
|
175
|
+
* 650 MB. A take killed outright runs no handler, so it leaves the folder where
|
|
176
|
+
* its owner looks, and the next take of that beat clears it.
|
|
177
|
+
*/
|
|
178
|
+
function openSpool(clip: string): { folder: string; remove: () => void } {
|
|
179
|
+
const folder = join(dirname(clip), 'spool')
|
|
180
|
+
rmSync(folder, { recursive: true, force: true })
|
|
181
|
+
mkdirSync(folder, { recursive: true })
|
|
182
|
+
const remove = (): void => {
|
|
183
|
+
process.off('exit', remove)
|
|
184
|
+
process.off('SIGINT', interrupt)
|
|
185
|
+
process.off('SIGTERM', interrupt)
|
|
186
|
+
rmSync(folder, { recursive: true, force: true })
|
|
187
|
+
}
|
|
188
|
+
// A signal skips the exit event, so it removes the folder and then re-raises
|
|
189
|
+
// itself with this handler gone, ending the process as it would have.
|
|
190
|
+
const interrupt = (signal: NodeJS.Signals): void => {
|
|
191
|
+
remove()
|
|
192
|
+
process.kill(process.pid, signal)
|
|
193
|
+
}
|
|
194
|
+
process.on('exit', remove)
|
|
195
|
+
process.once('SIGINT', interrupt)
|
|
196
|
+
process.once('SIGTERM', interrupt)
|
|
197
|
+
return { folder, remove }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Writes the clip from the spool, one frame per slot of the stage clock, so its
|
|
202
|
+
* length is the take's rather than whatever the frames span. Refuses when no
|
|
203
|
+
* frame arrived, since there is nothing true to show.
|
|
204
|
+
*/
|
|
205
|
+
async function encodeSpool(
|
|
206
|
+
spool: readonly SpooledFrame[],
|
|
207
|
+
clip: string,
|
|
208
|
+
startMs: number,
|
|
209
|
+
lengthMs: number,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const slots = slotFrames(
|
|
212
|
+
spool.map((frame) => frame.stamp),
|
|
213
|
+
{ startMs, lengthMs, frameMs: FRAME_MS },
|
|
214
|
+
)
|
|
215
|
+
if (slots.length === 0) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`the screencast delivered no frame in ${(lengthMs / 1000).toFixed(2)} s of filming`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const encoder = openFrameEncoder(clip, FPS)
|
|
221
|
+
try {
|
|
222
|
+
let shown = -1
|
|
223
|
+
let jpeg = Buffer.alloc(0)
|
|
224
|
+
for (const index of slots) {
|
|
225
|
+
const frame = spool[index]
|
|
226
|
+
if (frame && index !== shown) {
|
|
227
|
+
jpeg = readFileSync(frame.path)
|
|
228
|
+
shown = index
|
|
229
|
+
}
|
|
230
|
+
await encoder.write(jpeg)
|
|
231
|
+
}
|
|
232
|
+
await encoder.close()
|
|
233
|
+
} catch (error) {
|
|
234
|
+
encoder.abort()
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The page time of its next animation frame, or undefined when none comes
|
|
241
|
+
* within `capMs`, as on a hidden page, or the page closed first.
|
|
242
|
+
*/
|
|
243
|
+
function nextFrame(page: Page, capMs: number): Promise<number | undefined> {
|
|
244
|
+
return withinCap(
|
|
245
|
+
page
|
|
246
|
+
.evaluate(
|
|
247
|
+
() => new Promise<number>((done) => requestAnimationFrame(done)),
|
|
248
|
+
)
|
|
249
|
+
.catch(() => undefined),
|
|
250
|
+
capMs,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Where the calibration's armed listener hands back the frame it painted in. */
|
|
255
|
+
const PAINTED = '__canonTakePainted'
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* How long a move takes to show in the clip, measured by sending one aligned
|
|
259
|
+
* move to `START` and reading the animation frame after its handler against
|
|
260
|
+
* the frame it was aligned to. Both readings are page time, so no clock mapping
|
|
261
|
+
* is needed. The screencast captures a frame once it is presented, one page
|
|
262
|
+
* frame after that frame begins, so the lag counts one page frame past the
|
|
263
|
+
* readings, read off two consecutive animation frames rather than assumed from
|
|
264
|
+
* the clip's rate. That held a dragged box within 7 px on every run where the
|
|
265
|
+
* readings alone left 10 to 15. Undefined when the page gives no frame to read,
|
|
266
|
+
* and the pointer rests on `START` either way.
|
|
267
|
+
*/
|
|
268
|
+
async function calibrate(page: Page): Promise<number | undefined> {
|
|
269
|
+
await page.mouse.move(START.x - 1, START.y)
|
|
270
|
+
const interval = await withinCap(
|
|
271
|
+
page
|
|
272
|
+
.evaluate(
|
|
273
|
+
() =>
|
|
274
|
+
new Promise<number>((done) =>
|
|
275
|
+
requestAnimationFrame((first) =>
|
|
276
|
+
requestAnimationFrame((second) => done(second - first)),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
.catch(() => undefined),
|
|
281
|
+
2 * ALIGN_CAP_MS,
|
|
282
|
+
)
|
|
283
|
+
await page.evaluate((key) => {
|
|
284
|
+
const slot = window as unknown as Record<string, unknown>
|
|
285
|
+
slot[key] = new Promise<number>((done) => {
|
|
286
|
+
addEventListener('pointermove', () => requestAnimationFrame(done), {
|
|
287
|
+
once: true,
|
|
288
|
+
capture: true,
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
}, PAINTED)
|
|
292
|
+
const aligned = await nextFrame(page, ALIGN_CAP_MS)
|
|
293
|
+
await page.mouse.move(START.x, START.y)
|
|
294
|
+
const painted = await withinCap(
|
|
295
|
+
page
|
|
296
|
+
.evaluate((key) => {
|
|
297
|
+
const slot = window as unknown as Record<string, unknown>
|
|
298
|
+
const armed = slot[key] as Promise<number>
|
|
299
|
+
delete slot[key]
|
|
300
|
+
return armed
|
|
301
|
+
}, PAINTED)
|
|
302
|
+
.catch(() => undefined),
|
|
303
|
+
CALIBRATE_CAP_MS,
|
|
304
|
+
)
|
|
305
|
+
if (interval === undefined || aligned === undefined) return undefined
|
|
306
|
+
if (painted === undefined) return undefined
|
|
307
|
+
return Math.max(0, painted - aligned) + interval
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** An event moved `lag` later on the clip, with every point it sent. */
|
|
311
|
+
function shifted(event: LogEvent, lag: number): LogEvent {
|
|
312
|
+
if (event.kind === 'move' && event.points) {
|
|
313
|
+
return {
|
|
314
|
+
...event,
|
|
315
|
+
t: event.t + lag,
|
|
316
|
+
points: event.points.map((point) => ({ ...point, t: point.t + lag })),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { ...event, t: event.t + lag }
|
|
320
|
+
}
|
|
321
|
+
|
|
112
322
|
async function send(mouse: Mouse, input: PointerInput): Promise<void> {
|
|
113
323
|
switch (input.type) {
|
|
114
324
|
case 'move':
|
package/src/take/stepper.ts
CHANGED
|
@@ -187,10 +187,10 @@ export async function filmStepped(
|
|
|
187
187
|
async function frameStill(
|
|
188
188
|
beginFrame: (screenshot: boolean) => Promise<{ screenshotData?: string }>,
|
|
189
189
|
): Promise<Buffer | undefined> {
|
|
190
|
-
const reply = await
|
|
190
|
+
const reply = await withinCap(
|
|
191
191
|
beginFrame(true).catch(() => undefined),
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
STILL_CAP_MS,
|
|
193
|
+
)
|
|
194
194
|
return reply?.screenshotData
|
|
195
195
|
? Buffer.from(reply.screenshotData, 'base64')
|
|
196
196
|
: undefined
|
|
@@ -214,7 +214,7 @@ async function load(page: ShellPage, url: string): Promise<number> {
|
|
|
214
214
|
if (navigated.errorText) {
|
|
215
215
|
throw new Error(`${navigated.errorText} at ${url}`)
|
|
216
216
|
}
|
|
217
|
-
const timestamp = await
|
|
217
|
+
const timestamp = await withinCap(loaded, LOAD_CAP_MS)
|
|
218
218
|
if (typeof timestamp !== 'number') {
|
|
219
219
|
throw new Error(
|
|
220
220
|
`${url} fired no load event within ${LOAD_CAP_MS / 1000} s, so no frame clock could be read`,
|
|
@@ -381,6 +381,26 @@ async function withinStall<T>(
|
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Races `work` against a cap and resolves empty when the cap wins. The timer
|
|
386
|
+
* is cleared once the race settles, since a pending one keeps the process alive
|
|
387
|
+
* for the whole cap after filming ends.
|
|
388
|
+
*/
|
|
389
|
+
export async function withinCap<T>(
|
|
390
|
+
work: Promise<T>,
|
|
391
|
+
ms: number,
|
|
392
|
+
): Promise<T | undefined> {
|
|
393
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
394
|
+
const capped = new Promise<undefined>((done) => {
|
|
395
|
+
timer = setTimeout(() => done(undefined), ms)
|
|
396
|
+
})
|
|
397
|
+
try {
|
|
398
|
+
return await Promise.race([work, capped])
|
|
399
|
+
} finally {
|
|
400
|
+
clearTimeout(timer)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
384
404
|
function sleep(ms: number): Promise<void> {
|
|
385
405
|
return new Promise((done) => setTimeout(done, ms))
|
|
386
406
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git/env'
|
|
3
|
+
import {
|
|
4
|
+
repositoryOf,
|
|
5
|
+
resolveSessions,
|
|
6
|
+
type SessionReport,
|
|
7
|
+
} from '@/sessions/resolve'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bounds the one network read `canon tasks validate --live` takes, shorter than
|
|
11
|
+
* the archive's own, since the sweep waits on it and a slow answer reads the
|
|
12
|
+
* same as no answer.
|
|
13
|
+
*/
|
|
14
|
+
const GH_TIMEOUT_MS = 5_000
|
|
15
|
+
|
|
16
|
+
/** `gh pr list` caps at 30 by default, and a truncated list would read as clean. */
|
|
17
|
+
const PULL_LIMIT = 1_000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Who holds each branch, named for a person reading the report. `undefined`
|
|
21
|
+
* means the source could not be read, which has to stay apart from an empty map:
|
|
22
|
+
* an unreachable source that degraded to "nobody holds it" would report a held
|
|
23
|
+
* row as clean.
|
|
24
|
+
*/
|
|
25
|
+
export type HolderReader = () => Promise<
|
|
26
|
+
ReadonlyMap<string, string> | undefined
|
|
27
|
+
>
|
|
28
|
+
|
|
29
|
+
/** Whether a merged pull request names a branch, `undefined` when GitHub could not say. */
|
|
30
|
+
export type MergedReader = (branch: string) => Promise<boolean | undefined>
|
|
31
|
+
|
|
32
|
+
export interface LiveReaders {
|
|
33
|
+
readonly sessions: HolderReader
|
|
34
|
+
readonly pulls: HolderReader
|
|
35
|
+
readonly merged: MergedReader
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SessionHolderOptions {
|
|
39
|
+
readonly resolve?: () => Promise<SessionReport>
|
|
40
|
+
readonly repository?: (root: string) => Promise<string | null>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads the roster scoped to `root`'s repository, since a branch name says
|
|
45
|
+
* nothing across a machine and another project's worker on the same name holds
|
|
46
|
+
* nothing here. Two rows leave the liveness undecided, a foreign record whose
|
|
47
|
+
* socket is gone and a row resting only on a socket file a crash can leave, so
|
|
48
|
+
* either reads the whole roster as unread rather than as a hold or as clear.
|
|
49
|
+
*/
|
|
50
|
+
export function sessionHolders(
|
|
51
|
+
root: string,
|
|
52
|
+
opts: SessionHolderOptions = {},
|
|
53
|
+
): HolderReader {
|
|
54
|
+
const resolve = opts.resolve ?? resolveSessions
|
|
55
|
+
const repository = opts.repository ?? repositoryOf
|
|
56
|
+
|
|
57
|
+
return async () => {
|
|
58
|
+
try {
|
|
59
|
+
const [report, repo] = await Promise.all([resolve(), repository(root)])
|
|
60
|
+
if (
|
|
61
|
+
report.kind !== 'resolved' ||
|
|
62
|
+
report.foreignDead > 0 ||
|
|
63
|
+
report.sessions.some((session) => session.via === 'socket') ||
|
|
64
|
+
!repo
|
|
65
|
+
) {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Map(
|
|
70
|
+
report.sessions.flatMap((session) =>
|
|
71
|
+
session.repository === repo && session.branch
|
|
72
|
+
? [[session.branch, `session ${session.name}`] as const]
|
|
73
|
+
: [],
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type GhRunner = (
|
|
83
|
+
cwd: string,
|
|
84
|
+
args: readonly string[],
|
|
85
|
+
) => Promise<string | undefined>
|
|
86
|
+
|
|
87
|
+
const runGh: GhRunner = async (cwd, args) => {
|
|
88
|
+
if (Bun.which('gh') === null) return undefined
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// `gh` resolves its repository through git, so a hook's resolution
|
|
92
|
+
// variables would point it at another repository than `cwd`.
|
|
93
|
+
const result = await execa('gh', [...args], {
|
|
94
|
+
cwd,
|
|
95
|
+
timeout: GH_TIMEOUT_MS,
|
|
96
|
+
env: gitEnv(),
|
|
97
|
+
extendEnv: false,
|
|
98
|
+
})
|
|
99
|
+
return result.stdout
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ListedPull {
|
|
106
|
+
readonly number: number
|
|
107
|
+
readonly headRefName: string
|
|
108
|
+
readonly isCrossRepository: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isListedPull(value: unknown): value is ListedPull {
|
|
112
|
+
if (typeof value !== 'object' || value === null) return false
|
|
113
|
+
const pull = value as Record<string, unknown>
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
typeof pull.number === 'number' &&
|
|
117
|
+
typeof pull.headRefName === 'string' &&
|
|
118
|
+
typeof pull.isCrossRepository === 'boolean'
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parsePulls(stdout: string): readonly ListedPull[] | undefined {
|
|
123
|
+
try {
|
|
124
|
+
const parsed: unknown = JSON.parse(stdout)
|
|
125
|
+
return Array.isArray(parsed) && parsed.every(isListedPull)
|
|
126
|
+
? parsed
|
|
127
|
+
: undefined
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reads open pull requests off GitHub. A list reaching the limit may have cut
|
|
135
|
+
* off the one that holds a row, so it reads as unread. A fork's head names a
|
|
136
|
+
* branch in another repository, which holds nothing here however it is spelled.
|
|
137
|
+
*/
|
|
138
|
+
export function pullHolders(root: string, run: GhRunner = runGh): HolderReader {
|
|
139
|
+
return async () => {
|
|
140
|
+
const stdout = await run(root, [
|
|
141
|
+
'pr',
|
|
142
|
+
'list',
|
|
143
|
+
'--state',
|
|
144
|
+
'open',
|
|
145
|
+
'--limit',
|
|
146
|
+
String(PULL_LIMIT),
|
|
147
|
+
'--json',
|
|
148
|
+
'number,headRefName,isCrossRepository',
|
|
149
|
+
])
|
|
150
|
+
if (stdout === undefined) return undefined
|
|
151
|
+
|
|
152
|
+
const pulls = parsePulls(stdout)
|
|
153
|
+
if (!pulls || pulls.length >= PULL_LIMIT) return undefined
|
|
154
|
+
|
|
155
|
+
return new Map(
|
|
156
|
+
pulls
|
|
157
|
+
.filter((pull) => !pull.isCrossRepository)
|
|
158
|
+
.map((pull) => [pull.headRefName, `pull request #${pull.number}`]),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Asks about one branch at a time rather than listing merged pull requests,
|
|
165
|
+
* since a list long enough to reach an old merge costs every run, and only a
|
|
166
|
+
* branch a session already holds against a row takes this read.
|
|
167
|
+
*/
|
|
168
|
+
export function mergedBranches(
|
|
169
|
+
root: string,
|
|
170
|
+
run: GhRunner = runGh,
|
|
171
|
+
): MergedReader {
|
|
172
|
+
return async (branch) => {
|
|
173
|
+
const stdout = await run(root, [
|
|
174
|
+
'pr',
|
|
175
|
+
'list',
|
|
176
|
+
'--state',
|
|
177
|
+
'merged',
|
|
178
|
+
'--head',
|
|
179
|
+
branch,
|
|
180
|
+
'--limit',
|
|
181
|
+
String(PULL_LIMIT),
|
|
182
|
+
'--json',
|
|
183
|
+
'number,headRefName,isCrossRepository',
|
|
184
|
+
])
|
|
185
|
+
if (stdout === undefined) return undefined
|
|
186
|
+
|
|
187
|
+
const pulls = parsePulls(stdout)
|
|
188
|
+
return pulls?.some((pull) => !pull.isCrossRepository)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function liveReaders(root: string): LiveReaders {
|
|
193
|
+
return {
|
|
194
|
+
sessions: sessionHolders(root),
|
|
195
|
+
pulls: pullHolders(root),
|
|
196
|
+
merged: mergedBranches(root),
|
|
197
|
+
}
|
|
198
|
+
}
|