Public: anyone can read this; the crew can change it.
The direction's number says where its seconds went #129
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/122-the-direction-s-number-says-where-its-se → main 1 commit fork
3 files
changed +172
−19
src/core/runs.ts +48 −14
| @@ -616,19 +616,36 @@ export function latestAgentRunsForRepos( | ||
| 616 | 616 | return out; |
| 617 | 617 | } |
| 618 | 618 | |
| 619 | /** How fast this app's deploys have reached their preview: the median, and the count it is drawn from. */ | |
| 619 | /** | |
| 620 | * How fast this app's deploys have reached their preview: the total median | |
| 621 | * and the count it is drawn from, plus the same walk split at `started_at` | |
| 622 | * — the wait to be picked up, and the boot once it was. The split is its | |
| 623 | * own two medians, not half of the total's; they will not sum to it. | |
| 624 | */ | |
| 620 | 625 | export interface PreviewLatency { |
| 621 | 626 | readonly medianSeconds: number; |
| 622 | 627 | readonly n: number; |
| 628 | /** Undefined when no served deploy in the window also has a `started_at`. */ | |
| 629 | readonly queueMedianSeconds: number | undefined; | |
| 630 | readonly bootMedianSeconds: number | undefined; | |
| 631 | /** How many of `n` carried a `started_at` and so fed the split. */ | |
| 632 | readonly splitN: number; | |
| 623 | 633 | } |
| 624 | 634 | |
| 635 | const median = (xs: readonly number[]): number => { | |
| 636 | const mid = Math.floor(xs.length / 2); | |
| 637 | return xs.length % 2 === 0 ? (xs[mid - 1]! + xs[mid]!) / 2 : xs[mid]!; | |
| 638 | }; | |
| 639 | ||
| 625 | 640 | /** |
| 626 | 641 | * The median seconds from push (`created_at`) to serving (`serving_at`) |
| 627 | 642 | * over this repo's deploys since `since` — the same reference instant |
| 628 | 643 | * `servedPhrase` (`web/ui.ts`) words a single deploy from. A deploy that |
| 629 | * never served says nothing about speed and is left out, never a zero. | |
| 630 | * One query, bounded by `limit` and served by the `run_repo` index (it | |
| 631 | * walks newest-first and stops at the cap), never a scan of the table. | |
| 644 | * never served says nothing about speed and is left out, never a zero; | |
| 645 | * one that served but never records a `started_at` feeds the total but | |
| 646 | * not the split. One query, bounded by `limit` and served by the | |
| 647 | * `run_repo` index (it walks newest-first and stops at the cap), never a | |
| 648 | * scan of the table. | |
| 632 | 649 | */ |
| 633 | 650 | export function previewLatency( |
| 634 | 651 | db: Database, |
| @@ -636,22 +653,39 @@ export function previewLatency( | ||
| 636 | 653 | since: string, |
| 637 | 654 | limit = 500, |
| 638 | 655 | ): PreviewLatency | undefined { |
| 639 | const secs = db | |
| 640 | .query<{ secs: number }, [string, string, number]>( | |
| 641 | `SELECT (julianday(serving_at) - julianday(created_at)) * 86400 AS secs | |
| 656 | const rows = db | |
| 657 | .query< | |
| 658 | { secs: number; queueSecs: number | null; bootSecs: number | null }, | |
| 659 | [string, string, number] | |
| 660 | >( | |
| 661 | `SELECT (julianday(serving_at) - julianday(created_at)) * 86400 AS secs, | |
| 662 | CASE WHEN started_at IS NOT NULL | |
| 663 | THEN (julianday(started_at) - julianday(created_at)) * 86400 END AS queueSecs, | |
| 664 | CASE WHEN started_at IS NOT NULL | |
| 665 | THEN (julianday(serving_at) - julianday(started_at)) * 86400 END AS bootSecs | |
| 642 | 666 | FROM run |
| 643 | 667 | WHERE repo_id = ?1 AND kind = 'deploy' AND serving_at IS NOT NULL |
| 644 | 668 | AND created_at >= ?2 |
| 645 | 669 | ORDER BY id DESC LIMIT ?3`, |
| 646 | 670 | ) |
| 647 | .all(repoId, since, limit) | |
| 648 | .map((r) => r.secs) | |
| 671 | .all(repoId, since, limit); | |
| 672 | if (rows.length === 0) return undefined; | |
| 673 | const secs = rows.map((r) => r.secs).sort((a, b) => a - b); | |
| 674 | const queueSecs = rows | |
| 675 | .map((r) => r.queueSecs) | |
| 676 | .filter((s): s is number => s !== null) | |
| 649 | 677 | .sort((a, b) => a - b); |
| 650 | if (secs.length === 0) return undefined; | |
| 651 | const mid = Math.floor(secs.length / 2); | |
| 652 | const medianSeconds = | |
| 653 | secs.length % 2 === 0 ? (secs[mid - 1]! + secs[mid]!) / 2 : secs[mid]!; | |
| 654 | return { medianSeconds, n: secs.length }; | |
| 678 | const bootSecs = rows | |
| 679 | .map((r) => r.bootSecs) | |
| 680 | .filter((s): s is number => s !== null) | |
| 681 | .sort((a, b) => a - b); | |
| 682 | return { | |
| 683 | medianSeconds: median(secs), | |
| 684 | n: secs.length, | |
| 685 | queueMedianSeconds: queueSecs.length === 0 ? undefined : median(queueSecs), | |
| 686 | bootMedianSeconds: bootSecs.length === 0 ? undefined : median(bootSecs), | |
| 687 | splitN: queueSecs.length, | |
| 688 | }; | |
| 655 | 689 | } |
| 656 | 690 | |
| 657 | 691 | /** Agent runs queued or running across many repositories, newest first. */ |
src/web/studio/app.ts +13 −2
| @@ -78,15 +78,26 @@ const PREVIEW_LATENCY_MIN = 3; | ||
| 78 | 78 | * How fast a change has reached its preview this week, worded like the |
| 79 | 79 | * reference instant in `servedPhrase` (`web/ui.ts`): "reaches its preview |
| 80 | 80 | * in 6.1s". Too few served deploys to trust a median, and the line says so |
| 81 | * rather than printing one from a run or two. | |
| 81 | * rather than printing one from a run or two. Below the total, the same | |
| 82 | * walk split at `started_at` — waiting to be picked up, then coming up — | |
| 83 | * each its own median, not a piece of the total that ought to add up. | |
| 82 | 84 | */ |
| 83 | 85 | function previewLatencyWords(latency: PreviewLatency | undefined): Html { |
| 84 | 86 | if (latency === undefined || latency.n < PREVIEW_LATENCY_MIN) |
| 85 | 87 | return html`<span class="muted small" |
| 86 | 88 | >not enough deploys this week to say</span |
| 87 | 89 | >`; |
| 90 | const split = | |
| 91 | latency.queueMedianSeconds === undefined || | |
| 92 | latency.bootMedianSeconds === undefined | |
| 93 | ? null | |
| 94 | : html`${latency.queueMedianSeconds.toFixed(1)}s waiting, | |
| 95 | ${latency.bootMedianSeconds.toFixed(1)}s coming up${latency.splitN < | |
| 96 | latency.n | |
| 97 | ? ` (${latency.splitN} of ${latency.n})` | |
| 98 | : ""} · `; | |
| 88 | 99 | return html`reaches its preview in ${latency.medianSeconds.toFixed(1)}s |
| 89 | <span class="muted">· ${latency.n} deploys</span>`; | |
| 100 | <span class="muted">· ${split}${latency.n} deploys</span>`; | |
| 90 | 101 | } |
| 91 | 102 | |
| 92 | 103 | export async function studioApp( |
test/studio.test.ts +111 −3
| @@ -3184,12 +3184,21 @@ describe("previewLatency: the median seconds from push to serving", () => { | ||
| 3184 | 3184 | await rm(dataDir, { recursive: true, force: true }); |
| 3185 | 3185 | }); |
| 3186 | 3186 | |
| 3187 | /** A deploy run stamped with a known push instant and, unless null, a known serving instant. */ | |
| 3188 | const seedDeploy = (createdAt: string, servingAt: string | null): void => { | |
| 3187 | /** A deploy run stamped with a known push instant and, unless null, known started/serving instants. */ | |
| 3188 | const seedDeploy = ( | |
| 3189 | createdAt: string, | |
| 3190 | startedAt: string | null, | |
| 3191 | servingAt: string | null, | |
| 3192 | ): void => { | |
| 3189 | 3193 | const deploy = createRun(app.db, { repoId: repo.id, kind: "deploy" }); |
| 3190 | 3194 | app.db |
| 3191 | 3195 | .query("UPDATE run SET created_at = ?2 WHERE id = ?1") |
| 3192 | 3196 | .run(deploy.id, createdAt); |
| 3197 | if (startedAt !== null) { | |
| 3198 | app.db | |
| 3199 | .query("UPDATE run SET started_at = ?2 WHERE id = ?1") | |
| 3200 | .run(deploy.id, startedAt); | |
| 3201 | } | |
| 3193 | 3202 | if (servingAt !== null) { |
| 3194 | 3203 | app.db |
| 3195 | 3204 | .query("UPDATE run SET serving_at = ?2 WHERE id = ?1") |
| @@ -3207,12 +3216,13 @@ describe("previewLatency: the median seconds from push to serving", () => { | ||
| 3207 | 3216 | // Before the window: a very slow deploy that must not pull the median up. |
| 3208 | 3217 | seedDeploy( |
| 3209 | 3218 | new Date(now - 10 * 24 * 60 * 60_000).toISOString(), |
| 3219 | null, | |
| 3210 | 3220 | new Date(now - 10 * 24 * 60 * 60_000 + 600_000).toISOString(), |
| 3211 | 3221 | ); |
| 3212 | 3222 | expect(previewLatency(app.db, repo.id, since)).toBeUndefined(); |
| 3213 | 3223 | |
| 3214 | 3224 | // A deploy that never served is left out, not counted as a zero. |
| 3215 | seedDeploy(new Date(now).toISOString(), null); | |
| 3225 | seedDeploy(new Date(now).toISOString(), null, null); | |
| 3216 | 3226 | expect(previewLatency(app.db, repo.id, since)).toBeUndefined(); |
| 3217 | 3227 | |
| 3218 | 3228 | // Three served deploys, seeded out of order: an odd-count median. |
| @@ -3221,6 +3231,7 @@ describe("previewLatency: the median seconds from push to serving", () => { | ||
| 3221 | 3231 | const createdAt = new Date(now + i * 1_000).toISOString(); |
| 3222 | 3232 | seedDeploy( |
| 3223 | 3233 | createdAt, |
| 3234 | null, | |
| 3224 | 3235 | new Date(Date.parse(createdAt) + d * 1_000).toISOString(), |
| 3225 | 3236 | ); |
| 3226 | 3237 | }); |
| @@ -3232,11 +3243,58 @@ describe("previewLatency: the median seconds from push to serving", () => { | ||
| 3232 | 3243 | const createdAt = new Date(now + 3_000).toISOString(); |
| 3233 | 3244 | seedDeploy( |
| 3234 | 3245 | createdAt, |
| 3246 | null, | |
| 3235 | 3247 | new Date(Date.parse(createdAt) + 10_000).toISOString(), |
| 3236 | 3248 | ); |
| 3237 | 3249 | const even = previewLatency(app.db, repo.id, since)!; |
| 3238 | 3250 | expect(even.n).toBe(4); |
| 3239 | 3251 | expect(even.medianSeconds).toBeCloseTo(5, 2); |
| 3252 | ||
| 3253 | // None of the above carried a `started_at`: the split has nothing to say. | |
| 3254 | expect(even.queueMedianSeconds).toBeUndefined(); | |
| 3255 | expect(even.bootMedianSeconds).toBeUndefined(); | |
| 3256 | expect(even.splitN).toBe(0); | |
| 3257 | }); | |
| 3258 | ||
| 3259 | test("the split between waiting to be picked up and coming up, seeded beside a total", () => { | |
| 3260 | const now = Date.now() + 10_000_000; // clear of the previous test's window | |
| 3261 | const since = new Date(now - 60_000).toISOString(); | |
| 3262 | ||
| 3263 | // Three deploys with known queue and boot segments: (queue, boot) in seconds. | |
| 3264 | const segments: readonly (readonly [number, number])[] = [ | |
| 3265 | [1, 3], | |
| 3266 | [2, 6], | |
| 3267 | [3, 9], | |
| 3268 | ]; | |
| 3269 | segments.forEach(([queue, boot], i) => { | |
| 3270 | const createdAt = now + i * 1_000; | |
| 3271 | const startedAt = createdAt + queue * 1_000; | |
| 3272 | const servingAt = startedAt + boot * 1_000; | |
| 3273 | seedDeploy( | |
| 3274 | new Date(createdAt).toISOString(), | |
| 3275 | new Date(startedAt).toISOString(), | |
| 3276 | new Date(servingAt).toISOString(), | |
| 3277 | ); | |
| 3278 | }); | |
| 3279 | const full = previewLatency(app.db, repo.id, since)!; | |
| 3280 | expect(full.n).toBe(3); | |
| 3281 | expect(full.splitN).toBe(3); | |
| 3282 | expect(full.queueMedianSeconds).toBeCloseTo(2, 2); | |
| 3283 | expect(full.bootMedianSeconds).toBeCloseTo(6, 2); | |
| 3284 | ||
| 3285 | // A fourth, served deploy with no `started_at`: it feeds the total but | |
| 3286 | // is left out of the split, never counted as a zero-length queue or boot. | |
| 3287 | const createdAt = now + 3_000; | |
| 3288 | seedDeploy( | |
| 3289 | new Date(createdAt).toISOString(), | |
| 3290 | null, | |
| 3291 | new Date(createdAt + 100_000).toISOString(), | |
| 3292 | ); | |
| 3293 | const withGap = previewLatency(app.db, repo.id, since)!; | |
| 3294 | expect(withGap.n).toBe(4); | |
| 3295 | expect(withGap.splitN).toBe(3); | |
| 3296 | expect(withGap.queueMedianSeconds).toBeCloseTo(2, 2); | |
| 3297 | expect(withGap.bootMedianSeconds).toBeCloseTo(6, 2); | |
| 3240 | 3298 | }); |
| 3241 | 3299 | }); |
| 3242 | 3300 | |
| @@ -3318,6 +3376,56 @@ describe("the studio's own words for how fast a change reaches its preview", () | ||
| 3318 | 3376 | expect(one).toContain("not enough deploys this week to say"); |
| 3319 | 3377 | expect(one).not.toContain("reaches its preview in"); |
| 3320 | 3378 | }); |
| 3379 | ||
| 3380 | test("the split reads as the total's explanation, and says when it is drawn from fewer deploys", async () => { | |
| 3381 | const seedDeploy = ( | |
| 3382 | createdAt: string, | |
| 3383 | startedAt: string | null, | |
| 3384 | servingAt: string, | |
| 3385 | ): void => { | |
| 3386 | const deploy = createRun(app.db, { repoId: repo.id, kind: "deploy" }); | |
| 3387 | app.db | |
| 3388 | .query("UPDATE run SET created_at = ?2 WHERE id = ?1") | |
| 3389 | .run(deploy.id, createdAt); | |
| 3390 | if (startedAt !== null) { | |
| 3391 | app.db | |
| 3392 | .query("UPDATE run SET started_at = ?2 WHERE id = ?1") | |
| 3393 | .run(deploy.id, startedAt); | |
| 3394 | } | |
| 3395 | app.db | |
| 3396 | .query("UPDATE run SET serving_at = ?2 WHERE id = ?1") | |
| 3397 | .run(deploy.id, servingAt); | |
| 3398 | }; | |
| 3399 | // Three deploys with a known queue and boot each. | |
| 3400 | const now = Date.now(); | |
| 3401 | const segments: readonly (readonly [number, number])[] = [ | |
| 3402 | [1, 3], | |
| 3403 | [2, 6], | |
| 3404 | [3, 9], | |
| 3405 | ]; | |
| 3406 | segments.forEach(([queue, boot], i) => { | |
| 3407 | const createdAt = now + i * 1_000; | |
| 3408 | const startedAt = createdAt + queue * 1_000; | |
| 3409 | const servingAt = startedAt + boot * 1_000; | |
| 3410 | seedDeploy( | |
| 3411 | new Date(createdAt).toISOString(), | |
| 3412 | new Date(startedAt).toISOString(), | |
| 3413 | new Date(servingAt).toISOString(), | |
| 3414 | ); | |
| 3415 | }); | |
| 3416 | // A fourth, served deploy with no `started_at`: it feeds the total but | |
| 3417 | // is left out of the split. | |
| 3418 | seedDeploy( | |
| 3419 | new Date(now + 3_000).toISOString(), | |
| 3420 | null, | |
| 3421 | new Date(now + 100_000).toISOString(), | |
| 3422 | ); | |
| 3423 | const page = await (await get("/alice/trickle")).text(); | |
| 3424 | expect(page).toContain("4 deploys"); | |
| 3425 | expect(page).toContain("2.0s waiting"); | |
| 3426 | // Only 3 of the 4 served deploys carried a `started_at`: the split says so. | |
| 3427 | expect(page).toContain("6.0s coming up (3 of 4)"); | |
| 3428 | }); | |
| 3321 | 3429 | }); |
| 3322 | 3430 | |
| 3323 | 3431 | describe("this week's runs and spend are counted by the database, not read off a page of runs", () => { |
new above
offline