ubuntu-latest follows GitHub: 4 vCPU in a public repository, 2 in a private one #170
Mergeds
syntaqx merged runners-like-github into main
26 files+576−9090/26 viewed
| 266 | 266 | name: The desktop app's Linux build | |
| 267 | 267 | needs: look | |
| 268 | 268 | if: ${{ needs.look.outputs.desktop-linux == 'true' }} | |
| 269 | − | # The larger machine: linking Tauri on the small one dies with a bus error. | |
| 270 | − | runs-on: g1t-4core | |
| 269 | + | # ubuntu-latest is 4 vCPU in this public repository, as on GitHub; linking | |
| 270 | + | # Tauri needs it (on 2 vCPU it dies with a bus error). | |
| 271 | + | runs-on: ubuntu-latest | |
| 271 | 272 | timeout-minutes: 60 | |
| 272 | 273 | steps: | |
| 273 | 274 | - uses: actions/checkout@v5 | |
| ⋯ | |||
| 310 | 311 | name: The desktop app's Windows build | |
| 311 | 312 | needs: look | |
| 312 | 313 | if: ${{ needs.look.outputs.desktop-windows == 'true' && vars.DESKTOP_WINDOWS_FROM_LINUX == 'true' && vars.DESKTOP_WINDOWS_RUNNER != 'true' }} | |
| 313 | − | runs-on: g1t-4core | |
| 314 | + | runs-on: ubuntu-latest | |
| 314 | 315 | timeout-minutes: 60 | |
| 315 | 316 | steps: | |
| 316 | 317 | - uses: actions/checkout@v5 | |
| ⋯ | |||
| 374 | 375 | needs: look | |
| 375 | 376 | if: ${{ needs.look.outputs.cli == 'true' }} | |
| 376 | 377 | # The builder runs in Docker, which g1t's own machines give each job. | |
| 377 | − | runs-on: g1t-4core | |
| 378 | + | runs-on: ubuntu-latest | |
| 378 | 379 | timeout-minutes: 60 | |
| 379 | 380 | steps: | |
| 380 | 381 | - uses: actions/checkout@v5 | |
| 38 | 38 | jobs: | |
| 39 | 39 | rust: | |
| 40 | 40 | name: Rust | |
| 41 | − | runs-on: g1t-4core | |
| 41 | + | runs-on: ubuntu-latest | |
| 42 | 42 | timeout-minutes: 45 | |
| 43 | 43 | env: | |
| 44 | 44 | # Line tables, not full debug info: a panic still names its file and | |
| ⋯ | |||
| 158 | 158 | ||
| 159 | 159 | desktop: | |
| 160 | 160 | name: Desktop app | |
| 161 | − | # The larger machine, as the Rust job: linking Tauri on the small one | |
| 162 | − | # ran out of room and the linker died with a bus error. | |
| 163 | − | runs-on: g1t-4core | |
| 161 | + | # ubuntu-latest is 4 vCPU in this public repository, as on GitHub; linking | |
| 162 | + | # Tauri needs it (on 2 vCPU it ran out of room and died with a bus error). | |
| 163 | + | runs-on: ubuntu-latest | |
| 164 | 164 | timeout-minutes: 30 | |
| 165 | 165 | steps: | |
| 166 | 166 | - uses: actions/checkout@v5 | |
| 11 | 11 | # Every platform builds in one job, in the rust-cross/cargo-zigbuild image, | |
| 12 | 12 | # pinned by digest in scripts/cli-release.mjs: the macOS builds link Apple's | |
| 13 | 13 | # Security framework (the keychain), and that image carries its SDK. It | |
| 14 | − | # runs on g1t's own machines (g1t-4core), whose jobs have a Docker Engine of | |
| 14 | + | # runs on g1t's own machines (ubuntu-latest, 4 vCPU here), whose jobs have a Docker Engine of | |
| 15 | 15 | # their own. | |
| 16 | 16 | name: CLI release | |
| 17 | 17 | ||
| ⋯ | |||
| 34 | 34 | jobs: | |
| 35 | 35 | build: | |
| 36 | 36 | name: Build every platform | |
| 37 | − | runs-on: g1t-4core | |
| 37 | + | runs-on: ubuntu-latest | |
| 38 | 38 | timeout-minutes: 60 | |
| 39 | 39 | steps: | |
| 40 | 40 | - uses: actions/checkout@v5 | |
| 156 | 156 | # Runs when nothing before it failed: a migrate job skipped for having | |
| 157 | 157 | # nothing to apply is not a failure. | |
| 158 | 158 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }} | |
| 159 | − | # Rust builds and the runner's image get 4 vCPUs; everything else the | |
| 160 | − | # standard machine. | |
| 161 | − | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 159 | + | # ubuntu-latest: 4 vCPU in this public repository, as on GitHub. | |
| 160 | + | runs-on: ubuntu-latest | |
| 162 | 161 | environment: | |
| 163 | 162 | name: production | |
| 164 | 163 | url: https://g1t.sh | |
| ⋯ | |||
| 306 | 305 | name: edge (${{ matrix.group }}) | |
| 307 | 306 | needs: [check, plan, migrate, core] | |
| 308 | 307 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }} | |
| 309 | − | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 308 | + | runs-on: ubuntu-latest | |
| 310 | 309 | environment: | |
| 311 | 310 | name: production | |
| 312 | 311 | url: https://g1t.sh | |
| ⋯ | |||
| 321 | 320 | name: front (${{ matrix.group }}) | |
| 322 | 321 | needs: [check, plan, migrate, core, edge] | |
| 323 | 322 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }} | |
| 324 | − | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 323 | + | runs-on: ubuntu-latest | |
| 325 | 324 | environment: | |
| 326 | 325 | name: production | |
| 327 | 326 | url: https://g1t.sh | |
| 63 | 63 | linux: | |
| 64 | 64 | name: Linux | |
| 65 | 65 | needs: version | |
| 66 | − | # The larger machine: linking Tauri on the small one dies with a bus error. | |
| 67 | − | runs-on: g1t-4core | |
| 66 | + | # ubuntu-latest is 4 vCPU in this public repository, as on GitHub; linking | |
| 67 | + | # Tauri needs it (on 2 vCPU it dies with a bus error). | |
| 68 | + | runs-on: ubuntu-latest | |
| 68 | 69 | # Production's secrets, without a deployment: a release is not a deploy of | |
| 69 | 70 | # g1t.sh, and would otherwise show on its card as one in progress. | |
| 70 | 71 | environment: | |
| ⋯ | |||
| 168 | 169 | name: Windows, built on Linux | |
| 169 | 170 | needs: version | |
| 170 | 171 | if: vars.DESKTOP_WINDOWS_FROM_LINUX == 'true' && vars.DESKTOP_WINDOWS_RUNNER != 'true' | |
| 171 | − | runs-on: g1t-4core | |
| 172 | + | runs-on: ubuntu-latest | |
| 172 | 173 | # Production's secrets, without a deployment: a release is not a deploy of | |
| 173 | 174 | # g1t.sh, and would otherwise show on its card as one in progress. | |
| 174 | 175 | environment: | |
| 11 | 11 | # to make the release key the first time; CONTRIBUTING.md ("Deploying") | |
| 12 | 12 | # says how a release is made. | |
| 13 | 13 | # | |
| 14 | − | # The image jobs run on g1t's own machines (g1t-4core), whose jobs have a | |
| 14 | + | # The image jobs run on g1t's own machines (ubuntu-latest, 4 vCPU here), whose jobs have a | |
| 15 | 15 | # Docker Engine of their own (crates/runner/src/docker/engine.rs). arm64 | |
| 16 | 16 | # images need QEMU's emulators registered with the kernel; each image job | |
| 17 | 17 | # tries that, and when the machine does not allow it, builds amd64 alone | |
| ⋯ | |||
| 126 | 126 | image: | |
| 127 | 127 | name: Container image | |
| 128 | 128 | needs: binaries | |
| 129 | − | runs-on: g1t-4core | |
| 129 | + | runs-on: ubuntu-latest | |
| 130 | 130 | # Production's secrets, without a deployment: a release is not a deploy of | |
| 131 | 131 | # g1t.sh, and would otherwise show on its card as one in progress. | |
| 132 | 132 | environment: | |
| ⋯ | |||
| 183 | 183 | sandbox: | |
| 184 | 184 | name: Sandbox images | |
| 185 | 185 | needs: [binaries, image] | |
| 186 | − | runs-on: g1t-4core | |
| 186 | + | runs-on: ubuntu-latest | |
| 187 | 187 | timeout-minutes: 60 | |
| 188 | 188 | steps: | |
| 189 | 189 | - uses: actions/checkout@v5 | |
| 351 | 351 | ||
| 352 | 352 | ### Machine sizes, time and storage | |
| 353 | 353 | ||
| 354 | − | | Limit | Value | | |
| 355 | − | | ------------------------- | ----------------------------------------------------------------------------------------------------- | | |
| 356 | − | | Largest machine | 4 vCPUs, 12 GiB of memory, 20 GB of disk (`g1t-4core`). No GPUs. | | |
| 357 | − | | Standard machine | 2 vCPUs, 8 GiB of memory, 16 GB of disk (`ubuntu-latest`, every other hosted label, and `g1t-2core`). | | |
| 358 | − | | One job on g1t's machines | 60 minutes. On a self-hosted runner, 24 hours. | | |
| 359 | − | | One cache entry | 2 GiB, compressed. A larger one is not saved. | | |
| 360 | − | | A repository's caches | 10 GiB together. Past it, the entries restored longest ago are removed. | | |
| 361 | − | | One artifact | 5 GiB, zipped. Kept 14 days unless the repository says otherwise, at most 90. | | |
| 362 | − | | A run's artifacts | 10 GiB together. | | |
| 354 | + | | Limit | Value | | |
| 355 | + | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 356 | + | | Largest machine | 4 vCPUs, 12 GiB of memory, 20 GB of disk (`g1t-4core`, and `ubuntu-latest` in a public repository). No GPUs. GitHub's machine for public repositories has 16 GB of memory. | | |
| 357 | + | | Standard machine | 2 vCPUs, 8 GiB of memory, 16 GB of disk (`ubuntu-latest` and every other hosted label in a private repository, and `g1t-2core`). | | |
| 358 | + | | One job on g1t's machines | 60 minutes. On a self-hosted runner, 24 hours. | | |
| 359 | + | | One cache entry | 2 GiB, compressed. A larger one is not saved. | | |
| 360 | + | | A repository's caches | 10 GiB together. Past it, the entries restored longest ago are removed. | | |
| 361 | + | | One artifact | 5 GiB, zipped. Kept 14 days unless the repository says otherwise, at most 90. | | |
| 362 | + | | A run's artifacts | 10 GiB together. | | |
| 363 | 363 | ||
| 364 | 364 | The machine sizes are Cloudflare Containers' instance sizes. For more, use a | |
| 365 | 365 | [self-hosted runner](/guides/self-hosted-runners/). See |
| 74 | 74 | `runner` defines three Container classes, picked by `runs-on`, each a | |
| 75 | 75 | named Cloudflare instance type: | |
| 76 | 76 | ||
| 77 | − | | `runs-on` | Class | Size | | |
| 78 | − | | ---------------------------------------------------- | ---------------- | ----------------------------------------------- | | |
| 79 | − | | Any other hosted label, and every other kind of work | `AttemptSandbox` | `standard-3`: 2 vCPU, 8 GiB memory, 16 GB disk | | |
| 80 | − | | `g1t-2core` | `Sandbox2Core` | `standard-3`: 2 vCPU, 8 GiB memory, 16 GB disk | | |
| 81 | − | | `g1t-4core` | `Sandbox4Core` | `standard-4`: 4 vCPU, 12 GiB memory, 20 GB disk | | |
| 77 | + | | `runs-on` | Class | Size | | |
| 78 | + | | ---------------------------------------------------------------------------- | ---------------- | ----------------------------------------------- | | |
| 79 | + | | Any other hosted label in a private repository, and every other kind of work | `AttemptSandbox` | `standard-3`: 2 vCPU, 8 GiB memory, 16 GB disk | | |
| 80 | + | | `g1t-2core` | `Sandbox2Core` | `standard-3`: 2 vCPU, 8 GiB memory, 16 GB disk | | |
| 81 | + | | `g1t-4core`, and any other hosted label in a public repository | `Sandbox4Core` | `standard-4`: 4 vCPU, 12 GiB memory, 20 GB disk | | |
| 82 | 82 | ||
| 83 | + | `ubuntu-latest` follows GitHub's hosted runners: the actions service | |
| 84 | + | resolves it to `g1t-4core` when the run's repository is public (its | |
| 85 | + | event's `repository.private`), and the job is billed as that machine. | |
| 83 | 86 | `g1t-2core` is kept as a name of the standard machine, so workflows that | |
| 84 | 87 | ask for it keep working; it keeps its class and its own `max_instances` | |
| 85 | 88 | in `services/runner/wrangler.jsonc`. The contracts' instance table |
| 554 | 554 | ||
| 555 | 555 | ### Machine sizes | |
| 556 | 556 | ||
| 557 | − | A job runs on the standard machine unless its `runs-on` is `g1t-4core`: | |
| 557 | + | `ubuntu-latest` gets what it gets on GitHub: 4 vCPUs in a public | |
| 558 | + | repository, 2 in a private one. Any other Linux label is the same. | |
| 558 | 559 | ||
| 559 | − | | `runs-on` | vCPUs | Memory | Disk | A minute | | |
| 560 | − | | -------------------------------------------- | ----- | ------ | ----- | -------- | | |
| 561 | − | | `ubuntu-latest`, or any other Linux label | 2 | 8 GiB | 16 GB | $0.004 | | |
| 562 | − | | `g1t-2core` (a name of the standard machine) | 2 | 8 GiB | 16 GB | $0.004 | | |
| 563 | − | | `g1t-4core` | 4 | 12 GiB | 20 GB | $0.007 | | |
| 560 | + | | `runs-on` | vCPUs | Memory | Disk | A minute | | |
| 561 | + | | ----------------------------------------------------------------- | ----- | ------ | ----- | -------- | | |
| 562 | + | | `ubuntu-latest`, or any other Linux label, in a public repository | 4 | 12 GiB | 20 GB | $0.007 | | |
| 563 | + | | `ubuntu-latest`, or any other Linux label, in a private one | 2 | 8 GiB | 16 GB | $0.004 | | |
| 564 | + | | `g1t-4core`, in any repository | 4 | 12 GiB | 20 GB | $0.007 | | |
| 565 | + | | `g1t-2core`, in any repository | 2 | 8 GiB | 16 GB | $0.004 | | |
| 564 | 566 | ||
| 567 | + | GitHub's machine for public repositories has 16 GB of memory; g1t's has | |
| 568 | + | 12 GiB, the most its machines have. | |
| 569 | + | ||
| 570 | + | To choose a machine whatever the repository's visibility, name it: | |
| 571 | + | ||
| 565 | 572 | ```yaml | |
| 566 | 573 | jobs: | |
| 567 | 574 | build: | |
| 568 | 575 | runs-on: g1t-4core | |
| 569 | 576 | ``` | |
| 570 | 577 | ||
| 571 | − | `g1t-4core` is for heavier jobs: builds that compile, such as Rust or a | |
| 572 | − | large TypeScript project, image builds and databases. `g1t-2core` is kept | |
| 573 | − | as a name of the standard machine, so workflows that ask for it keep | |
| 574 | − | working. The label can come from the matrix or the run's inputs | |
| 578 | + | `g1t-4core` suits heavier jobs in a private repository: builds that | |
| 579 | + | compile, such as Rust or a large TypeScript project, image builds and | |
| 580 | + | databases. `g1t-2core` keeps a public repository's light jobs on the | |
| 581 | + | smaller machine. The label can come from the matrix or the run's inputs | |
| 575 | 582 | (`runs-on: ${{ matrix.big && 'g1t-4core' || 'ubuntu-latest' }}`). | |
| 576 | 583 | ||
| 577 | 584 | Each job is charged its time rounded up to the whole minute (61 seconds |
| 340 | 340 | ||
| 341 | 341 | The standard sandbox is the instance type `standard-3` (2 vCPU, 8 GiB, | |
| 342 | 342 | 16 GB), and so is `runs-on: g1t-2core`, in a class of its own; jobs that | |
| 343 | − | ask for `runs-on: g1t-4core` get `standard-4` (4 vCPU, 12 GiB, 20 GB). At | |
| 343 | + | ask for `runs-on: g1t-4core`, and `ubuntu-latest` jobs in a public | |
| 344 | + | repository, get `standard-4` (4 vCPU, 12 GiB, 20 GB). At | |
| 344 | 345 | the `max_instances` in `services/runner/wrangler.jsonc` (20, 10 and 10), | |
| 345 | 346 | 40 sandboxes at once hold 100 vCPU, 360 GiB of memory and 680 GB of disk, | |
| 346 | 347 | which your account's Containers limits for live instances must allow. |
| 1 | 1 | --- | |
| 2 | 2 | title: Home | |
| 3 | − | description: Your workspace's front page. A box that asks g1t, your move (everything waiting on you, and the one item to start with), the agents, runs and builds going now, this week's work day by day with what is worth a look, and what happened since you were last here in plain English. How every number is worked out. | |
| 3 | + | description: Your workspace's front page. The past week in four figures, the daily signal (what your work says today, with the evidence), where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **Home** is the first page of your workspace, at `/<workspace>/-/home`. | |
| 7 | − | It reads top to bottom, from what you can do now to what already happened: | |
| 7 | + | Across the top is [the past week](#the-past-week) in four figures, each | |
| 8 | + | against the week before. Under it, the main column is read top to | |
| 9 | + | bottom: | |
| 8 | 10 | ||
| 9 | − | 1. **What are we building today?**: a box that [asks g1t](#ask-from-home) | |
| 10 | − | anything about your work, with questions to start with. | |
| 11 | − | 2. **[Your move](#your-move)**: everything waiting on you, however long ago | |
| 11 | + | 1. **[The daily signal](#the-daily-signal)**: what the workspace's work | |
| 12 | + | says today. Its headline is the finding that matters most, and the | |
| 13 | + | others follow, each a sentence with its evidence and where to see it. | |
| 14 | + | Under them is a box that [asks g1t](#ask-from-home) about any of it. | |
| 15 | + | 2. **Where you're needed**: everything waiting on you, however long ago | |
| 12 | 16 | it started, with the action beside each item. Its first row is | |
| 13 | − | [Start here](#start-here), the one item to do first, and why. Beside it, | |
| 14 | − | **[Live](#live)**: the agents at work, workflow runs and builds going | |
| 15 | − | now, each agent session with what it has cost so far. | |
| 16 | − | 3. **[This week](#this-week)**: the week in a sentence and four figures, | |
| 17 | − | the last two weeks day by day (what agents and people landed), and what | |
| 18 | − | is [worth a look](#worth-a-look), each finding with its evidence and | |
| 19 | − | pinned to the day it happened where it happened on one. | |
| 20 | − | 4. **Since you were last here**: one card for the span. It shows what people | |
| 17 | + | [Start here](#start-here), the one item to do first, and why. | |
| 18 | + | 3. **Since you were last here**: one card for the span. It shows what people | |
| 21 | 19 | did, what changed in plain English when you ask for it, and what landed, | |
| 22 | − | each change with whether it is live yet. Beside it, kept in view as you | |
| 23 | − | scroll: **[Spent](#spent)**, what the span cost and the month so far, | |
| 24 | − | and **[Settled for you](#settled-for-you)**, what finished by itself. | |
| 20 | + | each change with whether it is live yet. | |
| 25 | 21 | ||
| 26 | − | On a phone it is one column, in the same order. | |
| 22 | + | The side column stays in view as you scroll: | |
| 23 | + | ||
| 24 | + | - **[Running now](#running-now)**: workflow runs and builds still going. | |
| 25 | + | - **[Spent](#spent)**: what the span cost, and the month so far. | |
| 26 | + | - **[Settled for you](#settled-for-you)**: what finished by itself. | |
| 27 | 27 | ||
| 28 | + | On a phone it is one column: the past week, the daily signal, Where | |
| 29 | + | you're needed, Running now, the span, then Spent and Settled for you. | |
| 30 | + | ||
| 28 | 31 | For a workspace's owners, Home also opens | |
| 29 | 32 | [Getting started](/guides/workspaces/#getting-started), the setup guide, | |
| 30 | 33 | until they finish or close it. The **Getting started** button at the top | |
| ⋯ | |||
| 36 | 39 | so, and the rest of the page still shows. Only what you ask g1t is written | |
| 37 | 40 | by a model: [What changed](#what-changed) and the answers in Ask. | |
| 38 | 41 | ||
| 39 | − | ## This week | |
| 40 | − | ||
| 41 | − | A sentence for what landed in the last seven UTC days and how much of it | |
| 42 | − | was agents', then four figures. Each shows how it moved since the seven | |
| 43 | − | days before (green when that is good news, red when it is not); point at | |
| 44 | − | one for what it counts, and it opens the page behind it. | |
| 42 | + | ## The past week | |
| 45 | 43 | ||
| 46 | − | Under them are the last fourteen UTC days as columns: what agents landed | |
| 47 | − | (their merged pull requests and the commits their sessions pushed | |
| 48 | − | directly) in the accent, what people merged above it, last week's days | |
| 49 | − | faded. Point at a day, or focus it, to read it in full: what landed, by | |
| 50 | − | whom, and the sessions started. A finding that happened on one day, such | |
| 51 | − | as a milestone that closed, is pinned over it with the number it has in | |
| 52 | − | [Worth a look](#worth-a-look). | |
| 44 | + | Four figures for the last seven UTC days. Each shows how it moved since the | |
| 45 | + | seven days before (green when that is good news, red when it is not) and a | |
| 46 | + | line over the last ten weeks. Point at the line, or focus it and use the | |
| 47 | + | arrow keys, to read any week. Each figure opens the page behind it. | |
| 53 | 48 | ||
| 54 | − | | Figure | What it counts | | |
| 55 | − | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 56 | − | | **Landed by agents** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 57 | − | | **Cost per agent change** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 58 | − | | **Gone quiet** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 59 | − | | **Sent back** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 49 | + | | Figure | What it counts | | |
| 50 | + | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 51 | + | | **Agent-authored changes** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 52 | + | | **Spend per agent change** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 53 | + | | **Stalled changes** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 54 | + | | **Agent rework rate** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 60 | 55 | ||
| 61 | 56 | ### Whose work is an agent's | |
| 62 | 57 | ||
| ⋯ | |||
| 66 | 61 | for it, else its author, so an agent's work counts toward the person it | |
| 67 | 62 | was for. | |
| 68 | 63 | ||
| 69 | − | ### Worth a look | |
| 64 | + | ## The daily signal | |
| 70 | 65 | ||
| 71 | − | Under the days, what the work says is worth a look, most important first. | |
| 72 | − | Each finding is a sentence with its evidence after it, a link to where you | |
| 73 | − | can see it, and **Ask g1t**, which asks [Ask](/guides/ask/) to look into | |
| 74 | − | it. A finding with a trend has its line beside it. When nothing stands | |
| 75 | − | out against the workspace's last weeks, it says so. None of it is about | |
| 76 | − | what needs you: that is [Your move](#your-move). | |
| 66 | + | First the day, then one sentence for the finding that matters most today, | |
| 67 | + | then the others. Each finding names its evidence in a line of figures, | |
| 68 | + | links to where you can see it, and has **Ask g1t**, which asks | |
| 69 | + | [Ask](/guides/ask/) to look into it. A finding with a trend has its line | |
| 70 | + | beside it. When nothing stands out, the headline says what the week did. | |
| 77 | 71 | ||
| 78 | − | | Finding | When it shows | | |
| 79 | − | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 80 | − | | **One unreviewed change holding up work** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. | | |
| 81 | − | | **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. | | |
| 82 | − | | **A milestone closed** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 83 | − | | **Where agent spend is going** | More than a third of the quarter's agent spend by pull request went into one milestone (else one work item, else one project). Spend is g1t's usage on each pull request and its share of its sessions' cost. | | |
| 84 | − | | **More changes gone quiet** | At least three more stalled changes than last week, and a quarter more. | | |
| 85 | − | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 86 | − | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 87 | − | | **Who works where** | In a workspace where three people or more merged lately, someone started merging to a project one person has carried (three quarters of its last 90 days' merges); or merged to a project for the first time this week after work elsewhere. | | |
| 72 | + | | Finding | When it shows | | |
| 73 | + | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 74 | + | | **Work items waiting on one unreviewed change** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. | | |
| 75 | + | | **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. | | |
| 76 | + | | **A milestone shipped** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 77 | + | | **Where agent spend is going** | More than a third of the quarter's agent spend by pull request went into one milestone (else one work item, else one project). Spend is g1t's usage on each pull request and its share of its sessions' cost. | | |
| 78 | + | | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. | | |
| 79 | + | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 80 | + | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 81 | + | | **Who works where** | In a workspace where three people or more merged lately, someone started merging to a project one person has carried (three quarters of its last 90 days' merges); or merged to a project for the first time this week after work elsewhere. | | |
| 88 | 82 | ||
| 89 | 83 | Findings are ordered by how much they hold up: blocked work first, then | |
| 90 | − | waits, then what closed and where spend went. Up to four show, and the | |
| 84 | + | waits, then what shipped and where spend went. Up to five show, and the | |
| 91 | 85 | rest are one click away. What the work says is read again at most every | |
| 92 | 86 | five minutes for each person, so a figure can be up to five minutes | |
| 93 | 87 | behind. The same reads, at more length, are on the | |
| ⋯ | |||
| 95 | 89 | ||
| 96 | 90 | ## Ask from Home | |
| 97 | 91 | ||
| 98 | − | The box at the top of Home asks g1t anything about your work. Type a question and | |
| 92 | + | The box under the daily signal asks g1t anything about your work. Type a question and | |
| 99 | 93 | press **Enter** (**Shift+Enter** starts a new line): the top bar's | |
| 100 | 94 | [Ask](/guides/ask/) panel opens and sends it, and the answer streams in | |
| 101 | 95 | there. Under the box are questions to start with: | |
| ⋯ | |||
| 115 | 109 | still answering an earlier question, the panel opens with your question | |
| 116 | 110 | in its box rather than sending it, so nothing you typed is lost. | |
| 117 | 111 | ||
| 118 | − | ## Your move | |
| 112 | + | ## Where you're needed | |
| 119 | 113 | ||
| 120 | 114 | This list has no time window: something that started waiting a week ago | |
| 121 | 115 | is here until you deal with it. Each row says what it is, what is at | |
| ⋯ | |||
| 151 | 145 | 6. Mentions and direct messages. | |
| 152 | 146 | ||
| 153 | 147 | The list shows the first eight rows; **Show more** opens the rest. The | |
| 154 | − | line under its heading says how many things need you. | |
| 148 | + | sentence under the page's heading starts with how many things need you. | |
| 155 | 149 | ||
| 156 | 150 | ## Start here | |
| 157 | 151 | ||
| 158 | − | **Start here** is the first row of Your move, set apart at the | |
| 152 | + | **Start here** is the first row of Where you're needed, set apart at the | |
| 159 | 153 | top: the most pressing tier, and within it the row that has waited | |
| 160 | 154 | longest. g1t picks it with that rule, so the same rows always give the | |
| 161 | 155 | same pick. It says why, such as "Everything it needs is met; it lands when | |
| ⋯ | |||
| 198 | 192 | - With no visit on record, Home shows the last 24 hours and says so. After | |
| 199 | 193 | more than 14 days away, it shows the last 14 days. | |
| 200 | 194 | ||
| 201 | − | The sentence under the card's heading sums it up, for example: | |
| 195 | + | The sentence under the heading sums it up, for example: | |
| 202 | 196 | ||
| 203 | − | > Since Tuesday evening, 3 people pushed 41 commits and merged 5 | |
| 204 | − | > changes, and 12 changes landed. | |
| 197 | + | > 8 things need you. Since Tuesday evening, 3 people pushed 41 commits and | |
| 198 | + | > merged 5 changes, and 12 changes landed. | |
| 205 | 199 | ||
| 206 | 200 | It says what people did first (the commits they pushed and the changes | |
| 207 | 201 | they merged; if neither, the messages they sent), then everything that | |
| ⋯ | |||
| 333 | 327 | requests, pushes and releases, deployments) didn't answer, the card says | |
| 334 | 328 | so under the list. | |
| 335 | 329 | ||
| 336 | − | ## Live | |
| 330 | + | ## Running now | |
| 337 | 331 | ||
| 338 | − | Beside Your move, what is going right now. First the agents at work: each | |
| 339 | − | live [session](/guides/sessions/) of a harness (Claude Code, Codex and so | |
| 340 | − | on) or a hosted agent, with what it is on, who runs it, how long it has | |
| 341 | − | gone, its subagents, and what it has cost so far, estimated at list price | |
| 342 | − | from its token usage. Then workflow runs and builds, longest-going first, | |
| 343 | − | each with its state and how long it has gone: | |
| 332 | + | At the top of the side column, what is going right now, longest-going | |
| 333 | + | first, each with its state and how long it has gone: | |
| 344 | 334 | ||
| 345 | 335 | | Kind | What it is | | |
| 346 | 336 | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | |
| ⋯ | |||
| 349 | 339 | ||
| 350 | 340 | ## Spent | |
| 351 | 341 | ||
| 352 | − | Beside the span: what the workspace spent over it, from its | |
| 342 | + | In the side column: what the workspace spent over the span, from its | |
| 353 | 343 | [statement](/guides/usage-and-billing/), with each line at its price: what | |
| 354 | 344 | was charged plus what your plan's included usage, a trial, a pool or a | |
| 355 | 345 | discount paid of it. The lines are the statement's kinds of charge, such | |
| ⋯ | |||
| 366 | 356 | ||
| 367 | 357 | ## Settled for you | |
| 368 | 358 | ||
| 369 | − | Under Spent: your notifications in this workspace | |
| 359 | + | At the bottom of the side column: your notifications in this workspace | |
| 370 | 360 | that [settled themselves](/guides/notifications/#threads-that-settle-themselves) | |
| 371 | 361 | in the span, newest first, each with why, such as **CI passed in run 330** | |
| 372 | 362 | or **Merged by @ana**: a later run passed, the pull request merged, the | |
| ⋯ | |||
| 378 | 368 | Home keeps up with the workspace without a reload. What lands, starts | |
| 379 | 369 | running, finishes or comes to need you while you are on the page joins its | |
| 380 | 370 | list, sliding in at its place and lit for a moment; counts and the | |
| 381 | − | lines update with it. Sessions that end and runs that finish leave Live. | |
| 371 | + | sentence under the heading update with it. Runs that finish leave Running | |
| 372 | + | now. | |
| 382 | 373 | ||
| 383 | 374 | | Home updates when | From | | |
| 384 | 375 | | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | |
| ⋯ | |||
| 403 | 394 | A member whose membership does not include Code sees Home without anything | |
| 404 | 395 | from Code: | |
| 405 | 396 | ||
| 406 | − | - This week is not shown. | |
| 397 | + | - The past week and the daily signal are not shown. The page opens with | |
| 398 | + | **What are we building today?** and the box that asks g1t. | |
| 407 | 399 | - People shows what was said and the artifacts edited; nothing from | |
| 408 | 400 | repositories. | |
| 409 | − | - Your move has no rows from Code, and no notifications about a | |
| 401 | + | - Where you're needed has no rows from Code, and no notifications about a | |
| 410 | 402 | repository. | |
| 411 | − | - Landed is not shown, and Live has no sessions, runs or builds. | |
| 403 | + | - Landed and Running now are not shown. | |
| 412 | 404 | - Spent is the same for every member. | |
| 413 | 405 | ||
| 414 | 406 | ## When something doesn't answer | |
| ⋯ | |||
| 416 | 408 | Each section reads its own part of g1t and shows as soon as it has it. | |
| 417 | 409 | When one does not answer: | |
| 418 | 410 | ||
| 419 | − | - Your move and Live name the parts that didn't answer, | |
| 411 | + | - Where you're needed and Running now name the parts that didn't answer, | |
| 420 | 412 | so an empty list never reads as nothing waiting. | |
| 421 | 413 | - People counts what could be read, and names the part that is missing | |
| 422 | 414 | (Code or Chat). If nothing could be read, the card says so, and the | |
| 423 | − | sentence under the card's heading says what people did couldn't be read. | |
| 415 | + | sentence under the heading says what people did couldn't be read. | |
| 424 | 416 | - Landed and Spent each say their part didn't answer. When | |
| 425 | 417 | deployments or sessions can't be read, Landed shows its rows without | |
| 426 | 418 | their live labels or agent markers. | |
| 427 | 419 | - Settled for you says when your notifications can't be read. | |
| 428 | − | - This week says when the work couldn't be read. When some projects | |
| 429 | − | didn't answer, it says the week leaves them out. | |
| 420 | + | - The daily signal says when the work couldn't be read, and the past week | |
| 421 | + | is left out. When some projects didn't answer, it says its findings | |
| 422 | + | leave them out. | |
| 430 | 423 | - What changed says when Ask can't be reached or isn't set up, or when | |
| 431 | 424 | the question could not be sent. | |
| 432 | 425 | - If your last visit can't be read, the span is the last 24 hours and the | |
| 99 | 99 | A thread that leaves you nothing to do moves to Done by itself, read, the | |
| 100 | 100 | moment what it told you of is over: a later run passes, the pull request | |
| 101 | 101 | merges, the review is no longer asked of you. It leaves your unread count, | |
| 102 | − | the bell, [Home](/guides/home/#your-move)'s **Your | |
| 103 | − | move** and `g1t status`, and | |
| 102 | + | the bell, [Home](/guides/home/#where-youre-needed)'s **Where you're | |
| 103 | + | needed** and `g1t status`, and | |
| 104 | 104 | stays under **Done** with its history. New activity brings it back, as with | |
| 105 | 105 | any thread. | |
| 106 | 106 |
| 102 | 102 | - **$10 of usage each month**, used first: everything g1t runs, at the | |
| 103 | 103 | [prices below](#what-it-costs) (runner minutes at their flat rate per | |
| 104 | 104 | machine, everything else at cost plus 20%). That is 2,500 runner minutes on the standard machine, or | |
| 105 | − | about 1,428 on `g1t-4core`. | |
| 105 | + | about 1,428 on `g1t-4core` (which `ubuntu-latest` gets in a public | |
| 106 | + | repository). | |
| 106 | 107 | - **Everyone in the workspace** at one price, never per person. | |
| 107 | 108 | - **Unlimited** projects, previews and repositories. | |
| 108 | 109 | - Workflows, the merge queue, | |
| ⋯ | |||
| 244 | 245 | ||
| 245 | 246 | Once its card is checked, a workspace gets **$5 of trial credit**, once. It | |
| 246 | 247 | pays for usage at the plan's prices: runner minutes for checks, workflows | |
| 247 | − | and the merge queue (1,250 on the standard machine), and semantic search. It never pays for deployments. | |
| 248 | + | and the merge queue (1,250 on the standard machine, about 714 on the | |
| 249 | + | 4-core one), and semantic search. It never pays for deployments. | |
| 248 | 250 | ||
| 249 | 251 | - Each card gets one trial, whichever workspace it is checked in. Prepaid | |
| 250 | 252 | cards start none. | |
| ⋯ | |||
| 261 | 263 | ## The open-source pool | |
| 262 | 264 | ||
| 263 | 265 | g1t sets aside **$25 a month** for public repositories, at most **$2 a | |
| 264 | − | month** for any one repository: 6,250 and 500 runner minutes on the | |
| 265 | − | standard machine. It pays for **checks, workflows and the merge queue on | |
| 266 | + | month** for any one repository: about 3,571 and 285 minutes of | |
| 267 | + | workflow jobs, which run on the 4-core machine in a public repository, | |
| 268 | + | or 6,250 and 500 minutes of checks and merge queue runs on the standard | |
| 269 | + | machine. It pays for **checks, workflows and the merge queue on | |
| 266 | 270 | public repositories**, after a card check. It does not pay | |
| 267 | 271 | for deployments, storage or embeddings. | |
| 268 | 272 | ||
| ⋯ | |||
| 362 | 366 | queue run) is charged by the minute, at a fixed rate for the machine it | |
| 363 | 367 | ran on: | |
| 364 | 368 | ||
| 365 | − | | `runs-on` | Machine | vCPUs | Memory | Disk | A minute | | |
| 366 | − | | ------------------------------------------------------ | ------------------- | ----- | ------ | ----- | -------- | | |
| 367 | − | | `ubuntu-latest`, any other Linux label, or `g1t-2core` | Standard (`2-core`) | 2 | 8 GiB | 16 GB | $0.004 | | |
| 368 | − | | `g1t-4core` | `4-core` | 4 | 12 GiB | 20 GB | $0.007 | | |
| 369 | + | | `runs-on` | Machine | vCPUs | Memory | Disk | A minute | | |
| 370 | + | | -------------------------------------------------------------------------------- | ------------------- | ----- | ------ | ----- | -------- | | |
| 371 | + | | `ubuntu-latest` or any other Linux label in a private repository, or `g1t-2core` | Standard (`2-core`) | 2 | 8 GiB | 16 GB | $0.004 | | |
| 372 | + | | `ubuntu-latest` or any other Linux label in a public repository, or `g1t-4core` | `4-core` | 4 | 12 GiB | 20 GB | $0.007 | | |
| 369 | 373 | ||
| 370 | − | Checks and the merge queue run on the standard machine. `g1t-2core` is | |
| 374 | + | `ubuntu-latest` gets what it gets on GitHub: 4 vCPUs in a public | |
| 375 | + | repository, 2 in a private one. A job is charged as the machine it ran | |
| 376 | + | on. Checks and the merge queue run on the standard machine. `g1t-2core` is | |
| 371 | 377 | kept as a name of the standard machine, so workflows that ask for it keep | |
| 372 | 378 | working, at its rate. | |
| 373 | 379 | ||
| 675 | 675 | ||
| 676 | 676 | The workspace's **Overview**, `g1t.sh/<workspace>/-/workspace`, opens with | |
| 677 | 677 | what its work says, for members who can see Code. It is worked out the | |
| 678 | − | same way as [Home's week](/guides/home/#this-week), and | |
| 678 | + | same way as [Home's daily signal](/guides/home/#the-daily-signal), and | |
| 679 | 679 | each person sees only the projects they can read. | |
| 680 | 680 | ||
| 681 | − | 1. **[The past week](/guides/home/#this-week)**: the same four | |
| 681 | + | 1. **[The past week](/guides/home/#the-past-week)**: the same four | |
| 682 | 682 | figures as Home. | |
| 683 | 683 | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | 684 | each milestone, its work items, each work item's change set in each |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | /** | |
| 37 | − | * Home's box that asks g1t, with a few things to ask first. It leads the | |
| 38 | − | * page, above everything it can be asked about, with what are we building | |
| 39 | − | * today. | |
| 37 | + | * Home's box that asks g1t, with a few things to ask first: under the | |
| 38 | + | * daily signal, `compact`, it says to ask about any of it; on its own, it | |
| 39 | + | * leads the page with what are we building today. | |
| 40 | 40 | */ | |
| 41 | − | export function HomeAsk({ span }: { span: AskSpan }) { | |
| 41 | + | export function HomeAsk({ span, compact = false }: { span: AskSpan; compact?: boolean }) { | |
| 42 | 42 | const [draft, setDraft] = useState(""); | |
| 43 | 43 | const send = () => { | |
| 44 | 44 | if (!draft.trim()) return; | |
| ⋯ | |||
| 56 | 56 | } | |
| 57 | 57 | }; | |
| 58 | 58 | return ( | |
| 59 | − | <section aria-labelledby="home-ask" className="mx-auto flex w-full max-w-5xl flex-col items-center gap-5 pt-4 pb-3 md:pt-8 md:pb-5"> | |
| 60 | − | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 61 | − | What are we building today? | |
| 62 | − | </h1> | |
| 59 | + | <section | |
| 60 | + | aria-labelledby="home-ask" | |
| 61 | + | className={compact ? "flex w-full flex-col gap-3" : "mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8"} | |
| 62 | + | > | |
| 63 | + | {compact ? ( | |
| 64 | + | <h2 id="home-ask" className="text-sm font-semibold text-fg"> | |
| 65 | + | Ask about any of this | |
| 66 | + | </h2> | |
| 67 | + | ) : ( | |
| 68 | + | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 69 | + | What are we building today? | |
| 70 | + | </h1> | |
| 71 | + | )} | |
| 63 | 72 | <form onSubmit={submit} className="w-full"> | |
| 64 | 73 | <div className="flex items-end gap-2 rounded-2xl border border-line bg-surface p-2 pl-4 shadow-sm transition-shadow focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25"> | |
| 65 | 74 | <MarkGlyph className="mb-2.5 h-4 shrink-0 max-sm:hidden" /> | |
| ⋯ | |||
| 83 | 92 | </Hint> | |
| 84 | 93 | </div> | |
| 85 | 94 | </form> | |
| 86 | − | <Suggestions className="justify-center"> | |
| 95 | + | <Suggestions className={compact ? "" : "justify-center"}> | |
| 87 | 96 | <Suggestion | |
| 88 | 97 | suggestion={`Summarize what changed ${spanLabel(span)}`} | |
| 89 | 98 | onPick={() => window.dispatchEvent(new CustomEvent(SUMMARIZE_EVENT))} | |
| 1 | − | /** | |
| 2 | − | * Home's week (lib/signals.ts): the week in a sentence and four figures, | |
| 3 | − | * the last two weeks day by day (what agents and people landed), and what | |
| 4 | − | * is worth a look, each pinned to the day it happened where it happened | |
| 5 | − | * on one. The page leads with what needs you; this is the work's own | |
| 6 | − | * account, so it never says whether anything needs anyone. | |
| 7 | − | */ | |
| 8 | − | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 9 | − | import { useState } from "react"; | |
| 10 | − | import { Link } from "react-router"; | |
| 11 | − | ||
| 12 | − | import { askG1t } from "../lib/ask"; | |
| 13 | − | import { cn } from "../lib/cn"; | |
| 14 | − | import type { DailySignal, DayBar, PulseTile, Signal } from "../lib/signals"; | |
| 15 | − | import { TrendLine } from "./signals"; | |
| 16 | − | import { Hint } from "./ui/hint"; | |
| 17 | − | import { Skeleton } from "./ui/skeleton"; | |
| 18 | − | ||
| 19 | − | const DELTA_TONE: Record<"good" | "bad" | "flat", string> = { good: "text-success", bad: "text-danger", flat: "text-faint" }; | |
| 20 | − | ||
| 21 | − | /** One figure: what it counts, this week's value, and the change on last week. */ | |
| 22 | − | function Figure({ tile }: { tile: PulseTile }) { | |
| 23 | − | return ( | |
| 24 | − | <Hint label={tile.hint}> | |
| 25 | − | <Link to={tile.to} className="group min-w-0 rounded-lg px-3 py-2 transition-colors hover:bg-raised"> | |
| 26 | − | <p className="truncate text-xs text-muted group-hover:text-fg">{tile.label}</p> | |
| 27 | − | <p className="mt-0.5 flex items-baseline gap-2"> | |
| 28 | − | <span className="text-xl font-semibold tracking-tight text-fg tabular-nums">{tile.value}</span> | |
| 29 | − | {tile.delta && ( | |
| 30 | − | <span className={cn("text-xs tabular-nums", DELTA_TONE[tile.delta.tone])}> | |
| 31 | − | {tile.delta.text} | |
| 32 | − | <span className="text-faint"> on last week</span> | |
| 33 | − | </span> | |
| 34 | − | )} | |
| 35 | − | </p> | |
| 36 | − | </Link> | |
| 37 | − | </Hint> | |
| 38 | − | ); | |
| 39 | − | } | |
| 40 | − | ||
| 41 | − | function dayName(day: string, style: "short" | "long"): string { | |
| 42 | − | const date = new Date(`${day}T00:00:00Z`); | |
| 43 | − | return style === "short" | |
| 44 | − | ? date.toLocaleDateString("en-US", { weekday: "narrow", timeZone: "UTC" }) | |
| 45 | − | : date.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" }); | |
| 46 | − | } | |
| 47 | − | ||
| 48 | − | function dayWords(bar: DayBar): string { | |
| 49 | − | const landed = bar.agent + bar.people; | |
| 50 | − | if (landed === 0 && bar.sessions === 0) return `${dayName(bar.day, "long")}: nothing landed`; | |
| 51 | − | const parts = [`${landed} landed`, `${bar.agent} by agents`, `${bar.people} by people`]; | |
| 52 | − | if (bar.sessions > 0) parts.push(`${bar.sessions} ${bar.sessions === 1 ? "session" : "sessions"} started`); | |
| 53 | − | return `${dayName(bar.day, "long")}: ${parts.join(", ")}`; | |
| 54 | − | } | |
| 55 | − | ||
| 56 | − | /** | |
| 57 | − | * Two weeks of days as columns: agents' changes in the accent, people's | |
| 58 | − | * above them, last week's faded. A finding that happened on a day is | |
| 59 | − | * pinned over it with its number in the list below. | |
| 60 | − | */ | |
| 61 | − | function Days({ days, pins }: { days: DayBar[]; pins: Map<string, number[]> }) { | |
| 62 | − | const [active, setActive] = useState<number | null>(null); | |
| 63 | − | const top = Math.max(1, ...days.map((bar) => bar.agent + bar.people)); | |
| 64 | − | const thisWeek = days.length - 7; | |
| 65 | − | const shown = active ?? days.length - 1; | |
| 66 | − | return ( | |
| 67 | − | <figure className="px-5"> | |
| 68 | − | <div | |
| 69 | − | className="grid h-36 items-end gap-1.5 sm:gap-2" | |
| 70 | − | style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} | |
| 71 | − | onPointerLeave={() => setActive(null)} | |
| 72 | − | > | |
| 73 | − | {days.map((bar, i) => { | |
| 74 | − | const landed = bar.agent + bar.people; | |
| 75 | − | const pinned = pins.get(bar.day); | |
| 76 | − | return ( | |
| 77 | − | <button | |
| 78 | − | key={bar.day} | |
| 79 | − | type="button" | |
| 80 | − | aria-label={dayWords(bar)} | |
| 81 | − | onPointerEnter={() => setActive(i)} | |
| 82 | − | onFocus={() => setActive(i)} | |
| 83 | − | onBlur={() => setActive(null)} | |
| 84 | − | className={cn( | |
| 85 | − | "group relative flex h-full flex-col items-stretch justify-end rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent/40", | |
| 86 | − | i < thisWeek && "opacity-45", | |
| 87 | − | active === i && "opacity-100", | |
| 88 | − | )} | |
| 89 | − | > | |
| 90 | − | {pinned && ( | |
| 91 | − | <span className="absolute inset-x-0 -top-1 flex justify-center gap-0.5"> | |
| 92 | − | {pinned.map((n) => ( | |
| 93 | − | <span key={n} className="flex size-4 items-center justify-center rounded-full bg-warn text-[0.625rem] font-semibold text-bg"> | |
| 94 | − | {n} | |
| 95 | − | </span> | |
| 96 | − | ))} | |
| 97 | − | </span> | |
| 98 | − | )} | |
| 99 | − | <span | |
| 100 | − | className="mx-auto flex w-full max-w-11 flex-col justify-end overflow-hidden rounded-[5px]" | |
| 101 | − | style={{ height: `${Math.max(landed > 0 ? 6 : 2, (landed / top) * 80)}%` }} | |
| 102 | − | > | |
| 103 | − | {landed === 0 ? ( | |
| 104 | − | <span className="h-full bg-line" /> | |
| 105 | − | ) : ( | |
| 106 | − | <> | |
| 107 | − | <span className="bg-line-strong transition-colors group-hover:bg-fg-soft/50" style={{ flexGrow: bar.people }} /> | |
| 108 | − | <span className="bg-accent transition-colors group-hover:bg-accent-hover" style={{ flexGrow: bar.agent }} /> | |
| 109 | − | </> | |
| 110 | − | )} | |
| 111 | − | </span> | |
| 112 | − | </button> | |
| 113 | − | ); | |
| 114 | − | })} | |
| 115 | − | </div> | |
| 116 | − | <div className="mt-1.5 grid gap-1.5 sm:gap-2" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} aria-hidden> | |
| 117 | − | {days.map((bar, i) => ( | |
| 118 | − | <span key={bar.day} className={cn("text-center font-mono text-[0.625rem] text-faint", i === days.length - 1 && "text-fg-soft")}> | |
| 119 | − | {dayName(bar.day, "short")} | |
| 120 | − | </span> | |
| 121 | − | ))} | |
| 122 | − | </div> | |
| 123 | − | <figcaption className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"> | |
| 124 | − | <span className="text-fg-soft" aria-live="polite"> | |
| 125 | − | {days[shown] ? dayWords(days[shown]) : ""} | |
| 126 | − | </span> | |
| 127 | − | <span className="ml-auto flex items-center gap-3"> | |
| 128 | − | <span className="flex items-center gap-1.5"> | |
| 129 | − | <span className="size-2 rounded-sm bg-accent" /> | |
| 130 | − | Agents | |
| 131 | − | </span> | |
| 132 | − | <span className="flex items-center gap-1.5"> | |
| 133 | − | <span className="size-2 rounded-sm bg-line-strong" /> | |
| 134 | − | People | |
| 135 | − | </span> | |
| 136 | − | <span className="text-faint">last week faded</span> | |
| 137 | − | </span> | |
| 138 | − | </figcaption> | |
| 139 | − | </figure> | |
| 140 | − | ); | |
| 141 | − | } | |
| 142 | − | ||
| 143 | − | /** A finding: what the work says, the evidence after it, where to see it, and Ask. */ | |
| 144 | − | function Finding({ signal, n }: { signal: Signal; n: number | null }) { | |
| 145 | − | return ( | |
| 146 | − | <li className={cn("grid gap-3 border-t border-line px-5 py-4", signal.trend && "md:grid-cols-[minmax(0,1fr)_9rem] md:gap-6")}> | |
| 147 | − | <div className="flex min-w-0 gap-3"> | |
| 148 | − | <span | |
| 149 | − | className={cn( | |
| 150 | − | "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-semibold", | |
| 151 | − | n != null ? "bg-warn text-bg" : "border border-line-strong text-faint", | |
| 152 | − | )} | |
| 153 | − | aria-hidden | |
| 154 | − | > | |
| 155 | − | {n ?? "·"} | |
| 156 | − | </span> | |
| 157 | − | <div className="min-w-0 space-y-1"> | |
| 158 | − | <p className="text-sm leading-snug font-medium text-fg">{signal.headline}</p> | |
| 159 | − | <p className="text-xs leading-relaxed text-muted">{signal.facts.join(" · ")}</p> | |
| 160 | − | <div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-0.5"> | |
| 161 | − | {signal.link && ( | |
| 162 | − | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-xs font-medium text-accent hover:underline"> | |
| 163 | − | {signal.link.label} | |
| 164 | − | <ChevronRight size={12} /> | |
| 165 | − | </Link> | |
| 166 | − | )} | |
| 167 | − | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 168 | − | <MessageSquare size={11} /> | |
| 169 | − | Ask g1t | |
| 170 | − | </button> | |
| 171 | − | </div> | |
| 172 | − | </div> | |
| 173 | − | </div> | |
| 174 | − | {signal.trend && ( | |
| 175 | − | <div className="hidden md:block"> | |
| 176 | − | <TrendLine | |
| 177 | − | values={signal.trend.values} | |
| 178 | − | points={signal.trend.points} | |
| 179 | − | label={`${signal.trend.caption}, ${signal.trend.range}`} | |
| 180 | − | width={144} | |
| 181 | − | height={32} | |
| 182 | − | /> | |
| 183 | − | <p className="mt-1 font-mono text-[0.625rem] text-faint">{signal.trend.range}</p> | |
| 184 | − | </div> | |
| 185 | − | )} | |
| 186 | − | </li> | |
| 187 | − | ); | |
| 188 | − | } | |
| 189 | − | ||
| 190 | − | /** Findings shown before "N more". */ | |
| 191 | − | const SHOWN = 4; | |
| 192 | − | ||
| 193 | − | /** Home's week: its figures, its days with findings pinned, and what is worth a look. */ | |
| 194 | − | export function HomeWeek({ view }: { view: DailySignal | null }) { | |
| 195 | − | const [more, setMore] = useState(false); | |
| 196 | − | if (view == null) { | |
| 197 | − | return ( | |
| 198 | − | <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface px-5 py-4"> | |
| 199 | − | <h2 id="week" className="text-base font-semibold tracking-tight text-fg"> | |
| 200 | − | This week | |
| 201 | − | </h2> | |
| 202 | − | <p className="mt-2 flex items-center gap-1.5 text-sm text-muted"> | |
| 203 | − | <TriangleAlert size={13} className="shrink-0 text-warn" /> | |
| 204 | − | The week's work couldn't be read just now. | |
| 205 | − | </p> | |
| 206 | − | </section> | |
| 207 | − | ); | |
| 208 | − | } | |
| 209 | − | // A number for each finding pinned to a day in the chart, in the list's order. | |
| 210 | − | const days = new Set(view.days.map((bar) => bar.day)); | |
| 211 | − | const numbers = new Map<string, number>(); | |
| 212 | − | const pins = new Map<string, number[]>(); | |
| 213 | − | for (const signal of view.findings) { | |
| 214 | − | if (!signal.day || !days.has(signal.day)) continue; | |
| 215 | − | const n = numbers.size + 1; | |
| 216 | − | numbers.set(signal.key, n); | |
| 217 | − | pins.set(signal.day, [...(pins.get(signal.day) ?? []), n]); | |
| 218 | − | } | |
| 219 | − | const shown = more ? view.findings : view.findings.slice(0, SHOWN); | |
| 220 | − | return ( | |
| 221 | − | <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface"> | |
| 222 | − | <header className="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-5 pt-4"> | |
| 223 | − | <h2 id="week" className="text-base font-semibold tracking-tight text-fg"> | |
| 224 | − | This week | |
| 225 | − | </h2> | |
| 226 | − | <p className="text-sm text-muted">{view.summary}</p> | |
| 227 | − | </header> | |
| 228 | − | {view.tiles.length > 0 && ( | |
| 229 | − | <div className="mt-2 grid grid-cols-2 gap-1 px-2 lg:grid-cols-4"> | |
| 230 | − | {view.tiles.map((tile) => ( | |
| 231 | − | <Figure key={tile.key} tile={tile} /> | |
| 232 | − | ))} | |
| 233 | − | </div> | |
| 234 | − | )} | |
| 235 | − | <div className="mt-4"> | |
| 236 | − | <Days days={view.days} pins={pins} /> | |
| 237 | − | </div> | |
| 238 | − | <div className="mt-5"> | |
| 239 | − | <h3 className="px-5 pb-2 text-xs font-medium text-muted">Worth a look</h3> | |
| 240 | − | {view.findings.length === 0 ? ( | |
| 241 | − | <p className="border-t border-line px-5 py-4 text-sm text-muted">Nothing in the work stands out against its last weeks.</p> | |
| 242 | − | ) : ( | |
| 243 | − | <ul> | |
| 244 | − | {shown.map((signal) => ( | |
| 245 | − | <Finding key={signal.key} signal={signal} n={numbers.get(signal.key) ?? null} /> | |
| 246 | − | ))} | |
| 247 | − | </ul> | |
| 248 | − | )} | |
| 249 | − | {view.findings.length > SHOWN && ( | |
| 250 | − | <button type="button" onClick={() => setMore(!more)} className="border-t border-line px-5 py-3 text-left text-xs text-muted hover:text-fg w-full"> | |
| 251 | − | {more ? "Show fewer" : `${view.findings.length - SHOWN} more worth a look`} | |
| 252 | − | </button> | |
| 253 | − | )} | |
| 254 | − | </div> | |
| 255 | − | {!view.complete && ( | |
| 256 | − | <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint"> | |
| 257 | − | <TriangleAlert size={12} className="shrink-0" /> | |
| 258 | − | Some projects didn't answer, so the week leaves them out. | |
| 259 | − | </p> | |
| 260 | − | )} | |
| 261 | − | <div className="h-2" /> | |
| 262 | − | </section> | |
| 263 | − | ); | |
| 264 | − | } | |
| 265 | − | ||
| 266 | − | export function HomeWeekSkeleton() { | |
| 267 | − | return ( | |
| 268 | − | <section className="space-y-4 rounded-2xl border border-line bg-surface px-5 py-4" aria-busy="true"> | |
| 269 | − | <Skeleton className="h-5 w-72" /> | |
| 270 | − | <div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> | |
| 271 | − | {[0, 1, 2, 3].map((i) => ( | |
| 272 | − | <Skeleton key={i} className="h-10" /> | |
| 273 | − | ))} | |
| 274 | − | </div> | |
| 275 | − | <Skeleton className="h-36 w-full" /> | |
| 276 | − | </section> | |
| 277 | − | ); | |
| 278 | − | } |
| 1 | 1 | /** | |
| 2 | − | * Home's parts (routes/workspace/home.tsx): Your move, Start here, what | |
| 3 | − | * happened in the span (what people did, what landed, builds, spend) and | |
| 4 | − | * Live (sessions, runs and builds going now). Each takes what | |
| 2 | + | * Home's parts (routes/workspace/home.tsx): Where you're needed, Start | |
| 3 | + | * here, what happened in the span (what people did, what landed, builds, | |
| 4 | + | * spend) and Running now. Each takes what | |
| 5 | 5 | * lib/home.ts worked out and only draws it; a row that arrived while the | |
| 6 | 6 | * page was open comes in (`animate-arrive`, lib/use-home-live.ts). | |
| 7 | 7 | */ | |
| ⋯ | |||
| 27 | 27 | } from "lucide-react"; | |
| 28 | 28 | import type { ReactNode } from "react"; | |
| 29 | 29 | import { Link } from "react-router"; | |
| 30 | − | ||
| 31 | − | import type { WorkspaceSession } from "@g1t/contracts"; | |
| 32 | 30 | ||
| 33 | 31 | import { | |
| 34 | 32 | type Attention, | |
| ⋯ | |||
| 62 | 60 | import { useArrived } from "../lib/use-home-live"; | |
| 63 | 61 | import { cn } from "../lib/cn"; | |
| 64 | 62 | import { money } from "../lib/money"; | |
| 65 | − | import { repoName, runsIn, sessionHref, workspaceSessionTitle } from "../lib/sessions"; | |
| 66 | 63 | import { CommitText } from "./commit-message"; | |
| 67 | − | import { HarnessMark, StateDot } from "./sessions"; | |
| 68 | 64 | import { Button } from "./ui/button"; | |
| 69 | 65 | import { Hint } from "./ui/hint"; | |
| 70 | 66 | import { ButtonLink } from "./ui"; | |
| ⋯ | |||
| 846 | 842 | deploy: <Rocket size={15} className="text-info" />, | |
| 847 | 843 | }; | |
| 848 | 844 | ||
| 849 | − | export function RunningList({ rows, missing, now, sessions = 0 }: { rows: RunningRow[]; missing: string[]; now: number; sessions?: number }) { | |
| 845 | + | export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) { | |
| 850 | 846 | const arrived = useArrived(rows.map((row) => row.key)); | |
| 851 | 847 | return ( | |
| 852 | 848 | <> | |
| 853 | 849 | {rows.length === 0 ? ( | |
| 854 | − | // With sessions going above it, an empty list of runs says nothing. | |
| 855 | − | sessions > 0 ? null : ( | |
| 856 | − | <Quiet> | |
| 857 | − | {missing.length > 0 | |
| 858 | − | ? "Nothing is running in what could be read." | |
| 859 | − | : "Nothing is running. Agents at work, workflow runs and builds show here while they go."} | |
| 860 | − | </Quiet> | |
| 861 | − | ) | |
| 850 | + | <Quiet> | |
| 851 | + | {missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Workflow runs and builds show here while they go."} | |
| 852 | + | </Quiet> | |
| 862 | 853 | ) : ( | |
| 863 | 854 | <Folded | |
| 864 | 855 | items={rows} | |
| ⋯ | |||
| 885 | 876 | )} | |
| 886 | 877 | <Missing names={missing} what="everything running" /> | |
| 887 | 878 | </> | |
| 888 | − | ); | |
| 889 | − | } | |
| 890 | − | ||
| 891 | − | /** | |
| 892 | − | * The agents at work now, each with what it is on, who runs it, how long | |
| 893 | − | * it has gone and what it has cost so far (list price, from its token | |
| 894 | − | * usage). Only g1t sees these: they are its sessions. | |
| 895 | − | */ | |
| 896 | − | export function LiveSessions({ sessions, slug, now }: { sessions: WorkspaceSession[] | null; slug: string; now: number }) { | |
| 897 | − | const arrived = useArrived((sessions ?? []).map((session) => session.id)); | |
| 898 | − | if (sessions == null) return <p className="px-5 pb-3 text-xs text-faint">Sessions couldn't be read just now.</p>; | |
| 899 | − | if (sessions.length === 0) return null; | |
| 900 | − | return ( | |
| 901 | − | <Folded | |
| 902 | − | items={sessions} | |
| 903 | − | render={(session) => { | |
| 904 | − | const to = sessionHref(slug, session); | |
| 905 | − | const cost = session.session?.costUsd; | |
| 906 | − | const title = workspaceSessionTitle(session); | |
| 907 | − | return ( | |
| 908 | − | <li key={session.id} className={cn(rowClass, "items-start px-4", arrived.has(session.id) && ARRIVED)}> | |
| 909 | − | <span className="mt-1"> | |
| 910 | − | <StateDot state={session.state} /> | |
| 911 | − | </span> | |
| 912 | − | <div className="min-w-0 grow"> | |
| 913 | − | {to ? ( | |
| 914 | − | <Link to={to} className="line-clamp-2 text-sm text-fg hover:underline"> | |
| 915 | − | {title} | |
| 916 | − | </Link> | |
| 917 | − | ) : ( | |
| 918 | − | <p className="line-clamp-2 text-sm text-fg">{title}</p> | |
| 919 | − | )} | |
| 920 | − | <p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted"> | |
| 921 | − | <HarnessMark session={session} size={12} /> | |
| 922 | − | <span className="truncate">{[session.actor, runsIn(session), repoName(session.repo, slug)].filter(Boolean).join(" · ")}</span> | |
| 923 | − | </p> | |
| 924 | − | <p className="mt-1 text-xs text-fg-soft tabular-nums" suppressHydrationWarning> | |
| 925 | − | {waited(Date.parse(session.startedAt), now)} | |
| 926 | − | {cost != null && Number.isFinite(cost) && <span className="text-faint"> · {money(Math.round(cost * 1_000_000))} so far</span>} | |
| 927 | − | {session.subagents > 0 && <span className="text-faint"> · {plural(session.subagents, "subagent")}</span>} | |
| 928 | − | </p> | |
| 929 | − | </div> | |
| 930 | − | </li> | |
| 931 | − | ); | |
| 932 | − | }} | |
| 933 | − | /> | |
| 934 | 879 | ); | |
| 935 | 880 | } | |
| 1 | 1 | /** | |
| 2 | − | * The week's figures on the Workspace overview (lib/signals.ts), each | |
| 3 | − | * against the week before and drawn over ten, and the trend line they and | |
| 4 | − | * Home's week (components/home-week.tsx) draw with. | |
| 2 | + | * The daily signal on Home (lib/signals.ts): the past week in four | |
| 3 | + | * figures, each against the week before and drawn over ten, and the | |
| 4 | + | * findings worth a look today, each a sentence with its evidence, where | |
| 5 | + | * to see it, and a way to ask g1t about it. | |
| 5 | 6 | */ | |
| 6 | − | import { useId, useState } from "react"; | |
| 7 | + | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useId, useState } from "react"; | |
| 7 | 9 | import { Link } from "react-router"; | |
| 8 | 10 | ||
| 11 | + | import { askG1t } from "../lib/ask"; | |
| 9 | 12 | import { cn } from "../lib/cn"; | |
| 10 | − | import type { PulseTile } from "../lib/signals"; | |
| 13 | + | import type { DailySignal as DailySignalView, PulseTile, Signal } from "../lib/signals"; | |
| 11 | 14 | import { Skeleton } from "./ui/skeleton"; | |
| 12 | 15 | ||
| 13 | 16 | // --- A trend line ------------------------------------------------------------------- | |
| ⋯ | |||
| 158 | 161 | </div> | |
| 159 | 162 | ); | |
| 160 | 163 | } | |
| 164 | + | ||
| 165 | + | // --- The findings ------------------------------------------------------------------- | |
| 166 | + | ||
| 167 | + | function Facts({ facts }: { facts: string[] }) { | |
| 168 | + | return ( | |
| 169 | + | <p className="font-mono text-[0.75rem] leading-relaxed text-muted"> | |
| 170 | + | {facts.map((fact, i) => ( | |
| 171 | + | <span key={i}> | |
| 172 | + | {i > 0 && <span className="px-1.5 text-faint">·</span>} | |
| 173 | + | {fact} | |
| 174 | + | </span> | |
| 175 | + | ))} | |
| 176 | + | </p> | |
| 177 | + | ); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | function Actions({ signal }: { signal: Signal }) { | |
| 181 | + | return ( | |
| 182 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> | |
| 183 | + | {signal.link && ( | |
| 184 | + | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-sm font-medium text-accent hover:underline"> | |
| 185 | + | {signal.link.label} | |
| 186 | + | <ChevronRight size={14} /> | |
| 187 | + | </Link> | |
| 188 | + | )} | |
| 189 | + | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 190 | + | <MessageSquare size={12} /> | |
| 191 | + | Ask g1t | |
| 192 | + | </button> | |
| 193 | + | </div> | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | function Trend({ trend }: { trend: NonNullable<Signal["trend"]> }) { | |
| 198 | + | const [point, setPoint] = useState<number | null>(null); | |
| 199 | + | return ( | |
| 200 | + | <figure className="space-y-1.5"> | |
| 201 | + | <TrendLine values={trend.values} points={trend.points} label={`${trend.caption}, ${trend.range}`} width={196} height={40} onPoint={setPoint} /> | |
| 202 | + | <figcaption> | |
| 203 | + | <p className="text-xs text-fg-soft">{trend.caption}</p> | |
| 204 | + | <p className="font-mono text-[0.6875rem] text-faint">{point != null ? trend.points[point] : trend.range}</p> | |
| 205 | + | </figcaption> | |
| 206 | + | </figure> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | function Finding({ signal }: { signal: Signal }) { | |
| 211 | + | return ( | |
| 212 | + | <li className={cn("grid gap-4 py-5 first:pt-0 last:pb-0", signal.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 213 | + | <div className="min-w-0 space-y-2"> | |
| 214 | + | <h3 className="font-display text-lg leading-snug text-fg md:text-xl">{signal.headline}</h3> | |
| 215 | + | <Facts facts={signal.facts} /> | |
| 216 | + | <Actions signal={signal} /> | |
| 217 | + | </div> | |
| 218 | + | {signal.trend && ( | |
| 219 | + | <div className="md:pt-1"> | |
| 220 | + | <Trend trend={signal.trend} /> | |
| 221 | + | </div> | |
| 222 | + | )} | |
| 223 | + | </li> | |
| 224 | + | ); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /** Findings under the lead, before "and N more". */ | |
| 228 | + | const SHOWN = 4; | |
| 229 | + | ||
| 230 | + | /** | |
| 231 | + | * The daily signal: the day, the finding that matters most as the page's | |
| 232 | + | * headline, and the others under it. A quiet week still gets a sentence. | |
| 233 | + | * Null `view`: the work service did not answer. | |
| 234 | + | */ | |
| 235 | + | export function DailySignal({ date, view, line, footer }: { date: string; view: DailySignalView | null; line?: ReactNode; footer?: ReactNode }) { | |
| 236 | + | const [more, setMore] = useState(false); | |
| 237 | + | const lead = view?.findings[0] ?? null; | |
| 238 | + | const rest = view?.findings.slice(1) ?? []; | |
| 239 | + | const shown = more ? rest : rest.slice(0, SHOWN); | |
| 240 | + | return ( | |
| 241 | + | <section aria-labelledby="daily-signal" className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 242 | + | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 243 | + | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 244 | + | {view == null ? ( | |
| 245 | + | <> | |
| 246 | + | <h1 id="daily-signal" className="mt-4 font-display text-3xl leading-tight tracking-tight text-fg md:text-[2.5rem]"> | |
| 247 | + | Here's your day. | |
| 248 | + | </h1> | |
| 249 | + | <p className="mt-3 flex items-center gap-1.5 text-sm text-muted"> | |
| 250 | + | <TriangleAlert size={13} className="shrink-0 text-warn" /> | |
| 251 | + | What the work says couldn't be read just now, so today's findings aren't here. | |
| 252 | + | </p> | |
| 253 | + | </> | |
| 254 | + | ) : ( | |
| 255 | + | <> | |
| 256 | + | <h1 | |
| 257 | + | id="daily-signal" | |
| 258 | + | className="mt-4 max-w-3xl font-display text-3xl leading-[1.12] tracking-tight text-balance text-fg md:text-[2.25rem] xl:text-[2.5rem]" | |
| 259 | + | > | |
| 260 | + | {lead?.headline ?? view.quiet} | |
| 261 | + | </h1> | |
| 262 | + | {lead && ( | |
| 263 | + | <div className={cn("mt-4 grid gap-4", lead.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 264 | + | <div className="space-y-2"> | |
| 265 | + | <Facts facts={lead.facts} /> | |
| 266 | + | <Actions signal={lead} /> | |
| 267 | + | </div> | |
| 268 | + | {lead.trend && <Trend trend={lead.trend} />} | |
| 269 | + | </div> | |
| 270 | + | )} | |
| 271 | + | {!view.complete && ( | |
| 272 | + | <p className="mt-3 flex items-center gap-1.5 text-xs text-faint"> | |
| 273 | + | <TriangleAlert size={12} className="shrink-0" /> | |
| 274 | + | Some projects didn't answer, so these findings leave them out. | |
| 275 | + | </p> | |
| 276 | + | )} | |
| 277 | + | </> | |
| 278 | + | )} | |
| 279 | + | {line && <p className="mt-4 text-sm text-muted">{line}</p>} | |
| 280 | + | {shown.length > 0 && ( | |
| 281 | + | <> | |
| 282 | + | <hr className="my-7 border-line" /> | |
| 283 | + | <ul className="divide-y divide-line"> | |
| 284 | + | {shown.map((signal) => ( | |
| 285 | + | <Finding key={signal.key} signal={signal} /> | |
| 286 | + | ))} | |
| 287 | + | </ul> | |
| 288 | + | {rest.length > SHOWN && ( | |
| 289 | + | <button type="button" onClick={() => setMore(!more)} className="mt-5 text-xs text-muted hover:text-fg"> | |
| 290 | + | {more ? "Show fewer" : `${rest.length - SHOWN} more ${rest.length - SHOWN === 1 ? "finding" : "findings"}`} | |
| 291 | + | </button> | |
| 292 | + | )} | |
| 293 | + | </> | |
| 294 | + | )} | |
| 295 | + | {footer && <div className="mt-8 border-t border-line pt-6">{footer}</div>} | |
| 296 | + | </section> | |
| 297 | + | ); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | export function DailySignalSkeleton({ date }: { date: string }) { | |
| 301 | + | return ( | |
| 302 | + | <section className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 303 | + | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 304 | + | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 305 | + | <Skeleton className="mt-5 h-9 w-full max-w-2xl" /> | |
| 306 | + | <Skeleton className="mt-2 h-9 w-2/3 max-w-xl" /> | |
| 307 | + | <Skeleton className="mt-5 h-3 w-64" /> | |
| 308 | + | </section> | |
| 309 | + | ); | |
| 310 | + | } | |
| 8 | 8 | type WeekTotals, | |
| 9 | 9 | agentStep, | |
| 10 | 10 | blockedSignal, | |
| 11 | − | dayBars, | |
| 12 | 11 | dayList, | |
| 13 | 12 | emptySignals, | |
| 14 | 13 | findings, | |
| ⋯ | |||
| 19 | 18 | pulse, | |
| 20 | 19 | quarterSpend, | |
| 21 | 20 | quarterStart, | |
| 21 | + | quietHeadline, | |
| 22 | 22 | reviewSignal, | |
| 23 | 23 | reworkSignal, | |
| 24 | 24 | rhythm, | |
| ⋯ | |||
| 33 | 33 | spreading, | |
| 34 | 34 | stalledSignal, | |
| 35 | 35 | weekStarts, | |
| 36 | − | weekSummary, | |
| 37 | 36 | weekTotals, | |
| 38 | 37 | weeklySpend, | |
| 39 | 38 | } from "./signals.ts"; | |
| ⋯ | |||
| 197 | 196 | ], | |
| 198 | 197 | }; | |
| 199 | 198 | const signal = blockedSignal(ctx({ signals }))!; | |
| 200 | − | assert.equal(signal.headline, "tax#4821 holds up three work items, and no one has reviewed it yet."); | |
| 199 | + | assert.equal(signal.headline, "Three work items are waiting on one unreviewed change."); | |
| 201 | 200 | assert.deepEqual(signal.facts, ["tax#4821 Nexus threshold configuration", "open 2 days", "holds up #8, #9, #10"]); | |
| 202 | 201 | assert.equal(signal.link!.to, "/acme/tax/pull/4821"); | |
| 203 | 202 | assert.equal(blockedSignal(ctx()), null); | |
| ⋯ | |||
| 233 | 232 | ], | |
| 234 | 233 | }); | |
| 235 | 234 | const signal = spendSignal(ctx({ signals, quarterUsage }))!; | |
| 236 | − | assert.equal(signal.headline, "The Checkout replatform took more than half of the agent spend this quarter."); | |
| 235 | + | assert.equal(signal.headline, "More than half of this quarter's agent spend is going into the Checkout replatform."); | |
| 237 | 236 | assert.deepEqual(signal.facts, ["$28.3K of $52.4K", "quarter to date"]); | |
| 238 | 237 | const even = usage({ byPull: quarterUsage.byPull.map((slice) => ({ ...slice, micros: 1_000_000 })) }); | |
| 239 | 238 | assert.equal(spendSignal(ctx({ signals, quarterUsage: even })), null); | |
| ⋯ | |||
| 252 | 251 | ], | |
| 253 | 252 | }; | |
| 254 | 253 | const signal = shippedSignal(ctx({ signals }))!; | |
| 255 | − | assert.equal(signal.headline, "Single sign-on closed Tuesday, three weeks ahead of schedule."); | |
| 256 | − | assert.equal(signal.day, "2026-10-13"); | |
| 254 | + | assert.equal(signal.headline, "Single sign-on shipped on Tuesday, three weeks ahead of schedule."); | |
| 257 | 255 | assert.deepEqual(signal.facts, ["11 weeks in build", "2 changes", "2 projects"]); | |
| 258 | 256 | const old = { ...signals, milestones: [{ ...signals.milestones[0]!, closedAt: "2026-09-01T00:00:00Z" }] }; | |
| 259 | 257 | assert.equal(shippedSignal(ctx({ signals: old })), null, "only the last week's"); | |
| ⋯ | |||
| 300 | 298 | assert.equal(signal.headline, "bo is starting to work in web, which ana has carried almost alone."); | |
| 301 | 299 | }); | |
| 302 | 300 | ||
| 303 | − | test("findings come highest first, and the week is said in a sentence that claims nothing about what needs anyone", () => { | |
| 301 | + | test("findings come highest first, and a quiet week still says something", () => { | |
| 304 | 302 | const signals = { | |
| 305 | 303 | ...emptySignals(), | |
| 306 | 304 | waiting: [{ repoId: "r1", number: 2, title: "Fix", createdAt: "2026-10-16T00:00:00Z", agent: false, draftSeconds: null, item: 1, blocks: [3, 4] }], | |
| ⋯ | |||
| 310 | 308 | found.map((s) => s.key), | |
| 311 | 309 | ["blocked", "stalled"], | |
| 312 | 310 | ); | |
| 313 | − | assert.equal(weekSummary(week("x")), "Nothing has landed this week yet."); | |
| 314 | − | assert.equal(weekSummary(week("x", { merged: 4, agentMerged: 2 })), "Four changes landed this week, 50% of them by agents."); | |
| 311 | + | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has landed yet, and nothing is stuck."); | |
| 312 | + | assert.equal( | |
| 313 | + | quietHeadline(week("x", { merged: 4, agentMerged: 2 })), | |
| 314 | + | "Four changes landed this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 315 | + | ); | |
| 315 | 316 | // A workspace that pushes straight to main: agents' direct commits are what landed. | |
| 316 | − | assert.equal(weekSummary(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), "Ten changes landed this week, 90% of them by agents."); | |
| 317 | + | assert.equal( | |
| 318 | + | quietHeadline(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), | |
| 319 | + | "Ten changes landed this week, 90% of them an agent's, and nothing needs a closer look.", | |
| 320 | + | ); | |
| 317 | 321 | }); | |
| 318 | 322 | ||
| 319 | 323 | test("waits are said in words and in a few characters", () => { | |
| ⋯ | |||
| 437 | 441 | test("agents' direct commits count as what they landed, and as what spend is spread over", () => { | |
| 438 | 442 | const weeks = [week("2026-10-05", { merged: 2, agentMerged: 1, agentCommits: 3 }), week("2026-10-12", { merged: 1, agentMerged: 1, agentCommits: 9 })]; | |
| 439 | 443 | const [changes, spend] = pulse({ slug: "acme", weeks, spend: [4_000_000, 20_000_000] }); | |
| 440 | − | assert.equal(changes!.label, "Landed by agents"); | |
| 444 | + | assert.equal(changes!.label, "Agent-authored changes"); | |
| 441 | 445 | assert.equal(changes!.value, "10"); | |
| 442 | 446 | assert.deepEqual(changes!.delta, { text: "+6", tone: "good" }); | |
| 443 | 447 | assert.equal(changes!.points[1], "Week of Oct 12: 1 merged, 9 pushed directly, of 10 changes"); | |
| 444 | 448 | assert.equal(spend!.value, "$2.00", "$20 over ten agent changes"); | |
| 445 | − | }); | |
| 446 | − | ||
| 447 | − | test("the last two weeks day by day: every repository added up, agents' and people's, and empty days as zeros", () => { | |
| 448 | − | const bars = dayBars( | |
| 449 | − | [ | |
| 450 | − | { day: "2026-10-18", merged: 3, agentMerged: 2, agentCommits: 4, sessions: 5 }, | |
| 451 | − | { day: "2026-10-18", merged: 1, agentMerged: 0, agentCommits: 0, sessions: 1 }, | |
| 452 | − | { day: "2026-10-10", merged: 2, agentMerged: 2, agentCommits: 0, sessions: 0 }, | |
| 453 | − | // Outside the two weeks: left out. | |
| 454 | − | { day: "2026-09-01", merged: 9, agentMerged: 9, agentCommits: 9, sessions: 9 }, | |
| 455 | − | ], | |
| 456 | − | NOW, | |
| 457 | − | ); | |
| 458 | − | assert.equal(bars.length, 14); | |
| 459 | − | assert.equal(bars[0]!.day, "2026-10-05"); | |
| 460 | − | assert.deepEqual(bars.at(-1), { day: "2026-10-18", agent: 6, people: 2, sessions: 6 }); | |
| 461 | − | assert.deepEqual( | |
| 462 | − | bars.find((bar) => bar.day === "2026-10-10"), | |
| 463 | − | { day: "2026-10-10", agent: 2, people: 0, sessions: 0 }, | |
| 464 | − | ); | |
| 465 | − | assert.deepEqual( | |
| 466 | − | bars.find((bar) => bar.day === "2026-10-11"), | |
| 467 | − | { day: "2026-10-11", agent: 0, people: 0, sessions: 0 }, | |
| 468 | − | ); | |
| 469 | 449 | }); | |
| 315 | 315 | return [ | |
| 316 | 316 | { | |
| 317 | 317 | key: "merges", | |
| 318 | − | label: "Landed by agents", | |
| 318 | + | label: "Agent-authored changes", | |
| 319 | 319 | value: String(agentChanges(now)), | |
| 320 | 320 | delta: deltaOf(agentChanges(now), before ? agentChanges(before) : null, count, true), | |
| 321 | 321 | series: weeks.map(agentChanges), | |
| ⋯ | |||
| 325 | 325 | }, | |
| 326 | 326 | { | |
| 327 | 327 | key: "spend", | |
| 328 | − | label: "Cost per agent change", | |
| 328 | + | label: "Spend per agent change", | |
| 329 | 329 | value: dollars(perMerge[at] ?? null), | |
| 330 | 330 | delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false), | |
| 331 | 331 | series: perMerge, | |
| ⋯ | |||
| 335 | 335 | }, | |
| 336 | 336 | { | |
| 337 | 337 | key: "stalled", | |
| 338 | − | label: "Gone quiet", | |
| 338 | + | label: "Stalled changes", | |
| 339 | 339 | value: String(now.stalled), | |
| 340 | 340 | delta: deltaOf(now.stalled, before?.stalled ?? null, count, false), | |
| 341 | 341 | series: weeks.map((week) => week.stalled), | |
| ⋯ | |||
| 345 | 345 | }, | |
| 346 | 346 | { | |
| 347 | 347 | key: "rework", | |
| 348 | − | label: "Sent back", | |
| 348 | + | label: "Agent rework rate", | |
| 349 | 349 | value: percent(rework[at] ?? null), | |
| 350 | 350 | delta: deltaOf(rework[at] ?? null, before ? (rework[at - 1] ?? null) : null, (abs) => `${(abs * 100).toFixed(1).replace(/\.0$/, "")} pts`, false), | |
| 351 | 351 | series: rework, | |
| ⋯ | |||
| 369 | 369 | trend?: { values: (number | null)[]; points: string[]; caption: string; range: string }; | |
| 370 | 370 | /** How much it matters today: findings are shown highest first. */ | |
| 371 | 371 | weight: number; | |
| 372 | − | /** The UTC day it happened (`YYYY-MM-DD`), when it happened on one: Home pins it there on the week. */ | |
| 373 | − | day?: string; | |
| 374 | 372 | /** What Ask is asked, to look into it. */ | |
| 375 | 373 | ask: string; | |
| 376 | 374 | }; | |
| ⋯ | |||
| 413 | 411 | const shown = top.blocks.slice(0, 3).map((number) => `#${number}`); | |
| 414 | 412 | return { | |
| 415 | 413 | key: "blocked", | |
| 416 | − | headline: | |
| 417 | − | n === 1 | |
| 418 | − | ? `${repo.name}#${top.number} holds up a work item, and no one has reviewed it yet.` | |
| 419 | − | : `${repo.name}#${top.number} holds up ${countWord(n).toLowerCase()} work items, and no one has reviewed it yet.`, | |
| 414 | + | headline: n === 1 ? "A work item is waiting on a change no one has reviewed." : `${countWord(n)} work items are waiting on one unreviewed change.`, | |
| 420 | 415 | facts: [ | |
| 421 | 416 | `${repo.name}#${top.number} ${top.title}`, | |
| 422 | 417 | `open ${spoken(ago(ctx.now, top.createdAt))}`, | |
| ⋯ | |||
| 547 | 542 | if (!top) return null; | |
| 548 | 543 | const share = top.micros / total; | |
| 549 | 544 | if (share < 0.35 || groups.size < 2) return null; | |
| 550 | − | const amount = share > 0.5 ? "more than half" : share > 1 / 3 ? "more than a third" : `${Math.round(share * 100)}%`; | |
| 545 | + | const amount = share > 0.5 ? "More than half" : share > 1 / 3 ? "More than a third" : `${Math.round(share * 100)}%`; | |
| 551 | 546 | return { | |
| 552 | 547 | key: "spend", | |
| 553 | − | headline: `${top.label.charAt(0).toUpperCase()}${top.label.slice(1)} took ${amount} of the agent spend this quarter.`, | |
| 548 | + | headline: `${amount} of this quarter's agent spend is going into ${top.label}.`, | |
| 554 | 549 | facts: [`${dollars(top.micros)} of ${dollars(total)}`, "quarter to date"], | |
| 555 | 550 | link: { label: "See the spend", to: `/${ctx.slug}/-/workspace#spend` }, | |
| 556 | 551 | weight: 25 + share * 40, | |
| ⋯ | |||
| 585 | 580 | } | |
| 586 | 581 | return { | |
| 587 | 582 | key: "shipped", | |
| 588 | − | headline: `${recent.title} closed ${weekday}${schedule}.`, | |
| 589 | − | day: recent.closedAt!.slice(0, 10), | |
| 583 | + | headline: `${recent.title} shipped on ${weekday}${schedule}.`, | |
| 590 | 584 | facts: [ | |
| 591 | 585 | `${weeksIn} ${weeksIn === 1 ? "week" : "weeks"} in build`, | |
| 592 | 586 | `${pulls.length} ${pulls.length === 1 ? "change" : "changes"}`, | |
| ⋯ | |||
| 613 | 607 | trend: { | |
| 614 | 608 | values: ctx.weeks.map((week) => week.stalled), | |
| 615 | 609 | points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`), | |
| 616 | − | caption: "Gone quiet", | |
| 610 | + | caption: "Stalled changes", | |
| 617 | 611 | range: `${ctx.weeks[0]!.stalled} → ${now.stalled} over ${ctx.weeks.length} weeks`, | |
| 618 | 612 | }, | |
| 619 | 613 | weight: 30 + Math.min(25, more * 2), | |
| ⋯ | |||
| 821 | 815 | facts: [`${first.elsewhere} merges elsewhere before`, `first in ${repo.name}`], | |
| 822 | 816 | link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` }, | |
| 823 | 817 | weight: 20 + Math.min(10, others * 3), | |
| 824 | − | day: first.firstMergedAt.slice(0, 10), | |
| 825 | 818 | ask: `What did ${first.name} change in ${repo.namespace}/${repo.name}?`, | |
| 826 | 819 | }; | |
| 827 | 820 | } | |
| ⋯ | |||
| 842 | 835 | return found.sort((a, b) => b.weight - a.weight); | |
| 843 | 836 | } | |
| 844 | 837 | ||
| 845 | − | /** | |
| 846 | − | * The week in a sentence: what landed and how much of it was agents'. It | |
| 847 | − | * says nothing about what needs anyone; Home leads with that on its own. | |
| 848 | − | */ | |
| 849 | − | export function weekSummary(week: WeekTotals | undefined): string { | |
| 838 | + | /** The headline when nothing stands out: still a sentence about the week. */ | |
| 839 | + | export function quietHeadline(week: WeekTotals | undefined): string { | |
| 850 | 840 | const landed = week ? allChanges(week) : 0; | |
| 851 | − | if (!week || landed === 0) return "Nothing has landed this week yet."; | |
| 841 | + | if (!week || landed === 0) return "A quiet week: nothing has landed yet, and nothing is stuck."; | |
| 852 | 842 | const share = agentShare(week); | |
| 853 | − | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them by agents` : ""}.`; | |
| 843 | + | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; | |
| 854 | 844 | } | |
| 855 | 845 | ||
| 856 | − | /** One day on Home's week: what landed, agents' and people's, and the sessions started. */ | |
| 857 | − | export type DayBar = { day: string; agent: number; people: number; sessions: number }; | |
| 858 | − | ||
| 859 | − | /** | |
| 860 | − | * The last `count` UTC days to `now`, oldest first, every repository's | |
| 861 | − | * rows added up; a day with nothing in it is zeros. Agents' are their | |
| 862 | − | * merged pull requests and the commits their sessions pushed directly; | |
| 863 | − | * people's, the other merged pull requests. | |
| 864 | − | */ | |
| 865 | − | export function dayBars(days: Pick<SignalDay, "day" | "merged" | "agentMerged" | "agentCommits" | "sessions">[], now: number, count = SIGNAL_DAYS): DayBar[] { | |
| 866 | − | const until = utcDay(now); | |
| 867 | − | const bars = new Map<string, DayBar>(); | |
| 868 | − | for (let i = count - 1; i >= 0; i--) { | |
| 869 | − | const day = addDays(until, -i); | |
| 870 | − | bars.set(day, { day, agent: 0, people: 0, sessions: 0 }); | |
| 871 | − | } | |
| 872 | − | for (const row of days) { | |
| 873 | − | const bar = bars.get(row.day); | |
| 874 | − | if (!bar) continue; | |
| 875 | − | bar.agent += row.agentMerged + (row.agentCommits ?? 0); | |
| 876 | − | bar.people += Math.max(0, row.merged - row.agentMerged); | |
| 877 | − | bar.sessions += row.sessions; | |
| 878 | − | } | |
| 879 | − | return [...bars.values()]; | |
| 880 | − | } | |
| 881 | − | ||
| 882 | − | /** What Home shows of the work: the week's figures, its days, and the findings, most important first. */ | |
| 846 | + | /** What Home shows: the pulse, and the findings with the one that leads. */ | |
| 883 | 847 | export type DailySignal = { | |
| 884 | 848 | tiles: PulseTile[]; | |
| 885 | 849 | findings: Signal[]; | |
| 886 | − | /** The week in a sentence (`weekSummary`). */ | |
| 887 | − | summary: string; | |
| 888 | − | days: DayBar[]; | |
| 850 | + | /** The headline when no finding stands out. */ | |
| 851 | + | quiet: string; | |
| 889 | 852 | complete: boolean; | |
| 890 | 853 | }; | |
| 891 | 854 | ||
| ⋯ | |||
| 911 | 874 | quarterUsage: data.quarterUsage, | |
| 912 | 875 | quarter: data.range.quarter, | |
| 913 | 876 | }), | |
| 914 | − | summary: weekSummary(data.weeks[data.weeks.length - 1]), | |
| 915 | − | days: dayBars(data.signals.days, data.now), | |
| 877 | + | quiet: quietHeadline(data.weeks[data.weeks.length - 1]), | |
| 916 | 878 | complete: data.complete, | |
| 917 | 879 | }; | |
| 918 | 880 | } | |
| 1 | 1 | import { Suspense, useEffect, useState } from "react"; | |
| 2 | 2 | import { Await, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { type WorkspaceSession, hasCodeAccess } from "@g1t/contracts"; | |
| 4 | + | import { hasCodeAccess } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/home"; | |
| 7 | 7 | import { | |
| 8 | 8 | Card, | |
| 9 | 9 | LandedFeed, | |
| 10 | − | LiveSessions, | |
| 11 | 10 | NeedsList, | |
| 12 | 11 | PeopleSummary, | |
| 13 | 12 | RowsSkeleton, | |
| ⋯ | |||
| 19 | 18 | WindowSwitch, | |
| 20 | 19 | } from "../../components/home"; | |
| 21 | 20 | import { HomeAsk, WhatChangedCard } from "../../components/home-ask"; | |
| 22 | − | import { HomeWeek, HomeWeekSkeleton } from "../../components/home-week"; | |
| 21 | + | import { DailySignal, DailySignalSkeleton, PulseSkeleton, PulseStrip } from "../../components/signals"; | |
| 23 | 22 | import { Skeleton } from "../../components/ui/skeleton"; | |
| 24 | 23 | import { sidebarOrNull } from "../../lib/chat.server"; | |
| 25 | 24 | import { | |
| ⋯ | |||
| 67 | 66 | } from "../../lib/home.server"; | |
| 68 | 67 | import { HOME_WATCH_MAX, type HomeRepo } from "../../lib/home-live"; | |
| 69 | 68 | import { readCookie } from "../../lib/mission"; | |
| 70 | − | import { dailySignal } from "../../lib/signals"; | |
| 69 | + | import { dailySignal, signalDate } from "../../lib/signals"; | |
| 71 | 70 | import { loadSignalData } from "../../lib/signals.server"; | |
| 72 | 71 | import { useHomeLive } from "../../lib/use-home-live"; | |
| 73 | 72 | import { guideIsFor, loadOnboarding } from "../../lib/onboarding.server"; | |
| 74 | 73 | import { GettingStartedButton, OnboardingOnPage } from "../../components/onboarding"; | |
| 75 | 74 | import { loadMissionControl } from "../../lib/mission-control.server"; | |
| 76 | 75 | import { page } from "../../lib/meta"; | |
| 77 | − | import { inbox, work } from "../../lib/services.server"; | |
| 76 | + | import { inbox } from "../../lib/services.server"; | |
| 78 | 77 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 79 | 78 | import { homePagePath } from "../../lib/workspace-nav"; | |
| 80 | 79 | import { Boundary } from "../../components/boundary"; | |
| ⋯ | |||
| 83 | 82 | return page(args, { title: `Home · ${params.owner} · g1t` }); | |
| 84 | 83 | } | |
| 85 | 84 | ||
| 86 | − | /** Unread notifications read for Your move. */ | |
| 85 | + | /** Unread notifications read for Needs you. */ | |
| 87 | 86 | const NOTIFICATIONS_READ = 50; | |
| 88 | − | /** Live sessions read for Live. */ | |
| 89 | − | const LIVE_READ = 12; | |
| 90 | 87 | ||
| 91 | 88 | /** | |
| 92 | − | * Home: the workspace's front page. First the box that asks g1t | |
| 89 | + | * Home: the workspace's front page. First the past week in four figures | |
| 90 | + | * and the daily signal: what the workspace's work says today, the finding | |
| 91 | + | * that matters most as the page's headline, each with its evidence | |
| 92 | + | * (lib/signals.ts), and under them a box that asks g1t about any of it | |
| 93 | 93 | * (components/home-ask: What changed summarizes the span on the page, and | |
| 94 | − | * Explain on each change that landed asks about it). Then Your move: | |
| 95 | − | * everything waiting on you, with no time window, beside Live: the agents | |
| 96 | − | * at work, workflow runs and builds going now. Then the week's work | |
| 97 | − | * (components/home-week, lib/signals.ts): four figures, the last two weeks | |
| 98 | − | * day by day, and what is worth a look, each with its evidence and pinned | |
| 99 | − | * to its day where it has one. Then what happened since you were last | |
| 100 | − | * here (or over the last 24 hours or 7 days, `?window=`): what people | |
| 101 | − | * did, from the events service's digest of the span and chat's, then what | |
| 102 | − | * landed; beside it what the span cost and what settled by itself. The last visit is kept by notify, per | |
| 94 | + | * Explain on each change that landed asks about it); then where you're | |
| 95 | + | * needed now, with no time window; what happened since you were last here (or over the last 24 | |
| 96 | + | * hours or 7 days, `?window=`): what people did, from the events | |
| 97 | + | * service's digest of the span and chat's, then what landed; what is | |
| 98 | + | * running; what the span cost. The last visit is kept by notify, per | |
| 103 | 99 | * person and workspace, and marked by the page once it has been looked | |
| 104 | 100 | * at (routes/workspace/home-seen.ts), so a refresh never wipes it. Only | |
| 105 | 101 | * the last visit (for the span) is waited for; every section streams in | |
| ⋯ | |||
| 223 | 219 | missing: [...(code && (!workflows || !workflows.complete) ? ["Workflows"] : []), ...(code && !deploys ? ["Deployments"] : [])], | |
| 224 | 220 | })) | |
| 225 | 221 | .catch(() => ({ rows: [], missing: ["Workflows", "Deployments"] })); | |
| 226 | − | // The agents at work now: harness and hosted sessions that are live. | |
| 227 | − | const live: Promise<WorkspaceSession[] | null> = code | |
| 228 | − | ? work | |
| 229 | − | .listWorkspaceSessions(slug, viewer, { shown: "live", sessionsOnly: true, limit: LIVE_READ }) | |
| 230 | − | .then((result) => (result.ok ? result.value.sessions : null)) | |
| 231 | − | .catch(() => null) | |
| 232 | − | : Promise.resolve([]); | |
| 233 | 222 | const spend = loadSpend(viewer, slug, span).catch(() => null); | |
| 234 | 223 | // What the workspace's work says: the pulse and the findings, for members who see Code. | |
| 235 | 224 | const signal = code | |
| ⋯ | |||
| 258 | 247 | attention: waiting, | |
| 259 | 248 | away, | |
| 260 | 249 | running: runningNow, | |
| 261 | − | live, | |
| 262 | − | /** The sentence about the span needs the digest and what landed. */ | |
| 263 | − | head: Promise.all([digest, happened]), | |
| 250 | + | /** The sentence under the heading needs the digest, what landed and what waits. */ | |
| 251 | + | head: Promise.all([digest, happened, waiting]), | |
| 264 | 252 | spend, | |
| 265 | 253 | signal, | |
| 254 | + | date: signalDate(now, tz), | |
| 266 | 255 | watch, | |
| 267 | 256 | onboarding, | |
| 268 | 257 | setupAsked, | |
| ⋯ | |||
| 327 | 316 | } | |
| 328 | 317 | ||
| 329 | 318 | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 330 | − | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, live, head, spend, signal } = loaderData; | |
| 319 | + | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend, signal } = loaderData; | |
| 331 | 320 | useMarkSeen(slug, now); | |
| 332 | 321 | useHomeLive(slug, useWatched(loaderData.watch)); | |
| 333 | 322 | return ( | |
| 334 | − | <div className="mx-auto w-full max-w-360 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 323 | + | <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 335 | 324 | <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} /> | |
| 336 | 325 | {loaderData.guide && ( | |
| 337 | 326 | <div className="flex justify-end"> | |
| 338 | 327 | <GettingStartedButton slug={slug} variant="soft" /> | |
| 339 | 328 | </div> | |
| 340 | 329 | )} | |
| 341 | − | {/* 0. The box that asks g1t leads the page, above everything it can be asked about. */} | |
| 342 | − | <HomeAsk span={span} /> | |
| 343 | − | {/* | |
| 344 | − | What needs you and what is going on now, side by side; on a phone, | |
| 345 | − | one after the other. Then the week's work, then what happened since | |
| 346 | − | you were last here beside what it cost. | |
| 347 | − | */} | |
| 348 | − | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 349 | − | {/* 1. Your move: everything waiting on you, the one to start with on top, with no time window. */} | |
| 350 | − | <div id="needed" className="scroll-mt-4"> | |
| 351 | − | <Suspense | |
| 352 | − | fallback={ | |
| 353 | − | <Card title="Your move"> | |
| 354 | − | <RowsSkeleton rows={4} /> | |
| 355 | − | </Card> | |
| 356 | − | } | |
| 357 | − | > | |
| 358 | − | {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */} | |
| 359 | − | <Await resolve={waiting}> | |
| 360 | − | {(att) => ( | |
| 361 | − | <Card title="Your move" hint={waitingSentence(att.rows.length)} link={{ label: "Notifications", to: "/notifications" }}> | |
| 362 | − | <Boundary what="Your move"> | |
| 363 | − | <NeedsList attention={att} now={now} /> | |
| 364 | − | </Boundary> | |
| 365 | − | </Card> | |
| 366 | − | )} | |
| 367 | − | </Await> | |
| 368 | − | </Suspense> | |
| 369 | − | </div> | |
| 370 | − | ||
| 371 | − | {/* 2. Live: agents at work, then workflow runs and builds, as they go. */} | |
| 372 | − | <Card title="Live" hint="Agents, runs and builds going now"> | |
| 373 | − | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 374 | − | <Await resolve={Promise.all([live, runningNow])}> | |
| 375 | − | {([sessions, value]) => ( | |
| 376 | − | <Boundary what="Live"> | |
| 377 | − | <LiveSessions sessions={sessions} slug={slug} now={now} /> | |
| 378 | − | <RunningList rows={value.rows} missing={value.missing} now={now} sessions={sessions?.length ?? 0} /> | |
| 379 | − | </Boundary> | |
| 380 | − | )} | |
| 381 | − | </Await> | |
| 382 | − | </Suspense> | |
| 383 | − | </Card> | |
| 384 | − | </div> | |
| 385 | − | ||
| 386 | − | {/* 3. The week's work, for members who see Code: its figures, its days, and what is worth a look. */} | |
| 330 | + | {/* 0. The past week, against the week before, for members who see Code. */} | |
| 387 | 331 | {signal && ( | |
| 388 | − | <Suspense fallback={<HomeWeekSkeleton />}> | |
| 332 | + | <Suspense fallback={<PulseSkeleton />}> | |
| 389 | 333 | <Await resolve={signal}> | |
| 390 | − | {(value) => ( | |
| 391 | − | <Boundary what="This week"> | |
| 392 | − | <HomeWeek view={value} /> | |
| 393 | − | </Boundary> | |
| 394 | − | )} | |
| 334 | + | {(value) => | |
| 335 | + | value && ( | |
| 336 | + | <Boundary what="The past week"> | |
| 337 | + | <PulseStrip tiles={value.tiles} /> | |
| 338 | + | </Boundary> | |
| 339 | + | ) | |
| 340 | + | } | |
| 395 | 341 | </Await> | |
| 396 | 342 | </Suspense> | |
| 397 | 343 | )} | |
| 344 | + | {/* | |
| 345 | + | Two columns on wide screens: what to do and what happened on the | |
| 346 | + | left, read top to bottom; what is going on now and what it costs on | |
| 347 | + | the right, kept in view. On a phone it is one column: Where you're | |
| 348 | + | needed, Running now, what happened, then Spent and Settled (the side | |
| 349 | + | column's cards join it, `contents`, each in its place by `order`). | |
| 350 | + | */} | |
| 351 | + | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 352 | + | {/* 1. Where you're needed now, with the one to start with on top: no window. */} | |
| 353 | + | <div className="order-1 space-y-5 lg:col-start-1 lg:row-start-1"> | |
| 354 | + | {/* 1. The daily signal, then a box that asks g1t about any of it; the line under it is yours. */} | |
| 355 | + | {(() => { | |
| 356 | + | const line = ( | |
| 357 | + | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 358 | + | <Await resolve={head}> | |
| 359 | + | {([digest, happened, att]) => ( | |
| 360 | + | <> | |
| 361 | + | <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)} | |
| 362 | + | </> | |
| 363 | + | )} | |
| 364 | + | </Await> | |
| 365 | + | </Suspense> | |
| 366 | + | ); | |
| 367 | + | const ask = <HomeAsk span={span} compact />; | |
| 368 | + | // Without Code there is no work to read: the box leads, as it always has. | |
| 369 | + | if (!signal) | |
| 370 | + | return ( | |
| 371 | + | <header className="space-y-3"> | |
| 372 | + | <HomeAsk span={span} /> | |
| 373 | + | <p className="mx-auto max-w-2xl text-center text-sm leading-relaxed text-muted">{line}</p> | |
| 374 | + | </header> | |
| 375 | + | ); | |
| 376 | + | return ( | |
| 377 | + | <Suspense fallback={<DailySignalSkeleton date={loaderData.date} />}> | |
| 378 | + | <Await resolve={signal}> | |
| 379 | + | {(value) => ( | |
| 380 | + | <Boundary what="The daily signal"> | |
| 381 | + | <DailySignal date={loaderData.date} view={value} line={line} footer={ask} /> | |
| 382 | + | </Boundary> | |
| 383 | + | )} | |
| 384 | + | </Await> | |
| 385 | + | </Suspense> | |
| 386 | + | ); | |
| 387 | + | })()} | |
| 388 | + | <div id="needed" className="scroll-mt-4"> | |
| 389 | + | <Card | |
| 390 | + | title="Where you're needed" | |
| 391 | + | hint="Everything waiting on you, however long ago it started" | |
| 392 | + | link={{ label: "Notifications", to: "/notifications" }} | |
| 393 | + | > | |
| 394 | + | <Suspense fallback={<RowsSkeleton rows={4} />}> | |
| 395 | + | {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */} | |
| 396 | + | <Await resolve={waiting}> | |
| 397 | + | {(att) => ( | |
| 398 | + | <Boundary what="Where you're needed"> | |
| 399 | + | <NeedsList attention={att} now={now} /> | |
| 400 | + | </Boundary> | |
| 401 | + | )} | |
| 402 | + | </Await> | |
| 403 | + | </Suspense> | |
| 404 | + | </Card> | |
| 405 | + | </div> | |
| 406 | + | </div> | |
| 398 | 407 | ||
| 399 | − | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 400 | − | {/* 4. What happened in the span: in a sentence, then in plain English, then who did what, then what landed. */} | |
| 401 | − | <section aria-labelledby="since" className="rounded-2xl border border-line bg-surface"> | |
| 408 | + | {/* 2. Now: what is running, what it cost, what settled by itself. */} | |
| 409 | + | <aside aria-label="Now" className="contents lg:sticky lg:top-4 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:block lg:space-y-5"> | |
| 410 | + | <Card title="Running now" className="order-2"> | |
| 411 | + | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 412 | + | <Await resolve={runningNow}> | |
| 413 | + | {(value) => ( | |
| 414 | + | <Boundary what="Running now"> | |
| 415 | + | <RunningList rows={value.rows} missing={value.missing} now={now} /> | |
| 416 | + | </Boundary> | |
| 417 | + | )} | |
| 418 | + | </Await> | |
| 419 | + | </Suspense> | |
| 420 | + | </Card> | |
| 421 | + | <div className="order-4"> | |
| 422 | + | <Suspense | |
| 423 | + | fallback={ | |
| 424 | + | <Card title="Spent"> | |
| 425 | + | <SpendSkeleton /> | |
| 426 | + | </Card> | |
| 427 | + | } | |
| 428 | + | > | |
| 429 | + | <Await resolve={spend}> | |
| 430 | + | {(value) => ( | |
| 431 | + | <Boundary what="Spent"> | |
| 432 | + | <SpendCard spend={value} slug={slug} span={span} /> | |
| 433 | + | </Boundary> | |
| 434 | + | )} | |
| 435 | + | </Await> | |
| 436 | + | </Suspense> | |
| 437 | + | </div> | |
| 438 | + | <div className="order-5"> | |
| 439 | + | <Suspense | |
| 440 | + | fallback={ | |
| 441 | + | <Card title="Settled for you"> | |
| 442 | + | <RowsSkeleton rows={1} /> | |
| 443 | + | </Card> | |
| 444 | + | } | |
| 445 | + | > | |
| 446 | + | <Await resolve={away}> | |
| 447 | + | {(value) => ( | |
| 448 | + | <Boundary what="Settled for you"> | |
| 449 | + | <SettledCard away={value} now={now} /> | |
| 450 | + | </Boundary> | |
| 451 | + | )} | |
| 452 | + | </Await> | |
| 453 | + | </Suspense> | |
| 454 | + | </div> | |
| 455 | + | </aside> | |
| 456 | + | ||
| 457 | + | {/* 3. What happened in the span: what changed in plain English first, then who did what, then what landed. */} | |
| 458 | + | <section aria-labelledby="since" className="order-3 rounded-2xl border border-line bg-surface lg:col-start-1 lg:row-start-2"> | |
| 402 | 459 | <header className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 pt-4"> | |
| 403 | 460 | <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1"> | |
| 404 | 461 | <h2 id="since" className="text-base font-semibold tracking-tight text-fg"> | |
| ⋯ | |||
| 410 | 467 | <WindowSwitch span={span} /> | |
| 411 | 468 | </div> | |
| 412 | 469 | </header> | |
| 413 | − | <p className="px-5 pt-2 text-sm text-muted"> | |
| 414 | − | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 415 | − | <Await resolve={head}>{([digest, happened]) => spanSentence(span, digest, happened?.landed?.length ?? null)}</Await> | |
| 416 | − | </Suspense> | |
| 417 | − | </p> | |
| 418 | 470 | {/* Summarize leads: the plain-English account is what most people come for. */} | |
| 419 | 471 | <div className="mt-4"> | |
| 420 | 472 | <WhatChangedCard key={span.key} slug={slug} span={span} tz={tz} /> | |
| ⋯ | |||
| 454 | 506 | </Await> | |
| 455 | 507 | </Suspense> | |
| 456 | 508 | </section> | |
| 457 | − | ||
| 458 | − | {/* 5. What the span cost, and what settled by itself: kept in view beside it. */} | |
| 459 | − | <aside aria-label="Cost and settled" className="space-y-5 lg:sticky lg:top-4"> | |
| 460 | − | <Suspense | |
| 461 | − | fallback={ | |
| 462 | − | <Card title="Spent"> | |
| 463 | − | <SpendSkeleton /> | |
| 464 | − | </Card> | |
| 465 | − | } | |
| 466 | − | > | |
| 467 | − | <Await resolve={spend}> | |
| 468 | − | {(value) => ( | |
| 469 | − | <Boundary what="Spent"> | |
| 470 | − | <SpendCard spend={value} slug={slug} span={span} /> | |
| 471 | − | </Boundary> | |
| 472 | − | )} | |
| 473 | − | </Await> | |
| 474 | − | </Suspense> | |
| 475 | − | <Suspense | |
| 476 | − | fallback={ | |
| 477 | − | <Card title="Settled for you"> | |
| 478 | − | <RowsSkeleton rows={1} /> | |
| 479 | − | </Card> | |
| 480 | − | } | |
| 481 | − | > | |
| 482 | − | <Await resolve={away}> | |
| 483 | − | {(value) => ( | |
| 484 | − | <Boundary what="Settled for you"> | |
| 485 | − | <SettledCard away={value} now={now} /> | |
| 486 | − | </Boundary> | |
| 487 | − | )} | |
| 488 | − | </Await> | |
| 489 | − | </Suspense> | |
| 490 | − | </aside> | |
| 491 | 509 | </div> | |
| 492 | 510 | </div> | |
| 493 | 511 | ); | |
| 872 | 872 | } | |
| 873 | 873 | ||
| 874 | 874 | #[test] | |
| 875 | − | fn deploy_builds_rust_on_the_larger_machine_with_its_target_cached() { | |
| 875 | + | fn deploy_builds_on_ubuntu_latest_with_its_target_cached() { | |
| 876 | 876 | let deploy = read("deploy.yml"); | |
| 877 | 877 | for stage in ["core", "edge", "front"] { | |
| 878 | 878 | let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap(); | |
| ⋯ | |||
| 889 | 889 | }; | |
| 890 | 890 | expr::interpolate_value(&job.runs_on, &scope).unwrap() | |
| 891 | 891 | }; | |
| 892 | − | assert_eq!(on(true, false), json!("g1t-4core"), "{stage}"); | |
| 893 | − | // The runner's image is built with the job's own Docker Engine. | |
| 894 | − | assert_eq!(on(false, true), json!("g1t-4core"), "{stage}"); | |
| 892 | + | // Every job on ubuntu-latest, as on GitHub: 4 vCPU in this public | |
| 893 | + | // repository (g1t_contracts::actions::instance_for), Rust builds and | |
| 894 | + | // the runner's image included. | |
| 895 | + | assert_eq!(on(true, false), json!("ubuntu-latest"), "{stage}"); | |
| 896 | + | assert_eq!(on(false, true), json!("ubuntu-latest"), "{stage}"); | |
| 895 | 897 | assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}"); | |
| 896 | 898 | } | |
| 897 | 899 | let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap(); | |
| 1089 | 1089 | /// Minutes before the job is stopped. | |
| 1090 | 1090 | pub timeout_minutes: u32, | |
| 1091 | 1091 | /// The machine its `runs-on` asked for, by label (`instance_for`): | |
| 1092 | − | /// `g1t-2core` or `g1t-4core`; absent, the standard one. | |
| 1092 | + | /// `g1t-2core` or `g1t-4core`, which is also what `ubuntu-latest` | |
| 1093 | + | /// gets in a public repository; absent, the standard one. | |
| 1093 | 1094 | #[serde(default)] | |
| 1094 | 1095 | pub instance: Option<String>, | |
| 1095 | 1096 | /// Whether the job may be given brokered secrets, so the runner sends | |
| ⋯ | |||
| 1116 | 1117 | /// 16 GB of disk), `g1t-2core` is kept as a name of it so workflows that | |
| 1117 | 1118 | /// ask for it keep working, and `g1t-4core` is standard-4 (4 vCPU, 12 GiB, | |
| 1118 | 1119 | /// 20 GB). | |
| 1120 | + | /// | |
| 1121 | + | /// `ubuntu-latest` and every other label not named here follow GitHub's | |
| 1122 | + | /// hosted runners ([`instance_for`]): in a public repository the 4-core | |
| 1123 | + | /// machine (GitHub's is 4 vCPU and 16 GB; 12 GiB is the most a Containers | |
| 1124 | + | /// instance has without a raised limit), in a private one the standard | |
| 1125 | + | /// machine (GitHub's is 2 vCPU and 8 GB). | |
| 1119 | 1126 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 1120 | 1127 | pub struct InstanceType { | |
| 1121 | 1128 | /// The `runs-on` label, or `standard` for the default. | |
| ⋯ | |||
| 1154 | 1161 | } | |
| 1155 | 1162 | } | |
| 1156 | 1163 | ||
| 1157 | − | /// The default: what `ubuntu-latest` and every other hosted label get. | |
| 1164 | + | /// The default in a private repository: what `ubuntu-latest` and every | |
| 1165 | + | /// other hosted label get there, as on GitHub. Checks and the merge queue | |
| 1166 | + | /// run on it too. | |
| 1158 | 1167 | pub const STANDARD_INSTANCE: InstanceType = InstanceType { | |
| 1159 | 1168 | label: "standard", | |
| 1160 | 1169 | machine: "2-core", | |
| ⋯ | |||
| 1207 | 1216 | if seconds <= 0 { 0 } else { (seconds + 59) / 60 } | |
| 1208 | 1217 | } | |
| 1209 | 1218 | ||
| 1219 | + | /// The label of the machine `ubuntu-latest` gets in a public repository: | |
| 1220 | + | /// GitHub gives public repositories 4 vCPU. | |
| 1221 | + | pub const PUBLIC_DEFAULT_LABEL: &str = "g1t-4core"; | |
| 1222 | + | ||
| 1210 | 1223 | /// The machine a job's `runs-on` labels ask for: the dearest named (the | |
| 1211 | − | /// later row when two cost the same), or the standard one. Labels compare | |
| 1212 | − | /// without regard to case. | |
| 1213 | − | pub fn instance_for(labels: &[String]) -> InstanceType { | |
| 1224 | + | /// later row when two cost the same). With none named (`ubuntu-latest`, | |
| 1225 | + | /// say), what GitHub gives: the 4-core machine in a public repository, | |
| 1226 | + | /// the standard one in a private one. Labels compare without regard to | |
| 1227 | + | /// case. | |
| 1228 | + | pub fn instance_for(labels: &[String], public: bool) -> InstanceType { | |
| 1214 | 1229 | INSTANCE_TYPES | |
| 1215 | 1230 | .iter() | |
| 1216 | 1231 | .filter(|instance| instance.label != STANDARD_INSTANCE.label && labels.iter().any(|label| label.trim().eq_ignore_ascii_case(instance.label))) | |
| 1217 | 1232 | .max_by_key(|instance| instance.micros_per_minute) | |
| 1218 | 1233 | .copied() | |
| 1219 | − | .unwrap_or(STANDARD_INSTANCE) | |
| 1234 | + | .unwrap_or_else(|| if public { instance_named(PUBLIC_DEFAULT_LABEL).unwrap_or(STANDARD_INSTANCE) } else { STANDARD_INSTANCE }) | |
| 1220 | 1235 | } | |
| 1221 | 1236 | ||
| 1222 | 1237 | /// An instance type by its label, if it is one. | |
| ⋯ | |||
| 1717 | 1732 | ||
| 1718 | 1733 | #[test] | |
| 1719 | 1734 | fn runs_on_picks_the_machine() { | |
| 1720 | − | assert_eq!(instance_for(&labels(&["ubuntu-latest"])), STANDARD_INSTANCE); | |
| 1721 | − | assert_eq!(instance_for(&labels(&[])).label, "standard"); | |
| 1722 | − | assert_eq!(instance_for(&labels(&["g1t-4core"])).label, "g1t-4core"); | |
| 1723 | − | assert_eq!(instance_for(&labels(&["ubuntu-latest", "G1T-2Core"])).label, "g1t-2core"); | |
| 1735 | + | // As on GitHub: ubuntu-latest is 2 vCPU in a private repository, 4 in a public one. | |
| 1736 | + | assert_eq!(instance_for(&labels(&["ubuntu-latest"]), false), STANDARD_INSTANCE); | |
| 1737 | + | assert_eq!(instance_for(&labels(&["ubuntu-latest"]), true).machine, "4-core"); | |
| 1738 | + | assert_eq!(instance_for(&labels(&["ubuntu-24.04"]), true).vcpu, 4.0); | |
| 1739 | + | assert_eq!(instance_for(&labels(&[]), false).label, "standard"); | |
| 1740 | + | assert_eq!(instance_for(&labels(&[]), true).label, PUBLIC_DEFAULT_LABEL); | |
| 1741 | + | // A machine asked for by name is that machine, public or not. | |
| 1742 | + | assert_eq!(instance_for(&labels(&["g1t-4core"]), false).label, "g1t-4core"); | |
| 1743 | + | assert_eq!(instance_for(&labels(&["ubuntu-latest", "G1T-2Core"]), false).label, "g1t-2core"); | |
| 1744 | + | assert_eq!(instance_for(&labels(&["ubuntu-latest", "g1t-2core"]), true).machine, "2-core"); | |
| 1724 | 1745 | // Both named: the later one. | |
| 1725 | − | assert_eq!(instance_for(&labels(&["g1t-2core", "g1t-4core"])).label, "g1t-4core"); | |
| 1746 | + | assert_eq!(instance_for(&labels(&["g1t-2core", "g1t-4core"]), false).label, "g1t-4core"); | |
| 1726 | 1747 | assert_eq!(instance_named("g1t-2core").map(|i| i.vcpu), Some(2.0)); | |
| 1727 | 1748 | assert_eq!(instance_named("g1t-4core").map(|i| i.vcpu), Some(4.0)); | |
| 1728 | 1749 | assert_eq!(instance_named("standard"), Some(STANDARD_INSTANCE)); | |
Loading the diff…
Loading the diff…
Loading the diff…