Public: anyone can read this; the crew can change it.
Home says the week's percent left and when it runs out at this pace #9061
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8961-home-says-the-week-s-percent-left-and-wh → main 4 commits fork 4 threads · 4 unresolved
7 files
changed +166
−33
docs/features/crew-line.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # The crew line on Home | |
| 2 | ||
| 3 | One short line under Home's header, for the owner (a site admin) only, on the web and in the terminal's dashboard. Both print the same words from `crewLineWords` (`src/crew/week_read.ts`): | |
| 4 | ||
| 5 | > 41% of the week left, runs out Thu 14:00 UTC at this pace | |
| 6 | ||
| 7 | - **Percent**: the share of the subscriptions' week still unspent, as the meter reads it, rounded and held to 0-100. | |
| 8 | - **Run-out time**: when the pacer's own `runsOutAt` says the current burn empties the week. Always UTC, written `Day HH:MM UTC`. | |
| 9 | - **"lasts to the reset at this pace"**: the burn would not empty the week before it resets, so there is no run-out time. | |
| 10 | - **Absent** while the week is unmeasured (no account metered, or the reset is past or over a week off): the line says nothing rather than guess. The sentence below it then says the pace is unknown. | |
| 11 | - It reads the repository the crew works most, the same one the long week sentence reads (`busiest` in `week_read.ts`), so the two cannot disagree. | |
| 12 | ||
| 13 | The sentence under it (`weekBlock`, `besideCrewLine` in `src/crew/week.ts`) keeps the pace verdict, burn, slots and efficiency but drops the percent and run-out time, so each figure is said once. Both lines are `small muted break` and read as one block. The Spend tab, which has no crew line, keeps the full sentence. While the week is unmeasured there is no crew line and the full sentence stands alone. |
src/crew/week.ts +14 −6
| @@ -80,6 +80,8 @@ export interface WeekDecision { | ||
| 80 | 80 | readonly release: boolean; |
| 81 | 81 | /** One sentence for home and the log. */ |
| 82 | 82 | readonly sentence: string; |
| 83 | /** The same sentence without the free percent and run-out time, for under the crew line that already states them. */ | |
| 84 | readonly besideCrewLine: string; | |
| 83 | 85 | } |
| 84 | 86 | |
| 85 | 87 | const utc = (d: Date): string => `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`; |
| @@ -91,6 +93,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 91 | 93 | const { meter, now, ceiling } = input; |
| 92 | 94 | if (meter === null) { |
| 93 | 95 | const slots = Math.min(ceiling, WEEK.unknownSlots); |
| 96 | const sentence = `The week's pace is unknown: the provider has not yet reported a measure of the week, so the crew holds at ${slots} of ${ceiling} slots.`; | |
| 94 | 97 | return { |
| 95 | 98 | status: "unknown", |
| 96 | 99 | targetPctPerHour: null, |
| @@ -98,7 +101,8 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 98 | 101 | runsOutAt: null, |
| 99 | 102 | slots, |
| 100 | 103 | release: false, |
| 101 | sentence: `The week's pace is unknown: the provider has not yet reported a measure of the week, so the crew holds at ${slots} of ${ceiling} slots.`, | |
| 104 | sentence, | |
| 105 | besideCrewLine: sentence, | |
| 102 | 106 | }; |
| 103 | 107 | } |
| 104 | 108 | const left = meter.resetsAt.getTime() - now.getTime(); |
| @@ -127,17 +131,21 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 127 | 131 | |
| 128 | 132 | const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0; |
| 129 | 133 | const gone = Math.round((1 - left / WINDOW_MS.week) * 100); |
| 130 | const weekLeft = `${Math.round(meter.freePct)}% of the week left with ${gone}% of its time gone, reset ${utc(meter.resetsAt)}`; | |
| 134 | const timeGone = `${gone}% of its time gone, reset ${utc(meter.resetsAt)}`; | |
| 131 | 135 | const burnWords = burn === null ? "unread (too few readings)" : `${pct(burn)} an hour`; |
| 132 | const sentence = | |
| 133 | burn === null && !burning | |
| 136 | // `full`: the free percent and run-out time are said here; otherwise the crew line above says them. | |
| 137 | const say = (full: boolean): string => { | |
| 138 | const weekLeft = full ? `${Math.round(meter.freePct)}% of the week left with ${timeGone}` : `the week has ${timeGone}`; | |
| 139 | const runOut = full && runsOutAt !== null ? `, running out about ${utc(runsOutAt)}` : ""; | |
| 140 | return burn === null && !burning | |
| 134 | 141 | ? `Burn not known yet: too few readings of the meter; ${weekLeft}; holding at ${slots} of ${ceiling} slots until it has them.` |
| 135 | 142 | : status === "on pace" |
| 136 | 143 | ? `On pace: ${weekLeft}; burning ${burnWords}, ${pct(allowed)} is allowed.` |
| 137 | 144 | : status === "ahead" |
| 138 | ? `Ahead of pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.` | |
| 145 | ? `Ahead of pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed${runOut}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.` | |
| 139 | 146 | : `Behind pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed; ${slots === 0 ? "holding the crew for the reset" : `speeding up to ${slots} of ${ceiling} slots`}${release ? " and releasing the next issue" : ""}.`; |
| 140 | return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence }; | |
| 147 | }; | |
| 148 | return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence: say(true), besideCrewLine: say(false) }; | |
| 141 | 149 | } |
| 142 | 150 | |
| 143 | 151 | /** What each real landing cost, and how many runs it took, over a window. */ |
src/crew/week_read.ts +65 −10
| @@ -147,20 +147,75 @@ function heldWords(app: App): string { | ||
| 147 | 147 | } |
| 148 | 148 | |
| 149 | 149 | /** |
| 150 | * The pace sentence and the efficiency beside it for the autonomous | |
| 151 | * repository the crew works most this week (the most agent runs; the first by | |
| 152 | * name on a tie), not whichever sorts first: a quiet repository's "nothing | |
| 153 | * landed" is not the forge's. With no measurement it says the pace is unknown; null only when no autonomous repository has a pace to state (a reset past or over a week off). Home and the | |
| 154 | * terminal's dashboard print the same words. | |
| 150 | * The autonomous repository the crew works most this week (the most agent | |
| 151 | * runs; the first by name on a tie) among those with a pace to state, and | |
| 152 | * what `read` makes of it. The one pick `weekWords` and `crewPace` share. | |
| 155 | 153 | */ |
| 156 | export function weekWords(app: App, now: Date = new Date()): string | null { | |
| 157 | let best: { words: string; runs: number } | null = null; | |
| 154 | function busiest<T>(app: App, now: Date, read: (repo: Repo, week: WeekDecision) => T | null): T | null { | |
| 155 | let best: { value: T; runs: number } | null = null; | |
| 158 | 156 | for (const repo of listAutonomousRepos(app.db)) { |
| 159 | 157 | if (repo.autonomy !== "autonomous" || repo.archivedAt !== null) continue; |
| 160 | 158 | const week = crewWeekFor(app, repo, now); |
| 161 | 159 | if (week === null) continue; |
| 162 | const efficiency = weekEfficiency(app, repo, now); | |
| 163 | if (best === null || efficiency.runs > best.runs) best = { words: `${week.sentence} The week allows the whole crew ${week.slots} of ${crewCeiling(app, repo)} slots across every repository. ${efficiencyWords(efficiency)}${heldWords(app)}`, runs: efficiency.runs }; | |
| 160 | const value = read(repo, week); | |
| 161 | if (value === null) continue; | |
| 162 | const runs = weekEfficiency(app, repo, now).runs; | |
| 163 | if (best === null || runs > best.runs) best = { value, runs }; | |
| 164 | 164 | } |
| 165 | return best?.words ?? null; | |
| 165 | return best?.value ?? null; | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * The pace sentence and the efficiency beside it for the busiest autonomous | |
| 170 | * repository (`busiest`), not whichever sorts first: a quiet repository's "nothing | |
| 171 | * landed" is not the forge's. With no measurement it says the pace is unknown; null only when no autonomous repository has a pace to state (a reset past or over a week off). Home and the | |
| 172 | * terminal's dashboard print the same words. `besideCrewLine` drops the free | |
| 173 | * percent and run-out time, for under the crew line that states them. | |
| 174 | */ | |
| 175 | export function weekWords(app: App, now: Date = new Date(), besideCrewLine = false): string | null { | |
| 176 | return busiest(app, now, (repo, week) => `${besideCrewLine ? week.besideCrewLine : week.sentence} The week allows the whole crew ${week.slots} of ${crewCeiling(app, repo)} slots across every repository. ${efficiencyWords(weekEfficiency(app, repo, now))}${heldWords(app)}`); | |
| 177 | } | |
| 178 | ||
| 179 | /** The crew line's figures: the week's free percent and when this burn empties it (null: it lasts to the reset). */ | |
| 180 | export interface CrewPace { | |
| 181 | readonly freePct: number; | |
| 182 | readonly runsOutAt: Date | null; | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * The same week `weekWords` states, as numbers: the meter's free share and | |
| 187 | * the pacer's own `runsOutAt`, for the same repository (`busiest`). | |
| 188 | * Null while the week is unmeasured, past, or has no autonomous repository. | |
| 189 | */ | |
| 190 | export function crewPace(app: App, now: Date = new Date()): CrewPace | null { | |
| 191 | return busiest(app, now, (repo, week) => { | |
| 192 | const meter = weekOf(app, repo, now).meter; | |
| 193 | return meter === null ? null : { freePct: meter.freePct, runsOutAt: week.runsOutAt }; | |
| 194 | }); | |
| 195 | } | |
| 196 | ||
| 197 | const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; | |
| 198 | ||
| 199 | /** `Thu 14:00 UTC`, the pacer's run-out moment. */ | |
| 200 | function dayTime(d: Date): string { | |
| 201 | return `${DAYS[d.getUTCDay()]} ${d.toISOString().slice(11, 16)} UTC`; | |
| 202 | } | |
| 203 | ||
| 204 | /** The crew line in a few words: `41% of the week left, runs out Thu 14:00 UTC at this pace`; null while the week is unmeasured. Home and the terminal print it. */ | |
| 205 | export function crewLineWords(app: App, now: Date = new Date()): string | null { | |
| 206 | const pace = crewPace(app, now); | |
| 207 | if (pace === null) return null; | |
| 208 | const left = `${Math.round(Math.min(100, Math.max(0, pace.freePct)))}% of the week left`; | |
| 209 | return pace.runsOutAt === null ? `${left}, lasts to the reset at this pace` : `${left}, runs out ${dayTime(pace.runsOutAt)} at this pace`; | |
| 210 | } | |
| 211 | ||
| 212 | /** | |
| 213 | * The owner's week on Home and the terminal: the crew line, then the week | |
| 214 | * sentence without the figures the line already gives, so the percent is | |
| 215 | * said once. With no crew line (an unmeasured week) the full sentence alone. | |
| 216 | */ | |
| 217 | export function weekBlock(app: App, now: Date = new Date()): string[] { | |
| 218 | const crew = crewLineWords(app, now); | |
| 219 | const words = weekWords(app, now, crew !== null); | |
| 220 | return [...(crew === null ? [] : [crew]), ...(words === null ? [] : [words])]; | |
| 166 | 221 | } |
src/tui/screens/dashboard.ts +2 −8
| @@ -119,7 +119,7 @@ import { lastClearedNote } from "../../flow/clog_tick.ts"; | ||
| 119 | 119 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 120 | 120 | import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts"; |
| 121 | 121 | import { idleLine, idleReason } from "../../crew/idle_reason.ts"; |
| 122 | import { weekWords } from "../../crew/week_read.ts"; | |
| 122 | import { weekBlock } from "../../crew/week_read.ts"; | |
| 123 | 123 | import { noReleaseAlarm } from "../../flow/alarm_no_release.ts"; |
| 124 | 124 | import { redMainAlarm } from "../../flow/alarm_red_main.ts"; |
| 125 | 125 | import { alarmStuck } from "../../flow/alarm_stuck.ts"; |
| @@ -400,7 +400,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 400 | 400 | ? [] |
| 401 | 401 | : [ |
| 402 | 402 | ...idleLines(f.app, now), |
| 403 | ...weekLines(f.app, now), | |
| 403 | ...weekBlock(f.app, now), | |
| 404 | 404 | ...kpiLines(readKpis(f.db, now, { ...kpiForge(f.app), idle: () => idleReason(f.app, now) }), commit === null ? null : { sha: commit, since: ago(BOOTED) }), |
| 405 | 405 | vitalsLine(readVitals(f.db, now)), |
| 406 | 406 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says), |
| @@ -1522,12 +1522,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1522 | 1522 | return screen; |
| 1523 | 1523 | } |
| 1524 | 1524 | |
| 1525 | /** The week's pace and efficiency, as the line an admin's home reads (#8695); none while no account is metered. */ | |
| 1526 | function weekLines(app: App, now: Date): string[] { | |
| 1527 | const words = weekWords(app, now); | |
| 1528 | return words === null ? [] : [words]; | |
| 1529 | } | |
| 1530 | ||
| 1531 | 1525 | /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */ |
| 1532 | 1526 | function idleLines(app: App, now: Date): string[] { |
outdated
Drop |
||
| 1533 | 1527 | const reason = idleReason(app, now); |
src/web/studio/home.ts +2 −2
| @@ -56,7 +56,7 @@ import { heldLine } from "./held.ts"; | ||
| 56 | 56 | import { appChips, movingRegion, tellBox } from "./living.ts"; |
| 57 | 57 | import { alarmLines, cachedAlarms } from "./alarm_lines.ts"; |
| 58 | 58 | import { kpiRow, waitingLine } from "./kpi_row.ts"; |
| 59 | import { weekLine } from "./week_line.ts"; | |
| 59 | import { homeWeek } from "./week_line.ts"; | |
| 60 | 60 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 61 | 61 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 62 | 62 | import { idleReason } from "../../crew/idle_reason.ts"; |
| @@ -239,7 +239,7 @@ export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | | ||
| 239 | 239 | const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn); |
| 240 | 240 | const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) }; |
| 241 | 241 | return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))} |
| 242 | ${weekLine(ctx.app)} | |
| 242 | ${homeWeek(ctx.app, user)} | |
| 243 | 243 | ${alarmLines(cachedAlarms(db, new Date(), ceilingUsd))}`; |
| 244 | 244 | }, |
| 245 | 245 | waiting: () => timePhase(request, "waiting", () => waitingLine(db, user), () => waitingCache(db)), |
src/web/studio/week_line.ts +17 −4
| @@ -1,14 +1,27 @@ | ||
| 1 | 1 | /** |
| 2 | * Home's week line (#8695): one sentence on whether the crew is spending the | |
| 3 | * subscriptions' week on pace, and beside it what a real landing costs | |
| 4 | * (`weekWords`). Empty while no account is metered. | |
| 2 | * Home's week (#8695, #8961): for the owner, the crew line (percent of the | |
| 3 | * week left, when it runs out at this pace), then the pace sentence without | |
| 4 | * those figures and beside it what a real landing costs (`weekWords`). Both | |
| 5 | * in the same muted small style, read as one block. Empty while no account | |
| 6 | * is metered. | |
| 5 | 7 | */ |
| 6 | 8 | |
| 7 | 9 | import type { App } from "../../app.ts"; |
| 8 | import { weekWords } from "../../crew/week_read.ts"; | |
| 10 | import { crewLineWords, weekWords } from "../../crew/week_read.ts"; | |
| 9 | 11 | import { html, type Html } from "../html.ts"; |
| 10 | 12 | |
| 13 | type Viewer = { readonly isAdmin: boolean } | undefined; | |
| 14 | ||
| 15 | /** The full pace sentence, for pages without the crew line (Spend). */ | |
outdated
The class differs from the sentence beneath it (no |
||
| 11 | 16 | export function weekLine(app: App, now: Date = new Date()): Html { |
| 12 | 17 | const words = weekWords(app, now); |
| 13 | 18 | return words === null ? html`` : html`<p class="small muted break">${words}</p>`; |
| 14 | 19 | } |
| 20 | ||
| 21 | /** Home's week: the owner reads the crew line and the sentence under it, the percent said once; anyone else the full sentence. */ | |
| 22 | export function homeWeek(app: App, viewer: Viewer, now: Date = new Date()): Html { | |
| 23 | const crew = viewer?.isAdmin === true ? crewLineWords(app, now) : null; | |
| 24 | if (crew === null) return weekLine(app, now); | |
| 25 | const words = weekWords(app, now, true); | |
outdated
Only Home renders this. Add the same line to the terminal dashboard in src/tui/ (admin only, using crewLineWords) and a test for it, and add docs/features/ how-to. |
||
| 26 | return html`<p class="small muted break" id="crew-pace">${crew}</p>${words === null ? html`` : html`<p class="small muted break">${words}</p>`}`; | |
| 27 | } |
test/week-wiring.test.ts +53 −3
| @@ -16,14 +16,14 @@ import { noteMeasure } from "../src/core/measure.ts"; | ||
| 16 | 16 | import { setHeadroom } from "../src/core/standing.ts"; |
| 17 | 17 | import { addSubscription } from "../src/core/subscriptions.ts"; |
| 18 | 18 | import { recordUsage } from "../src/core/usage.ts"; |
| 19 | import { type User } from "../src/core/users.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | 20 | import { driveRepo, repoPace } from "../src/crew/autonomy.ts"; |
| 21 | 21 | import { changeCapacity } from "../src/actions/capacity.ts"; |
| 22 | 22 | import { capacityDefault } from "../src/core/capacity.ts"; |
| 23 | import { forgetWeek, weekWords } from "../src/crew/week_read.ts"; | |
| 23 | import { crewLineWords, forgetWeek, weekWords } from "../src/crew/week_read.ts"; | |
| 24 | 24 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 25 | 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 26 | import { weekLine } from "../src/web/studio/week_line.ts"; | |
| 26 | import { homeWeek, weekLine } from "../src/web/studio/week_line.ts"; | |
| 27 | 27 | import { seedAdmin } from "./helpers/admin.ts"; |
| 28 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | 29 | import { serve } from "../src/http/server.ts"; |
| @@ -219,6 +219,10 @@ test("with no measurement of the week the pace is unknown and the crew holds at | ||
| 219 | 219 | forgetWeek(); |
| 220 | 220 | expect(weekWords(app, NOW)).toContain("pace is unknown"); |
| 221 | 221 | expect(String(weekLine(app, NOW))).toContain("pace is unknown"); |
| 222 | // Nothing measured: no percent to state, so no crew line on either door. | |
| 223 | expect(crewLineWords(app, NOW)).toBeNull(); | |
| 224 | expect(String(homeWeek(app, alice, NOW))).not.toContain("crew-pace"); | |
| 225 | expect(String(homeWeek(app, alice, NOW))).toContain("pace is unknown"); | |
| 222 | 226 | expect(repoPace(app, repo, NOW).slots).toBe(2); |
| 223 | 227 | }); |
| 224 | 228 | |
| @@ -298,3 +302,49 @@ test("a week that fell 8 points in 3 hours slows the picks; a flat meter does no | ||
| 298 | 302 | expect(flat.slots).toBe(4); |
| 299 | 303 | expect(weekWords(app, NOW)).toContain("burning 0% an hour"); |
| 300 | 304 | }); |
| 305 | ||
| 306 | test("home's crew line gives the owner the percent left and when it runs out, escaped, and nobody else", () => { | |
| 307 | week(70, 6); | |
| 308 | const runsOut = repoPace(app, repo, NOW); | |
| 309 | expect(runsOut.reason).toContain("running out about"); | |
| 310 | const words = crewLineWords(app, NOW)!; | |
| 311 | expect(words).toMatch(/^30% of the week left, runs out (Sun|Mon|Tue|Wed|Thu|Fri|Sat) \d\d:\d\d UTC at this pace$/); | |
| 312 | const owner = String(homeWeek(app, alice, NOW)); | |
| 313 | expect(owner).toContain(words); | |
| 314 | expect(owner).toContain('id="crew-pace"'); | |
| 315 | // The sentence under the line does not say the percent or the run-out again. | |
| 316 | expect(owner.split("30% of the week left").length - 1).toBe(1); | |
| 317 | expect(owner).not.toContain("running out about"); | |
| 318 | expect(owner).toContain("Ahead of pace: the week has"); | |
| 319 | expect(owner).toContain('<p class="small muted break" id="crew-pace">'); | |
| 320 | const other = { isAdmin: false } as User; | |
| 321 | for (const viewer of [other, undefined]) { | |
| 322 | const page = String(homeWeek(app, viewer, NOW)); | |
| 323 | expect(page).not.toContain("crew-pace"); | |
| 324 | expect(page).toContain("Ahead of pace: 30% of the week left"); | |
| 325 | } | |
| 326 | week(20); | |
| 327 | expect(crewLineWords(app, NOW)).toBe("80% of the week left, lasts to the reset at this pace"); | |
| 328 | }); | |
| 329 | ||
| 330 | async function terminalFor(user: User): Promise<string> { | |
| 331 | const terminal = headlessTerminal(240, 32); | |
| 332 | const { app: tui } = createForgeApp(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, { terminal, exitHooks: false }); | |
| 333 | tui.start(); | |
| 334 | await settleApp(tui); | |
| 335 | const text = tui.screen.toText(); | |
| 336 | tui.stop(); | |
| 337 | return text; | |
| 338 | } | |
| 339 | ||
| 340 | test("the terminal's home carries the crew line for the admin, the same words as the web, and for nobody else", async () => { | |
| 341 | week(70, 6); | |
| 342 | const words = crewLineWords(app, NOW)!; | |
| 343 | expect(words).toContain("30% of the week left, runs out"); | |
| 344 | const admin = await terminalFor(alice); | |
| 345 | expect(admin).toContain(words); | |
| 346 | expect(admin.split("30% of the week left").length - 1).toBe(1); | |
| 347 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-passphrase" }); | |
| 348 | if (!made.ok) throw new Error(made.error); | |
| 349 | expect(await terminalFor(made.value)).not.toContain("of the week left, "); | |
| 350 | }); |
new above
offline
crewPace repeats the repository selection loop of weekWords. Extract the shared pick so the two cannot drift.