Public: anyone can read this; the crew can change it.
A deploy that never reached its preview is counted, not quietly dropped #141
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/123-a-deploy-that-never-reached-its-preview → main 1 commit fork
3 files
changed +168
−22
src/core/runs.ts +36 −17
| @@ -621,15 +621,21 @@ export function latestAgentRunsForRepos( | ||
| 621 | 621 | * and the count it is drawn from, plus the same walk split at `started_at` |
| 622 | 622 | * — the wait to be picked up, and the boot once it was. The split is its |
| 623 | 623 | * own two medians, not half of the total's; they will not sum to it. |
| 624 | * Beside them, how many of this window's deploys finished without ever | |
| 625 | * serving — counted, never folded into the median a dead deploy says | |
| 626 | * nothing about. | |
| 624 | 627 | */ |
| 625 | 628 | export interface PreviewLatency { |
| 626 | readonly medianSeconds: number; | |
| 629 | /** Undefined only when nothing served in the window — `n` is then 0. */ | |
| 630 | readonly medianSeconds: number | undefined; | |
| 627 | 631 | readonly n: number; |
| 628 | 632 | /** Undefined when no served deploy in the window also has a `started_at`. */ |
| 629 | 633 | readonly queueMedianSeconds: number | undefined; |
| 630 | 634 | readonly bootMedianSeconds: number | undefined; |
| 631 | 635 | /** How many of `n` carried a `started_at` and so fed the split. */ |
| 632 | 636 | readonly splitN: number; |
| 637 | /** Deploys in the window that finished (`ok`, `failed` or `cancelled`) without ever serving. A deploy still queued or running is neither served nor counted here. */ | |
| 638 | readonly failedN: number; | |
| 633 | 639 | } |
| 634 | 640 | |
| 635 | 641 | const median = (xs: readonly number[]): number => { |
| @@ -641,11 +647,14 @@ const median = (xs: readonly number[]): number => { | ||
| 641 | 647 | * The median seconds from push (`created_at`) to serving (`serving_at`) |
| 642 | 648 | * over this repo's deploys since `since` — the same reference instant |
| 643 | 649 | * `servedPhrase` (`web/ui.ts`) words a single deploy from. A deploy that |
| 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. | |
| 650 | * never served says nothing about speed and is left out of the median, | |
| 651 | * never a zero; one that served but never records a `started_at` feeds | |
| 652 | * the total but not the split. Beside the median, the same walk counts | |
| 653 | * deploys that finished — `ok`, `failed` or `cancelled` — without ever | |
| 654 | * serving; one still `queued` or `running` is neither, and counts as | |
| 655 | * neither. One query, bounded by `limit` and served by the `run_repo` | |
| 656 | * index (it walks newest-first and stops at the cap), never a scan of | |
| 657 | * the table. | |
| 649 | 658 | */ |
| 650 | 659 | export function previewLatency( |
| 651 | 660 | db: Database, |
| @@ -655,36 +664,46 @@ export function previewLatency( | ||
| 655 | 664 | ): PreviewLatency | undefined { |
| 656 | 665 | const rows = db |
| 657 | 666 | .query< |
| 658 | { secs: number; queueSecs: number | null; bootSecs: number | null }, | |
| 667 | { | |
| 668 | secs: number | null; | |
| 669 | queueSecs: number | null; | |
| 670 | bootSecs: number | null; | |
| 671 | finished: 0 | 1; | |
| 672 | }, | |
| 659 | 673 | [string, string, number] |
| 660 | 674 | >( |
| 661 | `SELECT (julianday(serving_at) - julianday(created_at)) * 86400 AS secs, | |
| 662 | CASE WHEN started_at IS NOT NULL | |
| 675 | `SELECT CASE WHEN serving_at IS NOT NULL | |
| 676 | THEN (julianday(serving_at) - julianday(created_at)) * 86400 END AS secs, | |
| 677 | CASE WHEN serving_at IS NOT NULL AND started_at IS NOT NULL | |
| 663 | 678 | 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 | |
| 679 | CASE WHEN serving_at IS NOT NULL AND started_at IS NOT NULL | |
| 680 | THEN (julianday(serving_at) - julianday(started_at)) * 86400 END AS bootSecs, | |
| 681 | state IN ('ok','failed','cancelled') AS finished | |
| 666 | 682 | FROM run |
| 667 | WHERE repo_id = ?1 AND kind = 'deploy' AND serving_at IS NOT NULL | |
| 683 | WHERE repo_id = ?1 AND kind = 'deploy' | |
| 668 | 684 | AND created_at >= ?2 |
| 669 | 685 | ORDER BY id DESC LIMIT ?3`, |
| 670 | 686 | ) |
| 671 | 687 | .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 | |
| 688 | const served = rows.filter((r) => r.secs !== null); | |
| 689 | const failedN = rows.filter((r) => r.secs === null && r.finished === 1).length; | |
| 690 | if (served.length === 0 && failedN === 0) return undefined; | |
| 691 | const secs = served.map((r) => r.secs!).sort((a, b) => a - b); | |
| 692 | const queueSecs = served | |
| 675 | 693 | .map((r) => r.queueSecs) |
| 676 | 694 | .filter((s): s is number => s !== null) |
| 677 | 695 | .sort((a, b) => a - b); |
| 678 | const bootSecs = rows | |
| 696 | const bootSecs = served | |
| 679 | 697 | .map((r) => r.bootSecs) |
| 680 | 698 | .filter((s): s is number => s !== null) |
| 681 | 699 | .sort((a, b) => a - b); |
| 682 | 700 | return { |
| 683 | medianSeconds: median(secs), | |
| 701 | medianSeconds: secs.length === 0 ? undefined : median(secs), | |
| 684 | 702 | n: secs.length, |
| 685 | 703 | queueMedianSeconds: queueSecs.length === 0 ? undefined : median(queueSecs), |
| 686 | 704 | bootMedianSeconds: bootSecs.length === 0 ? undefined : median(bootSecs), |
| 687 | 705 | splitN: queueSecs.length, |
| 706 | failedN, | |
| 688 | 707 | }; |
| 689 | 708 | } |
| 690 | 709 |
src/web/studio/app.ts +23 −5
| @@ -81,11 +81,29 @@ const PREVIEW_LATENCY_MIN = 3; | ||
| 81 | 81 | * rather than printing one from a run or two. Below the total, the same |
| 82 | 82 | * walk split at `started_at` — waiting to be picked up, then coming up — |
| 83 | 83 | * each its own median, not a piece of the total that ought to add up. |
| 84 | * When some of the week's deploys finished without ever serving, the line | |
| 85 | * says how many of how many and links to them — a clean week reads exactly | |
| 86 | * as it always has, no `0 failed`, no empty clause. | |
| 84 | 87 | */ |
| 85 | function previewLatencyWords(latency: PreviewLatency | undefined): Html { | |
| 86 | if (latency === undefined || latency.n < PREVIEW_LATENCY_MIN) | |
| 88 | function previewLatencyWords( | |
| 89 | latency: PreviewLatency | undefined, | |
| 90 | failedHref: string, | |
| 91 | ): Html { | |
| 92 | const failedClause = | |
| 93 | latency !== undefined && latency.failedN > 0 | |
| 94 | ? html`<a href="${failedHref}">${latency.failedN} of ${ | |
| 95 | latency.n + latency.failedN | |
| 96 | } never reached a preview</a>` | |
| 97 | : null; | |
| 98 | const failedSuffix = | |
| 99 | failedClause === null ? "" : html` · ${failedClause}`; | |
| 100 | if ( | |
| 101 | latency === undefined || | |
| 102 | latency.n < PREVIEW_LATENCY_MIN || | |
| 103 | latency.medianSeconds === undefined | |
| 104 | ) | |
| 87 | 105 | return html`<span class="muted small" |
| 88 | >not enough deploys this week to say</span | |
| 106 | >not enough deploys this week to say${failedSuffix}</span | |
| 89 | 107 | >`; |
| 90 | 108 | const split = |
| 91 | 109 | latency.queueMedianSeconds === undefined || |
| @@ -97,7 +115,7 @@ function previewLatencyWords(latency: PreviewLatency | undefined): Html { | ||
| 97 | 115 | ? ` (${latency.splitN} of ${latency.n})` |
| 98 | 116 | : ""} · `; |
| 99 | 117 | return html`reaches its preview in ${latency.medianSeconds.toFixed(1)}s |
| 100 | <span class="muted">· ${split}${latency.n} deploys</span>`; | |
| 118 | <span class="muted">· ${split}${latency.n} deploys${failedSuffix}</span>`; | |
| 101 | 119 | } |
| 102 | 120 | |
| 103 | 121 | export async function studioApp( |
| @@ -343,7 +361,7 @@ export async function studioApp( | ||
| 343 | 361 | <dt>Runs</dt> |
| 344 | 362 | <dd>${weekTotals.runs}</dd> |
| 345 | 363 | <dt>Preview</dt> |
| 346 | <dd>${previewLatencyWords(weekLatency)}</dd> | |
| 364 | <dd>${previewLatencyWords(weekLatency, `${base}/runs?kind=deploy`)}</dd> | |
| 347 | 365 | <dt>Spent</dt> |
| 348 | 366 | <dd> |
| 349 | 367 | $${spent.toFixed(2)}${shipped > 0 |
test/studio.test.ts +109
| @@ -3364,6 +3364,80 @@ describe("previewLatency: the median seconds from push to serving", () => { | ||
| 3364 | 3364 | expect(withGap.queueMedianSeconds).toBeCloseTo(2, 2); |
| 3365 | 3365 | expect(withGap.bootMedianSeconds).toBeCloseTo(6, 2); |
| 3366 | 3366 | }); |
| 3367 | ||
| 3368 | test("a deploy that finished without ever serving is counted apart, never in the median; one still running is counted as neither", () => { | |
| 3369 | const now = Date.now() + 20_000_000; // clear of the earlier tests' windows | |
| 3370 | const since = new Date(now - 60_000).toISOString(); | |
| 3371 | ||
| 3372 | // Two served deploys, 4s and 6s. | |
| 3373 | [4, 6].forEach((d, i) => { | |
| 3374 | const createdAt = new Date(now + i * 1_000).toISOString(); | |
| 3375 | seedDeploy( | |
| 3376 | createdAt, | |
| 3377 | null, | |
| 3378 | new Date(Date.parse(createdAt) + d * 1_000).toISOString(), | |
| 3379 | ); | |
| 3380 | }); | |
| 3381 | const clean = previewLatency(app.db, repo.id, since)!; | |
| 3382 | expect(clean.n).toBe(2); | |
| 3383 | expect(clean.failedN).toBe(0); | |
| 3384 | expect(clean.medianSeconds).toBeCloseTo(5, 2); | |
| 3385 | ||
| 3386 | // A deploy still queued: neither served nor finished. Moves neither number. | |
| 3387 | const inFlight = createRun(app.db, { repoId: repo.id, kind: "deploy" }); | |
| 3388 | app.db | |
| 3389 | .query("UPDATE run SET created_at = ?2 WHERE id = ?1") | |
| 3390 | .run(inFlight.id, new Date(now + 2_000).toISOString()); | |
| 3391 | const withInFlight = previewLatency(app.db, repo.id, since)!; | |
| 3392 | expect(withInFlight.n).toBe(2); | |
| 3393 | expect(withInFlight.failedN).toBe(0); | |
| 3394 | expect(withInFlight.medianSeconds).toBeCloseTo(5, 2); | |
| 3395 | ||
| 3396 | // A deploy that finished but never served — counted as failed, never a | |
| 3397 | // zero-second deploy pulling the median down. | |
| 3398 | const failed = createRun(app.db, { repoId: repo.id, kind: "deploy" }); | |
| 3399 | app.db | |
| 3400 | .query("UPDATE run SET created_at = ?2 WHERE id = ?1") | |
| 3401 | .run(failed.id, new Date(now + 3_000).toISOString()); | |
| 3402 | claimRun(app.db, failed.id); | |
| 3403 | finishRun(app.db, failed.id, { state: "failed", summary: "build failed" }); | |
| 3404 | const withFailed = previewLatency(app.db, repo.id, since)!; | |
| 3405 | expect(withFailed.n).toBe(2); | |
| 3406 | expect(withFailed.failedN).toBe(1); | |
| 3407 | expect(withFailed.medianSeconds).toBeCloseTo(5, 2); | |
| 3408 | ||
| 3409 | // A cancelled deploy that never served also counts as failed. | |
| 3410 | const cancelled = createRun(app.db, { repoId: repo.id, kind: "deploy" }); | |
| 3411 | app.db | |
| 3412 | .query("UPDATE run SET created_at = ?2 WHERE id = ?1") | |
| 3413 | .run(cancelled.id, new Date(now + 4_000).toISOString()); | |
| 3414 | claimRun(app.db, cancelled.id); | |
| 3415 | app.db | |
| 3416 | .query("UPDATE run SET state = 'cancelled', finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1") | |
| 3417 | .run(cancelled.id); | |
| 3418 | const withCancelled = previewLatency(app.db, repo.id, since)!; | |
| 3419 | expect(withCancelled.n).toBe(2); | |
| 3420 | expect(withCancelled.failedN).toBe(2); | |
| 3421 | expect(withCancelled.medianSeconds).toBeCloseTo(5, 2); | |
| 3422 | }); | |
| 3423 | ||
| 3424 | test("a week with only never-served deploys still reports the failed count, with no median to give", () => { | |
| 3425 | const now = Date.now() + 30_000_000; | |
| 3426 | const since = new Date(now - 60_000).toISOString(); | |
| 3427 | ||
| 3428 | const failed = createRun(app.db, { repoId: repo.id, kind: "deploy" }); | |
| 3429 | app.db | |
| 3430 | .query("UPDATE run SET created_at = ?2 WHERE id = ?1") | |
| 3431 | .run(failed.id, new Date(now).toISOString()); | |
| 3432 | claimRun(app.db, failed.id); | |
| 3433 | finishRun(app.db, failed.id, { state: "failed", summary: "build failed" }); | |
| 3434 | ||
| 3435 | const latency = previewLatency(app.db, repo.id, since)!; | |
| 3436 | expect(latency).toBeDefined(); | |
| 3437 | expect(latency.n).toBe(0); | |
| 3438 | expect(latency.failedN).toBe(1); | |
| 3439 | expect(latency.medianSeconds).toBeUndefined(); | |
| 3440 | }); | |
| 3367 | 3441 | }); |
| 3368 | 3442 | |
| 3369 | 3443 | describe("the studio's own words for how fast a change reaches its preview", () => { |
| @@ -3493,6 +3567,41 @@ describe("the studio's own words for how fast a change reaches its preview", () | ||
| 3493 | 3567 | expect(page).toContain("2.0s waiting"); |
| 3494 | 3568 | // Only 3 of the 4 served deploys carried a `started_at`: the split says so. |
| 3495 | 3569 | expect(page).toContain("6.0s coming up (3 of 4)"); |
| 3570 | ||
| 3571 | // A clean week — every deploy served — reads exactly as it always has: | |
| 3572 | // no mention of failures, no empty clause left dangling. | |
| 3573 | expect(page).not.toContain("never reached a preview"); | |
| 3574 | expect(page).not.toContain("0 failed"); | |
| 3575 | }); | |
| 3576 | ||
| 3577 | test("a deploy that finished without ever serving is counted and worded, and links to the runs list", async () => { | |
| 3578 | const before = await (await get("/alice/trickle")).text(); | |
| 3579 | ||
| 3580 | const failed = createRun(app.db, { | |
| 3581 | repoId: repo.id, | |
| 3582 | kind: "deploy", | |
| 3583 | env: "production", | |
| 3584 | reason: "push", | |
| 3585 | }); | |
| 3586 | claimRun(app.db, failed.id); | |
| 3587 | finishRun(app.db, failed.id, { state: "failed", summary: "build failed" }); | |
| 3588 | ||
| 3589 | const after = await (await get("/alice/trickle")).text(); | |
| 3590 | expect(after).toContain("1 of 5 never reached a preview"); | |
| 3591 | expect(after).toContain(`href="/alice/trickle/runs?kind=deploy"`); | |
| 3592 | ||
| 3593 | // A deploy still running moves neither the served count nor the failed | |
| 3594 | // count: it is neither served nor finished. | |
| 3595 | const inFlight = createRun(app.db, { | |
| 3596 | repoId: repo.id, | |
| 3597 | kind: "deploy", | |
| 3598 | env: "production", | |
| 3599 | reason: "push", | |
| 3600 | }); | |
| 3601 | claimRun(app.db, inFlight.id); | |
| 3602 | const withInFlight = await (await get("/alice/trickle")).text(); | |
| 3603 | expect(withInFlight).toContain("1 of 5 never reached a preview"); | |
| 3604 | expect(withInFlight).not.toBe(before); | |
| 3496 | 3605 | }); |
| 3497 | 3606 | }); |
| 3498 | 3607 |
new above
offline