Public: anyone can read this; the crew can change it.
Variants: a branch is a living variant of an app with its own settings, compared side by side #10669
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10550-variants-a-branch-is-a-living-variant-of → main 4 commits fork
21 files
changed +1524
−19
docs/design/attention-budget.json +4 −4
| @@ -13,24 +13,24 @@ | ||
| 13 | 13 | }, |
| 14 | 14 | "repository": { |
| 15 | 15 | "390": { |
| 16 | "words": 116, | |
| 16 | "words": 117, | |
| 17 | 17 | "blocks": 18, |
| 18 | 18 | "colours": 2 |
| 19 | 19 | }, |
| 20 | 20 | "1280": { |
| 21 | "words": 116, | |
| 21 | "words": 117, | |
| 22 | 22 | "blocks": 18, |
| 23 | 23 | "colours": 2 |
| 24 | 24 | } |
| 25 | 25 | }, |
| 26 | 26 | "app": { |
| 27 | 27 | "390": { |
| 28 | "words": 116, | |
| 28 | "words": 117, | |
| 29 | 29 | "blocks": 18, |
| 30 | 30 | "colours": 2 |
| 31 | 31 | }, |
| 32 | 32 | "1280": { |
| 33 | "words": 116, | |
| 33 | "words": 117, | |
| 34 | 34 | "blocks": 18, |
| 35 | 35 | "colours": 2 |
| 36 | 36 | } |
docs/features/variants.md added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | # Variants | |
| 2 | ||
| 3 | A branch of an app kept running as its own environment, with values of its | |
| 4 | own, beside production and the previews of pulls. Exploring means running | |
| 5 | several versions at once; the variant board puts them side by side and | |
| 6 | "Make this main" keeps the winner. | |
| 7 | ||
| 8 | ## Use | |
| 9 | ||
| 10 | Open an app's **Variants** (`/<owner>/<name>/variants`, linked from the app | |
| 11 | page). A writer who is a person (never an agent, never on a mirror): | |
| 12 | ||
| 13 | - **Run a branch as a variant** — a branch and a label (1–16 lowercase letters | |
| 14 | and digits; the branch name by default). It runs at | |
| 15 | `<slug>--v-<label>.<domain>` with a copy of production's data. | |
| 16 | - **Save values** — `NAME=value` lines, committed as `variant.json` on the | |
| 17 | branch, so they are versioned. The variant restarts with them. | |
| 18 | - **Make this main** — opens the pull from the branch to the default branch | |
| 19 | (or hands back the one open). The merge is the pull's: its checks, a person. | |
| 20 | - **Retire** — stops the environment and drops the row. The branch stays. | |
| 21 | ||
| 22 | ## `variant.json` | |
| 23 | ||
| 24 | json | |
| 25 | { "values": { "PRINT": "true", "VOICE": "dry" } } | |
| 26 | ||
| 27 | ||
| 28 | Plain values, at most 20, each one printable line of at most 200 characters, | |
| 29 | named like environment variables and never `FORGE_*` or a name the forge sets | |
| 30 | (`PORT`, `HOME`…). They go over the app's own environment variables of the | |
| 31 | same name. Secrets are never in the file: set them under the app's | |
| 32 | environment settings, where they stay sealed forge-side. A file that is | |
| 33 | present and wrong starts the variant with no values. | |
| 34 | ||
| 35 | ## What the board shows | |
| 36 | ||
| 37 | For each variant: its state, its distinguishing values (read from the branch), | |
| 38 | its preview, and what it last made. An app tells the forge what it last made | |
| 39 | by writing `last-output.json` into its own `FORGE_APP_DATA`: | |
| 40 | ||
| 41 | json | |
| 42 | { "title": "Edition 12", "path": "/editions/12" } | |
| 43 | ||
| 44 | ||
| 45 | `path` is optional and on the app's own address. The forge reads it as data | |
| 46 | only (4 KB, one printable line). | |
| 47 | ||
| 48 | ## Lifetime and budget | |
| 49 | ||
| 50 | A variant lives as long as its row, not as long as a pull: a reconcile keeps | |
| 51 | it, a push to its branch redeploys it. It sleeps on production's schedule | |
| 52 | (`SLEEP_AFTER_MS.production`), not a preview's thirty minutes, and wakes on | |
| 53 | the next request. It counts as a live preview against the account's | |
| 54 | `previews` limit, which the board shows. An app keeps at most | |
| 55 | `VARIANTS_MAX` (6). | |
| 56 | ||
| 57 | ## The terminal, and who may deploy | |
| 58 | ||
| 59 | The board is also in the terminal: `:` then "Variants" on a repository that is an | |
| 60 | app (`src/tui/screens/variants.ts`). Anyone who can read sees the list; a person | |
| 61 | who can write registers (`r`), sets (`s`) or drops (`u`) a value, retires after a | |
| 62 | confirm (`x`) and makes one main (`m`), through the same actions as the page. | |
| 63 | ||
| 64 | A push to a variant's branch redeploys it only when the pusher is a person who | |
| 65 | can write. A variant runs with the app's sealed values and a copy of its data, so | |
| 66 | an agent's push waits: it proposes through a pull, which has its own preview. | |
| 67 | `variant.json` is read only as a regular file in the checkout, never through a | |
| 68 | symlink. An app slugged like `v-<label>` before that shape was reserved keeps its | |
| 69 | host. | |
| 70 | ||
| 71 | ## Not yet | |
| 72 | ||
| 73 | A terminal into a variant, and a first-use morning-paper pair of branches | |
| 74 | (that app's own repository holds `variant.json` and the `last-output.json` | |
| 75 | write). |
src/actions/app_variants.ts added +238
| @@ -0,0 +1,238 @@ | ||
| 1 | /** | |
| 2 | * Variants: a branch of an app kept running as its own environment, with | |
| 3 | * values of its own (docs/features/variants.md). A person who can write the | |
| 4 | * repository may register a branch, set the values it carries, retire it, | |
| 5 | * or ask for it to become main. Never an agent: the crew proposes, a person | |
| 6 | * chooses what runs and what lands (docs/APPS.md rule 1). | |
| 7 | * | |
| 8 | * The values are `variant.json` on the branch, committed here, so they are | |
| 9 | * versioned like the rest of the branch. Registering, saving and retiring | |
| 10 | * are announced on the outbox. "Make this main" opens an ordinary pull from | |
| 11 | * the branch to the default branch and hands it back: the merge stays the | |
| 12 | * pull's, with its checks and its person. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 17 | import { parseValueLines, readLastOutput, VARIANT_FILE, variantFileText, parseVariantFile } from "../apps/variant_file.ts"; | |
| 18 | import { envDataDir, variantEnv } from "../apps/data.ts"; | |
| 19 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; | |
| 20 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 21 | import { listPulls, type Pull } from "../core/pulls.ts"; | |
| 22 | import type { Repo } from "../core/repos.ts"; | |
| 23 | import type { User } from "../core/users.ts"; | |
| 24 | import { | |
| 25 | addVariant, | |
| 26 | getVariant, | |
| 27 | isVariantLabel, | |
| 28 | labelFor, | |
| 29 | listVariants, | |
| 30 | removeVariant, | |
| 31 | type AddVariantError, | |
| 32 | type Variant, | |
| 33 | } from "../core/variants.ts"; | |
| 34 | import { getQuota } from "../settings/quotas.ts"; | |
| 35 | import { commitFiles } from "../git/write.ts"; | |
| 36 | import { isSafeRef } from "../git/guard.ts"; | |
| 37 | import { readerFor } from "../git/read.ts"; | |
| 38 | import { repoDir } from "../git/repos.ts"; | |
| 39 | import { resolveOid } from "../git/merge.ts"; | |
| 40 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 41 | import { isMirror } from "../origins/write-through.ts"; | |
| 42 | import { requireWriter, type AccessError } from "./access.ts"; | |
| 43 | import { openPull } from "./pulls.ts"; | |
| 44 | ||
| 45 | export type VariantError = | |
| 46 | | AccessError | |
| 47 | | AddVariantError | |
| 48 | | "mirrored" | |
| 49 | | "no_branch" | |
| 50 | | "default_branch" | |
| 51 | | "not_a_variant" | |
| 52 | | "bad_values" | |
| 53 | | "failed"; | |
| 54 | ||
| 55 | export function variantRefusalText(error: string, detail?: string): string { | |
| 56 | switch (error) { | |
| 57 | case "unauthorized": | |
| 58 | case "forbidden": | |
| 59 | return "Only a person with write access can change an app's variants."; | |
| 60 | case "mirrored": | |
| 61 | return "This repository mirrors another: keep variants in a fork of it."; | |
| 62 | case "no_branch": | |
| 63 | return "That branch does not exist in this repository."; | |
| 64 | case "default_branch": | |
| 65 | return "The default branch is production, not a variant."; | |
| 66 | case "invalid_label": | |
| 67 | return "A label is 1 to 16 lowercase letters and digits."; | |
| 68 | case "label_taken": | |
| 69 | return "Another variant already has that label."; | |
| 70 | case "branch_taken": | |
| 71 | return "That branch is already a variant."; | |
| 72 | case "too_many": | |
| 73 | return "This app keeps as many variants as it may; retire one first."; | |
| 74 | case "not_a_variant": | |
| 75 | return "That is not one of this app's variants."; | |
| 76 | case "bad_values": | |
| 77 | return detail ?? "Those values are not allowed."; | |
| 78 | case "not_found": | |
| 79 | return "This repository has no app."; | |
| 80 | default: | |
| 81 | return "That could not be done."; | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /** The writer who is a person, on an app, not a mirror: the floor every change here shares. */ | |
| 86 | function mayChange(app: App, actor: User | undefined, repo: Repo): Result<{ user: User; record: AppRecord }, VariantError> { | |
| 87 | const writer = requireWriter(app, actor, repo); | |
| 88 | if (!writer.ok) return writer; | |
| 89 | if (writer.value.kind === "agent") return err("forbidden"); | |
| 90 | if (isMirror(app, repo)) return err("mirrored"); | |
| 91 | const record = getAppByRepo(app.db, repo.id); | |
| 92 | if (record === undefined) return err("not_found"); | |
| 93 | return ok({ user: writer.value, record }); | |
| 94 | } | |
| 95 | ||
| 96 | /** Register `branch` as a variant (label defaults from its name) and start it. */ | |
| 97 | export async function registerVariant( | |
| 98 | app: App, | |
| 99 | actor: User | undefined, | |
| 100 | repo: Repo, | |
| 101 | input: { branch: string; label?: string }, | |
| 102 | ): Promise<Result<Variant, VariantError>> { | |
| 103 | const may = mayChange(app, actor, repo); | |
| 104 | if (!may.ok) return may; | |
| 105 | const branch = input.branch.trim(); | |
| 106 | if (!isSafeRef(branch) || branch.startsWith("refs/")) return err("no_branch"); | |
| 107 | if (branch === repo.defaultBranch) return err("default_branch"); | |
| 108 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 109 | if ((await resolveOid(dir, `refs/heads/${branch}`)) === undefined) return err("no_branch"); | |
| 110 | const label = (input.label ?? "").trim() === "" ? labelFor(branch) : (input.label ?? "").trim(); | |
| 111 | if (!isVariantLabel(label)) return err("invalid_label"); | |
| 112 | const added = addVariant(app.db, { appId: may.value.record.id, label, branch, createdBy: may.value.user.id }); | |
| 113 | if (!added.ok) return added; | |
| 114 | app.bus.append({ | |
| 115 | kind: "app.variant_added", | |
| 116 | repoId: repo.id, | |
| 117 | actorId: may.value.user.id, | |
| 118 | payload: { label, branch }, | |
| 119 | }); | |
| 120 | await appsRuntime(app)?.deployVariant(may.value.record, repo, label, may.value.user.id); | |
| 121 | return added; | |
| 122 | } | |
| 123 | ||
| 124 | /** The values a variant's branch carries now, from `variant.json` at its tip. */ | |
| 125 | export async function variantValues(app: App, repo: Repo, variant: Variant): Promise<Record<string, string>> { | |
| 126 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 127 | const file = await readerFor(dir).object(`refs/heads/${variant.branch}:${VARIANT_FILE}`); | |
| 128 | if (!file.ok || file.value.type !== "blob") return {}; | |
| 129 | const parsed = parseVariantFile(new TextDecoder().decode(file.value.body)); | |
| 130 | return parsed.ok ? parsed.value : {}; | |
| 131 | } | |
| 132 | ||
| 133 | /** Commit `NAME=value` lines as the branch's `variant.json`; the push redeploys the variant. */ | |
| 134 | export async function setVariantValues( | |
| 135 | app: App, | |
| 136 | actor: User | undefined, | |
| 137 | repo: Repo, | |
| 138 | label: string, | |
| 139 | lines: string, | |
| 140 | ): Promise<Result<Record<string, string>, VariantError | { kind: "bad_values"; message: string }>> { | |
| 141 | const may = mayChange(app, actor, repo); | |
| 142 | if (!may.ok) return may; | |
| 143 | const variant = getVariant(app.db, may.value.record.id, label); | |
| 144 | if (variant === undefined) return err("not_a_variant"); | |
| 145 | const values = parseValueLines(lines); | |
| 146 | if (!values.ok) return err({ kind: "bad_values", message: values.error }); | |
| 147 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 148 | const tip = await resolveOid(dir, `refs/heads/${variant.branch}`); | |
| 149 | if (tip === undefined) return err("no_branch"); | |
| 150 | const committed = await commitFiles(dir, { | |
| 151 | branch: variant.branch, | |
| 152 | message: `Variant ${label}: set its values\n\nSet from the app page by ${may.value.user.username}.`, | |
| 153 | files: [{ path: VARIANT_FILE, content: variantFileText(values.value) }], | |
| 154 | expectTip: tip, | |
| 155 | author: commitIdentity(app.config, may.value.user), | |
| 156 | }); | |
| 157 | if (!committed.ok) return err("failed"); | |
| 158 | app.bus.append({ | |
| 159 | kind: "app.variant_values", | |
| 160 | repoId: repo.id, | |
| 161 | actorId: may.value.user.id, | |
| 162 | payload: { label, names: Object.keys(values.value).sort() }, | |
| 163 | }); | |
| 164 | // The commit moved the branch without a hook: the variant follows it from here. | |
| 165 | await appsRuntime(app)?.deployVariant(may.value.record, repo, label, may.value.user.id); | |
| 166 | return ok(values.value); | |
| 167 | } | |
| 168 | ||
| 169 | /** Stop keeping a variant running; the branch and its file stay. */ | |
| 170 | export async function retireVariant(app: App, actor: User | undefined, repo: Repo, label: string): Promise<Result<true, VariantError>> { | |
| 171 | const may = mayChange(app, actor, repo); | |
| 172 | if (!may.ok) return may; | |
| 173 | if (!removeVariant(app.db, may.value.record.id, label)) return err("not_a_variant"); | |
| 174 | app.bus.append({ kind: "app.variant_removed", repoId: repo.id, actorId: may.value.user.id, payload: { label } }); | |
| 175 | await appsRuntime(app)?.teardown(may.value.record, variantEnv(label)); | |
| 176 | return ok(true); | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * "Make this main": the open pull from the variant's branch to the default | |
| 181 | * branch, opened if there is none. The merge is the pull's. | |
| 182 | */ | |
| 183 | export async function makeMain( | |
| 184 | app: App, | |
| 185 | actor: User | undefined, | |
| 186 | repo: Repo, | |
| 187 | label: string, | |
| 188 | ): Promise<Result<Pull, VariantError>> { | |
| 189 | const may = mayChange(app, actor, repo); | |
| 190 | if (!may.ok) return may; | |
| 191 | const variant = getVariant(app.db, may.value.record.id, label); | |
| 192 | if (variant === undefined) return err("not_a_variant"); | |
| 193 | const existing = listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 200 }).find( | |
| 194 | (p) => !p.crossRepo && p.headBranch === variant.branch && p.baseBranch === repo.defaultBranch, | |
| 195 | ); | |
| 196 | if (existing !== undefined) return ok(existing); | |
| 197 | const opened = await openPull(app, may.value.user, repo, { | |
| 198 | title: `Make the ${label} variant main`, | |
| 199 | body: `Opened from the variants board: merging this makes the ${label} variant (branch \`${variant.branch}\`) the app.`, | |
| 200 | baseBranch: repo.defaultBranch, | |
| 201 | headBranch: variant.branch, | |
| 202 | }); | |
| 203 | return opened.ok ? ok(opened.value) : err(opened.error === "unauthorized" || opened.error === "not_found" ? opened.error : "failed"); | |
| 204 | } | |
| 205 | ||
| 206 | /** One variant as the board shows it: plain data. */ | |
| 207 | export interface VariantCard { | |
| 208 | readonly label: string; | |
| 209 | readonly branch: string; | |
| 210 | readonly values: Readonly<Record<string, string>>; | |
| 211 | readonly env: string; | |
| 212 | readonly state: string; | |
| 213 | readonly url: string | undefined; | |
| 214 | readonly output: { readonly title: string; readonly path: string } | undefined; | |
| 215 | } | |
| 216 | ||
| 217 | /** What the board shows: every variant, its values from the branch, its output from the environment's data. */ | |
| 218 | export async function variantBoard(app: App, repo: Repo): Promise<{ cards: VariantCard[]; live: number; max: number | null } | undefined> { | |
| 219 | const record = getAppByRepo(app.db, repo.id); | |
| 220 | if (record === undefined) return undefined; | |
| 221 | const runtime = appsRuntime(app); | |
| 222 | const cards: VariantCard[] = []; | |
| 223 | for (const variant of listVariants(app.db, record.id)) { | |
| 224 | const env = variantEnv(variant.label); | |
| 225 | const status = runtime?.envStatus(record, env); | |
| 226 | cards.push({ | |
| 227 | label: variant.label, | |
| 228 | branch: variant.branch, | |
| 229 | values: await variantValues(app, repo, variant), | |
| 230 | env, | |
| 231 | state: status === undefined ? "not running" : status.dormant !== undefined ? "asleep" : status.state, | |
| 232 | url: runtime?.urlFor(record, env), | |
| 233 | output: readLastOutput(envDataDir(app.config.dataDir, record.id, env)), | |
| 234 | }); | |
| 235 | } | |
| 236 | const live = (runtime?.status(record) ?? []).filter((e) => e.env !== "production" && e.state === "running").length; | |
| 237 | return { cards, live, max: getQuota(app.db, "previews") }; | |
| 238 | } |
src/actions/apps.ts +7
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * the repository may look at it and open its environments. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { getVariant } from "../core/variants.ts"; | |
| 9 | 10 | import { pullDir } from "../core/pulls.ts"; |
| 10 | 11 | import { getIssue } from "../core/issues.ts"; |
| 11 | 12 | import { mayView } from "../core/sealed.ts"; |
| @@ -27,6 +28,7 @@ import type { FileWrite } from "../git/write.ts"; | ||
| 27 | 28 | import { TEMPLATE_ERRORS, templateFiles, type TemplateError } from "../apps/template_tip.ts"; |
| 28 | 29 | import { |
| 29 | 30 | envBuild, |
| 31 | envVariant, | |
| 30 | 32 | isEnvName, |
| 31 | 33 | previewEnv, |
| 32 | 34 | type EnvName, |
| @@ -788,6 +790,11 @@ async function commitFor( | ||
| 788 | 790 | // serves is the one the runtime holds for it, while it is held. |
| 789 | 791 | if (envBuild(env) !== undefined) |
| 790 | 792 | return appsRuntime(app)?.envStatus(record, env)?.commitOid || undefined; |
| 793 | const variant = envVariant(env); | |
| 794 | if (variant !== undefined) { | |
| 795 | const row = getVariant(app.db, record.id, variant); | |
| 796 | return row === undefined ? undefined : resolveOid(dir, `refs/heads/${row.branch}`); | |
| 797 | } | |
| 791 | 798 | const pull = getPull(app.db, repo.id, Number(env.slice(5))); |
| 792 | 799 | if (!pull || pull.state !== "open") return undefined; |
| 793 | 800 | const at = await pullDir(app.db, app.reposRoot, repo, pull); |
src/actions/env_words.ts +3 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * terminal's deploys screen, from here. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { envBuild, envPull, type EnvName } from "../apps/data.ts"; | |
| 7 | import { envBuild, envPull, envVariant, type EnvName } from "../apps/data.ts"; | |
| 8 | 8 | import type { Run } from "../core/runs.ts"; |
| 9 | 9 | import { VERIFY_FIRST } from "./verify_words.ts"; |
| 10 | 10 | |
| @@ -12,6 +12,8 @@ import { VERIFY_FIRST } from "./verify_words.ts"; | ||
| 12 | 12 | export function envLabel(env: EnvName): string { |
| 13 | 13 | const build = envBuild(env); |
| 14 | 14 | if (build !== undefined) return `build ${build}`; |
| 15 | const variant = envVariant(env); | |
| 16 | if (variant !== undefined) return `the ${variant} variant`; | |
| 15 | 17 | const number = envPull(env); |
| 16 | 18 | return number === undefined ? "production" : `preview of #${number}`; |
| 17 | 19 | } |
src/apps/data.ts +22 −1
| @@ -38,7 +38,7 @@ import { defect } from "../kernel/error.ts"; | ||
| 38 | 38 | * `production`, `pull-<n>` for the preview of pull `n`, or `build-<sha7>` |
| 39 | 39 | * for a build preview: one commit a writer asked to see now, short-lived. |
| 40 | 40 | */ |
| 41 | export type EnvName = "production" | `pull-${number}` | `build-${string}`; | |
| 41 | export type EnvName = "production" | `pull-${number}` | `build-${string}` | `variant-${string}`; | |
| 42 | 42 | |
| 43 | 43 | export const previewEnv = (pullNumber: number): EnvName => `pull-${pullNumber}`; |
| 44 | 44 | |
| @@ -50,9 +50,26 @@ export function envBuild(env: EnvName): string | undefined { | ||
| 50 | 50 | return /^build-[0-9a-f]{7}$/.test(env) ? env.slice(6) : undefined; |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | /** A variant's label: what its environment and hostname carry, `[a-z0-9]` and short. */ | |
| 54 | export const VARIANT_LABEL = /^[a-z0-9]{1,16}$/; | |
| 55 | ||
| 56 | /** The environment of the variant `label` (`variant-print`): a branch kept running (`core/variants.ts`). */ | |
| 57 | export const variantEnv = (label: string): EnvName => `variant-${label}`; | |
| 58 | ||
| 59 | /** The label a variant environment serves; `undefined` for any other environment. */ | |
| 60 | export function envVariant(env: EnvName): string | undefined { | |
| 61 | return env.startsWith("variant-") && VARIANT_LABEL.test(env.slice(8)) ? env.slice(8) : undefined; | |
| 62 | } | |
| 63 | ||
| 64 | /** A host label's tail read back as a variant: `v-print` → `variant-print`. */ | |
| 65 | export function variantEnvFromLabel(tail: string): EnvName | undefined { | |
| 66 | return tail.startsWith("v-") && VARIANT_LABEL.test(tail.slice(2)) ? variantEnv(tail.slice(2)) : undefined; | |
| 67 | } | |
| 68 | ||
| 53 | 69 | export function isEnvName(value: string): value is EnvName { |
| 54 | 70 | return ( |
| 55 | 71 | value === "production" || |
| 72 | /^variant-[a-z0-9]{1,16}$/.test(value) || | |
| 56 | 73 | /^pull-[1-9]\d*$/.test(value) || |
| 57 | 74 | /^build-[0-9a-f]{7}$/.test(value) |
| 58 | 75 | ); |
| @@ -73,6 +90,8 @@ export function envHostLabel(slug: string, env: EnvName): string { | ||
| 73 | 90 | if (env === "production") return slug; |
| 74 | 91 | const build = envBuild(env); |
| 75 | 92 | if (build !== undefined) return `${slug}--b${build}`; |
| 93 | const variant = envVariant(env); | |
| 94 | if (variant !== undefined) return `${slug}--v-${variant}`; | |
| 76 | 95 | return `${slug}--${env.slice(5)}`; |
| 77 | 96 | } |
| 78 | 97 | |
| @@ -90,6 +109,8 @@ export function envWords(env: EnvName): string { | ||
| 90 | 109 | if (env === "production") return "production"; |
| 91 | 110 | const build = envBuild(env); |
| 92 | 111 | if (build !== undefined) return `the build at ${build}`; |
| 112 | const variant = envVariant(env); | |
| 113 | if (variant !== undefined) return `the ${variant} variant`; | |
| 93 | 114 | return `the preview of #${env.slice(5)}`; |
| 94 | 115 | } |
| 95 | 116 |
src/apps/proxy.ts +13
| @@ -43,6 +43,8 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 43 | 43 | import { loadKey } from "../kernel/secret.ts"; |
| 44 | 44 | import { |
| 45 | 45 | buildEnvFromLabel, |
| 46 | variantEnvFromLabel, | |
| 47 | envVariant, | |
| 46 | 48 | envBuild, |
| 47 | 49 | envWords, |
| 48 | 50 | previewEnv, |
| @@ -117,6 +119,10 @@ export function parseAppHost( | ||
| 117 | 119 | if (build !== undefined) { |
| 118 | 120 | return { slug: parts.at(-2)!, env: build, nested: parts.length > 2 }; |
| 119 | 121 | } |
| 122 | const variant = parts.length >= 2 ? variantEnvFromLabel(last) : undefined; | |
| 123 | if (variant !== undefined) { | |
| 124 | return { slug: parts.at(-2)!, env: variant, nested: parts.length > 2 }; | |
| 125 | } | |
| 120 | 126 | if (parts.length >= 2 && /^\d+$/.test(last)) { |
| 121 | 127 | if (!/^[1-9]\d*$/.test(last)) return undefined; |
| 122 | 128 | return { |
| @@ -152,6 +158,13 @@ export function resolveAppHostname( | ||
| 152 | 158 | ): AppTarget | "unknown" | undefined { |
| 153 | 159 | const parsed = parseAppHost(host, domain); |
| 154 | 160 | if (parsed === undefined) return ownDomain(app, host); |
| 161 | // An app that already holds the slug `v-<label>` (made before that shape was | |
| 162 | // reserved) keeps its name: the host is it, in its parent's world, not a variant. | |
| 163 | const variant = envVariant(parsed.env); | |
| 164 | const held = variant === undefined ? undefined : getAppBySlug(app.db, `v-${variant}`); | |
| 165 | if (held !== undefined) { | |
| 166 | return shown(app, held) ? { app: held, env: "production", nested: true } : "unknown"; | |
| 167 | } | |
| 155 | 168 | const record = getAppBySlug(app.db, parsed.slug); |
| 156 | 169 | return record && shown(app, record) |
| 157 | 170 | ? { app: record, env: parsed.env, nested: parsed.nested } |
src/apps/runtime.ts +61 −8
| @@ -30,6 +30,8 @@ import type { App } from "../app.ts"; | ||
| 30 | 30 | import { getAppByRepo, listApps, type AppRecord } from "../core/apps.ts"; |
| 31 | 31 | import { resolveAppEnv } from "../core/app_env.ts"; |
| 32 | 32 | import { featureVarsAt } from "./feature_env.ts"; |
| 33 | import { getVariant, listVariants, variantOfBranch } from "../core/variants.ts"; | |
| 34 | import { readVariantValues } from "./variant_file.ts"; | |
| 33 | 35 | import { mintAppToken, renewAppToken, revokeAppToken } from "./token.ts"; |
| 34 | 36 | import type { ForgeEvent } from "../core/events.ts"; |
| 35 | 37 | import { announceRun } from "../actions/runs.ts"; |
| @@ -39,12 +41,12 @@ import { getPull, listPulls, type Pull } from "../core/pulls.ts"; | ||
| 39 | 41 | import { isContributionFork } from "../core/contribute.ts"; |
| 40 | 42 | import { pullMayRun } from "../core/pull_trust.ts"; |
| 41 | 43 | import { guestWaits } from "../origins/guest.ts"; |
| 42 | import { canEnterApp, getRepoById, type Repo } from "../core/repos.ts"; | |
| 44 | import { canEnterApp, canWrite, getRepoById, type Repo } from "../core/repos.ts"; | |
| 43 | 45 | import { runningLimitSentence } from "../settings/quotas.ts"; |
| 44 | 46 | import { placeFor } from "../core/placement_policy.ts"; |
| 45 | 47 | import { recordPlacement } from "../core/decisions.ts"; |
| 46 | 48 | import { bodyTarget, CLOUD_BODY_CONFIGURED } from "../spawn/placement.ts"; |
| 47 | import type { User } from "../core/users.ts"; | |
| 49 | import { getUserById, type User } from "../core/users.ts"; | |
| 48 | 50 | import { |
| 49 | 51 | cancelRun, |
| 50 | 52 | getRun, |
| @@ -87,6 +89,8 @@ import { | ||
| 87 | 89 | appsRoot, |
| 88 | 90 | buildEnv, |
| 89 | 91 | envBuild, |
| 92 | envVariant, | |
| 93 | variantEnv, | |
| 90 | 94 | envHostLabel, |
| 91 | 95 | ensureEnvData, |
| 92 | 96 | envDataDir, |
| @@ -467,7 +471,7 @@ export function envsFor( | ||
| 467 | 471 | * pull number — a number, not a string, so `pull-10` follows `pull-9`. |
| 468 | 472 | */ |
| 469 | 473 | const envRank = (env: EnvName): number => |
| 470 | env === "production" ? -1 : (envPull(env) ?? 0); | |
| 474 | env === "production" ? -1 : envVariant(env) !== undefined ? -0.5 : (envPull(env) ?? 0); | |
| 471 | 475 | const byEnv = (a: EnvStatus, b: EnvStatus): number => envRank(a.env) - envRank(b.env); |
| 472 | 476 | |
| 473 | 477 | export class AppsRuntime { |
| @@ -825,6 +829,15 @@ export class AppsRuntime { | ||
| 825 | 829 | if (tip !== undefined && only !== "preview") |
| 826 | 830 | this.requestDeploy(record, repo, "production", tip, reason); |
| 827 | 831 | const open = new Set<EnvName>(["production"]); |
| 832 | // A variant is a branch kept running: it stays while its row does, whatever | |
| 833 | // pulls are open (`core/variants.ts`). | |
| 834 | for (const variant of listVariants(this.app.db, record.id)) { | |
| 835 | const env = variantEnv(variant.label); | |
| 836 | open.add(env); | |
| 837 | if (only === "production") continue; | |
| 838 | const tipOid = await resolveOid(dir, `refs/heads/${variant.branch}`); | |
| 839 | if (tipOid !== undefined) this.requestDeploy(record, repo, env, tipOid, reason); | |
| 840 | } | |
| 828 | 841 | // A build preview lives only in memory: one this forge is running stays, |
| 829 | 842 | // and one an earlier life left on disk goes (`previewBuild`). |
| 830 | 843 | for (const state of this.envs.values()) |
| @@ -888,6 +901,15 @@ export class AppsRuntime { | ||
| 888 | 901 | |
| 889 | 902 | // ---- events ---- |
| 890 | 903 | |
| 904 | private pusherMayRunVariant(repo: Repo, actorId: string | null): boolean { | |
| 905 | const who = actorId === null ? undefined : getUserById(this.app.db, actorId); | |
| 906 | if (who === undefined || who.kind === "agent" || !canWrite(this.app.db, repo, who)) { | |
| 907 | log.info("variant waits for a person who can write", { repo: repo.name }); | |
| 908 | return false; | |
| 909 | } | |
| 910 | return true; | |
| 911 | } | |
| 912 | ||
| 891 | 913 | private async onEvent(event: ForgeEvent): Promise<void> { |
| 892 | 914 | if (this.stopped || event.repoId === null) return; |
| 893 | 915 | if (event.kind === "repo.deleted") { |
| @@ -929,6 +951,19 @@ export class AppsRuntime { | ||
| 929 | 951 | event.actorId ?? undefined, |
| 930 | 952 | ); |
| 931 | 953 | } |
| 954 | const variant = deleted ? undefined : variantOfBranch(this.app.db, record.id, branch); | |
| 955 | // A variant runs with the app's sealed values and a copy of its data: | |
| 956 | // a push to its branch redeploys only when a person who can write made it. | |
| 957 | // An agent proposes; the writer's own "set values" deploys directly. | |
| 958 | if (variant !== undefined && this.pusherMayRunVariant(repo, event.actorId)) | |
| 959 | this.requestDeploy( | |
| 960 | record, | |
| 961 | repo, | |
| 962 | variantEnv(variant.label), | |
| 963 | update.newOid, | |
| 964 | "push", | |
| 965 | event.actorId ?? undefined, | |
| 966 | ); | |
| 932 | 967 | // Every open pull whose head is this branch previews the new tip. |
| 933 | 968 | for (const pull of listPulls(this.app.db, repo.id, { sealed: true, |
| 934 | 969 | state: "open", |
| @@ -1172,6 +1207,20 @@ export class AppsRuntime { | ||
| 1172 | 1207 | return { env, run }; |
| 1173 | 1208 | } |
| 1174 | 1209 | |
| 1210 | /** | |
| 1211 | * Start (or move) a variant's environment at its branch's tip, now: the | |
| 1212 | * ask that registers a variant, or saves its values. Nothing when the | |
| 1213 | * branch has no tip. | |
| 1214 | */ | |
| 1215 | async deployVariant(record: AppRecord, repo: Repo, label: string, actorId?: string): Promise<Run | undefined> { | |
| 1216 | const variant = getVariant(this.app.db, record.id, label); | |
| 1217 | if (variant === undefined) return undefined; | |
| 1218 | const dir = repoDir(this.app.reposRoot, repo.ownerName, repo.name); | |
| 1219 | const tip = await resolveOid(dir, `refs/heads/${variant.branch}`); | |
| 1220 | if (tip === undefined) return undefined; | |
| 1221 | return this.requestDeploy(record, repo, variantEnv(label), tip, "variant", actorId); | |
| 1222 | } | |
| 1223 | ||
| 1175 | 1224 | private pullIssueId(repo: Repo, env: EnvName): string | undefined { |
| 1176 | 1225 | const pull = getPull(this.app.db, repo.id, Number(env.slice(5))); |
| 1177 | 1226 | return pull?.id; |
| @@ -1524,7 +1573,7 @@ export class AppsRuntime { | ||
| 1524 | 1573 | ssh: this.sshFor(state.app, state.env), |
| 1525 | 1574 | // Read at start, never cached: a change is a redeploy, and the |
| 1526 | 1575 | // process that comes up is the one that sees it. |
| 1527 | ...this.secretsFor(repo, state.app.id, state.env, token.token), | |
| 1576 | ...this.secretsFor(repo, state.app.id, state.env, token.token, dir), | |
| 1528 | 1577 | feature: this.featureFor(repo, state.app.id, state.env, dir), |
| 1529 | 1578 | log: write, |
| 1530 | 1579 | warm, |
| @@ -1802,14 +1851,18 @@ export class AppsRuntime { | ||
| 1802 | 1851 | appId: string, |
| 1803 | 1852 | env: EnvName, |
| 1804 | 1853 | forgeToken: string, |
| 1854 | dir?: string, | |
| 1805 | 1855 | ): { vars: Record<string, string>; forgeToken: string | undefined } { |
| 1806 | 1856 | const number = envPull(env); |
| 1807 | 1857 | const pull = |
| 1808 | 1858 | number === undefined ? undefined : getPull(this.app.db, repo.id, number); |
| 1809 | 1859 | if (pull !== undefined && guestWaits(this.app.db, pull)) |
| 1810 | 1860 | return { vars: {}, forgeToken: undefined }; |
| 1861 | // A variant's own values (`variant.json` on its branch) go over the | |
| 1862 | // app's defaults; they are plain settings, never secrets. | |
| 1863 | const own = dir !== undefined && envVariant(env) !== undefined ? readVariantValues(dir) : {}; | |
| 1811 | 1864 | return { |
| 1812 | vars: resolveAppEnv(this.app.db, this.app.config.dataDir, appId, env), | |
| 1865 | vars: { ...resolveAppEnv(this.app.db, this.app.config.dataDir, appId, env), ...own }, | |
| 1813 | 1866 | forgeToken, |
| 1814 | 1867 | }; |
| 1815 | 1868 | } |
| @@ -1834,7 +1887,7 @@ export class AppsRuntime { | ||
| 1834 | 1887 | if (isContributionFork(this.app.db, repo.id)) return false; |
| 1835 | 1888 | // Production is the default branch and a build preview a commit a writer asked to see |
| 1836 | 1889 | // (`actions/build_preview.ts`): each is a writer's choice already. |
| 1837 | if (env === "production" || envBuild(env) !== undefined) | |
| 1890 | if (env === "production" || envBuild(env) !== undefined || envVariant(env) !== undefined) | |
| 1838 | 1891 | return true; |
| 1839 | 1892 | const number = envPull(env); |
| 1840 | 1893 | const pull = |
| @@ -1903,7 +1956,7 @@ export class AppsRuntime { | ||
| 1903 | 1956 | forgeUrl: this.options.forgeUrl, |
| 1904 | 1957 | identityKey: identityKey(this.app.config.dataDir, record.id), |
| 1905 | 1958 | ssh: this.sshFor(record, env), |
| 1906 | ...this.secretsFor(repo, record.id, env, token.token), | |
| 1959 | ...this.secretsFor(repo, record.id, env, token.token, asleep.dir), | |
| 1907 | 1960 | feature: this.featureFor(repo, record.id, env, asleep.dir), |
| 1908 | 1961 | log: write, |
| 1909 | 1962 | signal: new AbortController().signal, |
| @@ -2141,7 +2194,7 @@ export class AppsRuntime { | ||
| 2141 | 2194 | continue; |
| 2142 | 2195 | const window = sleepWindow( |
| 2143 | 2196 | source, |
| 2144 | state.env === "production", | |
| 2197 | state.env === "production" || envVariant(state.env) !== undefined, | |
| 2145 | 2198 | live.manifest.alwaysOn === true, |
| 2146 | 2199 | ); |
| 2147 | 2200 | const limit = window.ms; |
src/apps/variant_file.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * What a variant carries on its branch: `variant.json`, at the root. | |
| 3 | * | |
| 4 | * { "values": { "PRINT": "true" } } | |
| 5 | * | |
| 6 | * `values` are plain settings the process starts with, over the app's own | |
| 7 | * defaults. They are in git, so they are versioned and visible: a secret | |
| 8 | * never goes here (it is set forge-side, `core/app_env.ts`). Pure: parsing, | |
| 9 | * checking and writing the text, and reading the one-line output an app | |
| 10 | * leaves for the board. Fail closed: a file that is present and wrong is | |
| 11 | * refused with the field named, and the variant then starts with no values. | |
| 12 | */ | |
| 13 | ||
| 14 | import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { isAppEnvName, isReservedAppEnvName } from "../core/app_env.ts"; | |
| 17 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 18 | ||
| 19 | export const VARIANT_FILE = "variant.json"; | |
| 20 | export const VALUES_MAX = 20; | |
| 21 | export const VALUE_LENGTH_MAX = 200; | |
| 22 | export const VARIANT_FILE_BYTES_MAX = 16 * 1024; | |
| 23 | ||
| 24 | /** Printable, one line. */ | |
| 25 | const PRINTABLE = /^[^\p{C}\p{Zl}\p{Zp}]*$/u; | |
| 26 | ||
| 27 | /** One value, checked: the reason when it is not allowed. */ | |
| 28 | export function checkValue(name: string, value: unknown): string | undefined { | |
| 29 | if (!isAppEnvName(name)) return `${JSON.stringify(name.slice(0, 40))} is not a variable name (capitals, digits, underscore)`; | |
| 30 | if (isReservedAppEnvName(name)) return `${name} is set by the forge and cannot be a variant's value`; | |
| 31 | if (typeof value !== "string") return `${name} must be a string`; | |
| 32 | if ([...value].length > VALUE_LENGTH_MAX) return `${name} is over ${VALUE_LENGTH_MAX} characters`; | |
| 33 | if (!PRINTABLE.test(value)) return `${name} must be one printable line`; | |
| 34 | return undefined; | |
| 35 | } | |
| 36 | ||
| 37 | export function parseVariantFile(text: string): Result<Record<string, string>, string> { | |
| 38 | if (Buffer.byteLength(text, "utf8") > VARIANT_FILE_BYTES_MAX) return err(`${VARIANT_FILE} is over ${VARIANT_FILE_BYTES_MAX} bytes`); | |
| 39 | let parsed: unknown; | |
| 40 | try { | |
| 41 | parsed = JSON.parse(text); | |
| 42 | } catch { | |
| 43 | return err(`${VARIANT_FILE} is not JSON`); | |
| 44 | } | |
| 45 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return err(`${VARIANT_FILE} is not an object`); | |
| 46 | const record = parsed as Record<string, unknown>; | |
| 47 | const extra = Object.keys(record).find((k) => k !== "values"); | |
| 48 | if (extra !== undefined) return err(`unexpected field ${JSON.stringify(extra.slice(0, 40))}`); | |
| 49 | const values = record["values"] ?? {}; | |
| 50 | if (typeof values !== "object" || values === null || Array.isArray(values)) return err("values is not an object"); | |
| 51 | const entries = Object.entries(values as Record<string, unknown>); | |
| 52 | if (entries.length > VALUES_MAX) return err(`values has more than ${VALUES_MAX} entries`); | |
| 53 | const out: Record<string, string> = {}; | |
| 54 | for (const [name, value] of entries) { | |
| 55 | const bad = checkValue(name, value); | |
| 56 | if (bad !== undefined) return err(bad); | |
| 57 | out[name] = value as string; | |
| 58 | } | |
| 59 | return ok(out); | |
| 60 | } | |
| 61 | ||
| 62 | /** The file's text for `values`: sorted, so the same values are the same bytes. */ | |
| 63 | export function variantFileText(values: Readonly<Record<string, string>>): string { | |
| 64 | const sorted: Record<string, string> = {}; | |
| 65 | for (const name of Object.keys(values).sort()) sorted[name] = values[name]!; | |
| 66 | return `${JSON.stringify({ values: sorted }, null, 2)}\n`; | |
| 67 | } | |
| 68 | ||
| 69 | /** The values in a checkout's `variant.json`; none when absent or wrong. */ | |
| 70 | export function readVariantValues(dir: string): Record<string, string> { | |
| 71 | const path = join(dir, VARIANT_FILE); | |
| 72 | try { | |
| 73 | // A branch may commit a symlink: only a regular file is read, never what a link points at. | |
| 74 | const info = lstatSync(path, { throwIfNoEntry: false }); | |
| 75 | if (info === undefined || !info.isFile() || info.size > VARIANT_FILE_BYTES_MAX) return {}; | |
| 76 | const parsed = parseVariantFile(readFileSync(path, "utf8")); | |
| 77 | return parsed.ok ? parsed.value : {}; | |
| 78 | } catch { | |
| 79 | return {}; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | /** Parse "name=value" lines, one per line: the form a person types. Blank lines are skipped. */ | |
| 84 | export function parseValueLines(text: string): Result<Record<string, string>, string> { | |
| 85 | const out: Record<string, string> = {}; | |
| 86 | for (const raw of text.split(/\r?\n/)) { | |
| 87 | const line = raw.trim(); | |
| 88 | if (line === "") continue; | |
| 89 | const at = line.indexOf("="); | |
| 90 | if (at < 1) return err(`"${line.slice(0, 40)}" is not NAME=value`); | |
| 91 | const name = line.slice(0, at).trim(); | |
| 92 | const value = line.slice(at + 1).trim(); | |
| 93 | const bad = checkValue(name, value); | |
| 94 | if (bad !== undefined) return err(bad); | |
| 95 | out[name] = value; | |
| 96 | } | |
| 97 | if (Object.keys(out).length > VALUES_MAX) return err(`more than ${VALUES_MAX} values`); | |
| 98 | return ok(out); | |
| 99 | } | |
| 100 | ||
| 101 | /** The line an app leaves in its data directory, `last-output.json`, for the board. */ | |
| 102 | export const OUTPUT_FILE = "last-output.json"; | |
| 103 | ||
| 104 | export interface LastOutput { | |
| 105 | readonly title: string; | |
| 106 | /** A path on the app's own address, or empty. */ | |
| 107 | readonly path: string; | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * `{ "title": "Edition 12", "path": "/editions/12" }` in the environment's data | |
| 112 | * directory: what this variant last made. Read as data only, bounded and | |
| 113 | * checked; a missing or wrong file is no output. | |
| 114 | */ | |
| 115 | export function readLastOutput(dataDir: string): LastOutput | undefined { | |
| 116 | const path = join(dataDir, OUTPUT_FILE); | |
| 117 | try { | |
| 118 | if (!existsSync(path) || statSync(path).size > 4096) return undefined; | |
| 119 | const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); | |
| 120 | if (typeof parsed !== "object" || parsed === null) return undefined; | |
| 121 | const { title, path: href } = parsed as Record<string, unknown>; | |
| 122 | if (typeof title !== "string" || title === "" || [...title].length > 120 || !PRINTABLE.test(title)) return undefined; | |
| 123 | const to = typeof href === "string" ? href : ""; | |
| 124 | if (to !== "" && (!to.startsWith("/") || to.startsWith("//") || to.length > 200 || !PRINTABLE.test(to) || /[\s"'<>\\]/.test(to))) return undefined; | |
| 125 | return { title, path: to }; | |
| 126 | } catch { | |
| 127 | return undefined; | |
| 128 | } | |
| 129 | } |
src/core/apps.ts +2 −2
| @@ -47,10 +47,10 @@ const toApp = (r: AppRow): AppRecord => ({ | ||
| 47 | 47 | |
| 48 | 48 | /** |
| 49 | 49 | * A slug that is also an environment's suffix: `<slug>--b<sha7>` is a build |
| 50 | * preview, so no slug may be `b` and seven hex digits. | |
| 50 | * preview, so no slug may be `b` and seven hex digits; `<slug>--v-<label>` is a variant (`core/variants.ts`), so none is `v-` and a label. | |
| 51 | 51 | */ |
| 52 | 52 | const RESERVED_SLUG = /^b[0-9a-f]{7}$/; |
| 53 | const reserved = (slug: string): boolean => RESERVED_SLUG.test(slug); | |
| 53 | const reserved = (slug: string): boolean => RESERVED_SLUG.test(slug) || /^v-[a-z0-9]{1,16}$/.test(slug); | |
| 54 | 54 | |
| 55 | 55 | /** |
| 56 | 56 | * A slug is a DNS label: lowercase letters, digits, single hyphens inside. |
src/core/migration-pins/20261110110000-app-variant.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 5265ef2a3ae373f4809e420e2f948d83772a80947ee6695eeed1548001bb30df |
src/core/migrations/20261110110000-app-variant.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | // A variant: a branch of an app kept running as its own environment | |
| 2 | // (`<slug>--v-<label>`), with values it carries in `variant.json` on the | |
| 3 | // branch. One row per (app, label); a branch is one variant at most. | |
| 4 | // Additive; the row is the index, the branch and its file are the truth. | |
| 5 | export default ` | |
| 6 | CREATE TABLE app_variant ( | |
| 7 | app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE, | |
| 8 | label TEXT NOT NULL, | |
| 9 | branch TEXT NOT NULL, | |
| 10 | created_by TEXT REFERENCES user(id) ON DELETE SET NULL, | |
| 11 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 12 | PRIMARY KEY (app_id, label), | |
| 13 | UNIQUE (app_id, branch) | |
| 14 | ); | |
| 15 | `; |
src/core/variants.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * Variants — the rows. A variant is a branch of an app kept running as an | |
| 3 | * environment of its own, beside production and the previews of pulls: it | |
| 4 | * does not end when a pull does, and it sleeps on production's schedule. | |
| 5 | * The row only says which branch is a variant and what to call it; the | |
| 6 | * values it carries are `variant.json` on the branch (`apps/variant_file.ts`). | |
| 7 | * Pure data: no authz, no events. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { VARIANT_LABEL } from "../apps/data.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | ||
| 14 | /** Most variants one app keeps: each is a running environment against the app's budget. */ | |
| 15 | export const VARIANTS_MAX = 6; | |
| 16 | ||
| 17 | export interface Variant { | |
| 18 | readonly appId: string; | |
| 19 | readonly label: string; | |
| 20 | readonly branch: string; | |
| 21 | readonly createdAt: string; | |
| 22 | } | |
| 23 | ||
| 24 | interface Row { | |
| 25 | app_id: string; | |
| 26 | label: string; | |
| 27 | branch: string; | |
| 28 | created_at: string; | |
| 29 | } | |
| 30 | ||
| 31 | const toVariant = (r: Row): Variant => ({ appId: r.app_id, label: r.label, branch: r.branch, createdAt: r.created_at }); | |
| 32 | ||
| 33 | /** The label a branch name suggests: lowercase letters and digits, at most 16. */ | |
| 34 | export function labelFor(branch: string): string { | |
| 35 | const label = branch.toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 16); | |
| 36 | return label === "" ? "variant" : label; | |
| 37 | } | |
| 38 | ||
| 39 | export const isVariantLabel = (value: string): boolean => VARIANT_LABEL.test(value); | |
| 40 | ||
| 41 | export function listVariants(db: Database, appId: string): Variant[] { | |
| 42 | return db | |
| 43 | .query<Row, [string]>("SELECT app_id, label, branch, created_at FROM app_variant WHERE app_id = ? ORDER BY created_at, label") | |
| 44 | .all(appId) | |
| 45 | .map(toVariant); | |
| 46 | } | |
| 47 | ||
| 48 | export function getVariant(db: Database, appId: string, label: string): Variant | undefined { | |
| 49 | const row = db | |
| 50 | .query<Row, [string, string]>("SELECT app_id, label, branch, created_at FROM app_variant WHERE app_id = ? AND label = ?") | |
| 51 | .get(appId, label); | |
| 52 | return row === null ? undefined : toVariant(row); | |
| 53 | } | |
| 54 | ||
| 55 | export function variantOfBranch(db: Database, appId: string, branch: string): Variant | undefined { | |
| 56 | const row = db | |
| 57 | .query<Row, [string, string]>("SELECT app_id, label, branch, created_at FROM app_variant WHERE app_id = ? AND branch = ?") | |
| 58 | .get(appId, branch); | |
| 59 | return row === null ? undefined : toVariant(row); | |
| 60 | } | |
| 61 | ||
| 62 | export type AddVariantError = "invalid_label" | "label_taken" | "branch_taken" | "too_many"; | |
| 63 | ||
| 64 | export function addVariant( | |
| 65 | db: Database, | |
| 66 | input: { appId: string; label: string; branch: string; createdBy: string }, | |
| 67 | ): Result<Variant, AddVariantError> { | |
| 68 | if (!isVariantLabel(input.label)) return err("invalid_label"); | |
| 69 | return db | |
| 70 | .transaction((): Result<Variant, AddVariantError> => { | |
| 71 | if (getVariant(db, input.appId, input.label) !== undefined) return err("label_taken"); | |
| 72 | if (variantOfBranch(db, input.appId, input.branch) !== undefined) return err("branch_taken"); | |
| 73 | if (listVariants(db, input.appId).length >= VARIANTS_MAX) return err("too_many"); | |
| 74 | db.query("INSERT INTO app_variant (app_id, label, branch, created_by) VALUES (?, ?, ?, ?)").run( | |
| 75 | input.appId, | |
| 76 | input.label, | |
| 77 | input.branch, | |
| 78 | input.createdBy, | |
| 79 | ); | |
| 80 | return ok(getVariant(db, input.appId, input.label)!); | |
| 81 | }) | |
| 82 | .immediate(); | |
| 83 | } | |
| 84 | ||
| 85 | export function removeVariant(db: Database, appId: string, label: string): boolean { | |
| 86 | return db.query("DELETE FROM app_variant WHERE app_id = ? AND label = ?").run(appId, label).changes > 0; | |
| 87 | } |
src/http/server.ts +6
| @@ -200,6 +200,7 @@ import { | ||
| 200 | 200 | branchesPage, |
| 201 | 201 | } from "../web/pages/branches.ts"; |
| 202 | 202 | import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts"; |
| 203 | import { mainSubmit, registerSubmit, retireSubmit, valuesSubmit, variantBoardPage } from "../web/pages/app_variants.ts"; | |
| 203 | 204 | import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts"; |
| 204 | 205 | import { shotImage, shotsPage } from "../web/pages/build_shots.ts"; |
| 205 | 206 | import { cartArt, cartPage } from "../web/pages/build_cart.ts"; |
| @@ -1395,6 +1396,11 @@ export function buildRouter(): Router<Handler> { | ||
| 1395 | 1396 | router.add("GET", "/:owner/:name/iterations", iterationsPage); |
| 1396 | 1397 | router.add("POST", "/:owner/:name/builds", pinBuildSubmit); |
| 1397 | 1398 | router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit); |
| 1399 | router.add("GET", "/:owner/:name/variants", variantBoardPage); | |
| 1400 | router.add("POST", "/:owner/:name/variants", registerSubmit); | |
| 1401 | router.add("POST", "/:owner/:name/variants/:label/values", valuesSubmit); | |
| 1402 | router.add("POST", "/:owner/:name/variants/:label/retire", retireSubmit); | |
| 1403 | router.add("POST", "/:owner/:name/variants/:label/main", mainSubmit); | |
| 1398 | 1404 | router.add("POST", "/:owner/:name/builds/:label/delete", deletePinSubmit); |
| 1399 | 1405 | router.add("POST", "/:owner/:name/builds/:label/note", noteSubmit); |
| 1400 | 1406 | router.add("GET", "/:owner/:name/waves.atom", wavesAtom); |
src/tui/screens/variants.ts added +194
| @@ -0,0 +1,194 @@ | ||
| 1 | /** | |
| 2 | * An app's variants in the terminal: the board the page draws, from a | |
| 3 | * repository's `:` palette. Anyone who can read the repository sees each | |
| 4 | * variant — branch, state, values, what it last made. A person who can | |
| 5 | * write also registers a branch (`r`), sets one value (`s`) or drops one | |
| 6 | * (`u`), retires a variant after a confirm (`x`) and asks for it to become | |
| 7 | * main (`m`), through the same actions as the page, refused in its words. | |
| 8 | */ | |
| 9 | ||
| 10 | import { | |
| 11 | makeMain, | |
| 12 | registerVariant, | |
| 13 | retireVariant, | |
| 14 | setVariantValues, | |
| 15 | variantBoard, | |
| 16 | variantRefusalText, | |
| 17 | type VariantCard, | |
| 18 | } from "../../actions/app_variants.ts"; | |
| 19 | import { checkValue } from "../../apps/variant_file.ts"; | |
| 20 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 21 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 22 | import { VARIANTS_MAX } from "../../core/variants.ts"; | |
| 23 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 24 | import { binding } from "../app/keys.ts"; | |
| 25 | import type { Command } from "../app/palette.ts"; | |
| 26 | import { style } from "../term/style.ts"; | |
| 27 | import { wrap } from "../term/width.ts"; | |
| 28 | import { fill, len, split } from "../ui/layout.ts"; | |
| 29 | import type { Session } from "../session.ts"; | |
| 30 | import { ListState, confirmScreen, drawList, later, promptScreen, runAsync, type ListRow } from "./common.ts"; | |
| 31 | ||
| 32 | /** The palette's "Variants", for anyone who can read a repository that is an app. */ | |
| 33 | export function variantsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 34 | if (repo === undefined || getAppByRepo(f.app.db, repo.id) === undefined) return []; | |
| 35 | return [ | |
| 36 | { | |
| 37 | id: "app-variants", | |
| 38 | title: `${repo.ownerName}/${repo.name} — Variants`, | |
| 39 | hint: "branches kept running, with values of their own", | |
| 40 | run: () => ctx.push(variantsScreen(f, ctx, repo)), | |
| 41 | }, | |
| 42 | ]; | |
| 43 | } | |
| 44 | ||
| 45 | const valuesLine = (values: Readonly<Record<string, string>>): string => | |
| 46 | Object.keys(values).length === 0 ? "the app's defaults" : Object.entries(values).map(([n, v]) => `${n}=${v}`).join(" "); | |
| 47 | ||
| 48 | const asLines = (values: Readonly<Record<string, string>>): string => | |
| 49 | Object.entries(values).map(([n, v]) => `${n}=${v}`).join("\n"); | |
| 50 | ||
| 51 | export function variantsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 52 | const state = new ListState(); | |
| 53 | let cards: VariantCard[] = []; | |
| 54 | let note = ""; | |
| 55 | let live = 0; | |
| 56 | const writer = (): boolean => f.user !== undefined && f.user.kind !== "agent" && canWrite(f.app.db, repo, f.user); | |
| 57 | ||
| 58 | const reload = async (): Promise<void> => { | |
| 59 | const board = await variantBoard(f.app, repo); | |
| 60 | cards = board?.cards ?? []; | |
| 61 | live = board?.live ?? 0; | |
| 62 | state.clamp(cards.length); | |
| 63 | ctx.invalidate(); | |
| 64 | }; | |
| 65 | const say = async (text: string): Promise<void> => { | |
| 66 | note = text; | |
| 67 | await reload(); | |
| 68 | }; | |
| 69 | runAsync(ctx, reload); | |
| 70 | ||
| 71 | const refusal = (error: unknown): string => | |
| 72 | typeof error === "string" | |
| 73 | ? variantRefusalText(error) | |
| 74 | : variantRefusalText("bad_values", (error as { message?: string }).message); | |
| 75 | ||
| 76 | const register = (): Screen => | |
| 77 | promptScreen(ctx, screen, { | |
| 78 | title: "Run a branch as a variant", | |
| 79 | label: "branch", | |
| 80 | onSubmit: (branch) => { | |
| 81 | if (branch === "") return "A variant needs a branch."; | |
| 82 | return later(ctx, () => | |
| 83 | promptScreen(ctx, screen, { | |
| 84 | title: branch, | |
| 85 | label: "label (empty: from the branch name)", | |
| 86 | onSubmit: async (label) => { | |
| 87 | const made = await registerVariant(f.app, f.user, repo, { branch, ...(label === "" ? {} : { label }) }); | |
| 88 | await say(made.ok ? `Running ${made.value.branch} as the ${made.value.label} variant.` : refusal(made.error)); | |
| 89 | }, | |
| 90 | }), | |
| 91 | ); | |
| 92 | }, | |
| 93 | }); | |
| 94 | ||
| 95 | const save = async (card: VariantCard, values: Record<string, string>, said: string): Promise<void> => { | |
| 96 | const saved = await setVariantValues(f.app, f.user, repo, card.label, asLines(values)); | |
| 97 | await say(saved.ok ? said : refusal(saved.error)); | |
| 98 | }; | |
| 99 | ||
| 100 | const setOne = (card: VariantCard): Screen => | |
| 101 | promptScreen(ctx, screen, { | |
| 102 | title: `A value for ${card.label}`, | |
| 103 | label: "NAME=value", | |
| 104 | onSubmit: (line) => { | |
| 105 | const at = line.indexOf("="); | |
| 106 | if (at < 1) return "Type NAME=value."; | |
| 107 | const name = line.slice(0, at).trim(); | |
| 108 | const value = line.slice(at + 1).trim(); | |
| 109 | const bad = checkValue(name, value); | |
| 110 | if (bad !== undefined) return bad; | |
| 111 | return save(card, { ...card.values, [name]: value }, `${name} is set for ${card.label}.`); | |
| 112 | }, | |
| 113 | }); | |
| 114 | ||
| 115 | const dropOne = (card: VariantCard): Screen => | |
| 116 | promptScreen(ctx, screen, { | |
| 117 | title: `Drop a value of ${card.label}`, | |
| 118 | label: "NAME", | |
| 119 | onSubmit: (name) => { | |
| 120 | if (!(name in card.values)) return `${card.label} has no value ${name.slice(0, 40)}.`; | |
| 121 | const { [name]: _gone, ...rest } = card.values; | |
| 122 | return save(card, rest, `${name} is dropped from ${card.label}.`); | |
| 123 | }, | |
| 124 | }); | |
| 125 | ||
| 126 | const retire = (card: VariantCard): Screen => | |
| 127 | confirmScreen(ctx, screen, { | |
| 128 | title: "Retire this variant", | |
| 129 | body: [`${card.label} on ${card.branch}`, "Its environment and data are removed; the branch is kept."], | |
| 130 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Retire", variant: "danger" }], | |
| 131 | onChoose: (i) => { | |
| 132 | if (i !== 1) return; | |
| 133 | runAsync(ctx, async () => { | |
| 134 | const gone = await retireVariant(f.app, f.user, repo, card.label); | |
| 135 | await say(gone.ok ? `${card.label} is retired. The branch is kept.` : refusal(gone.error)); | |
| 136 | }); | |
| 137 | }, | |
| 138 | }); | |
| 139 | ||
| 140 | const rows = (): ListRow[] => | |
| 141 | cards.map((c) => ({ | |
| 142 | title: c.label, | |
| 143 | ...(c.state === "running" ? { tone: "success" as const } : c.state === "failed" ? { tone: "danger" as const } : {}), | |
| 144 | secondary: c.branch, | |
| 145 | meta: c.state, | |
| 146 | below: [valuesLine(c.values), c.output === undefined ? "nothing made yet" : `last made: ${c.output.title}`], | |
| 147 | })); | |
| 148 | ||
| 149 | const screen: Screen = { | |
| 150 | title: "Variants", | |
| 151 | get keys() { | |
| 152 | return [ | |
| 153 | ...(writer() | |
| 154 | ? [binding(["r"], "run a branch"), binding(["s"], "set a value"), binding(["u"], "drop one"), binding(["x"], "retire"), binding(["m"], "make main")] | |
| 155 | : []), | |
| 156 | binding(["j", "k"], "move cursor"), | |
| 157 | ]; | |
| 158 | }, | |
| 159 | render(area, s) { | |
| 160 | const c = ctx.theme.c; | |
| 161 | const text = | |
| 162 | note !== "" | |
| 163 | ? note | |
| 164 | : cards.length === 0 | |
| 165 | ? writer() ? "No variants yet. Press r to run a branch as one." : "No variants yet." | |
| 166 | : `${cards.length} of ${VARIANTS_MAX}; ${live} previews running.`; | |
| 167 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); | |
| 168 | const [head, list] = split(area, "v", [len(said.length), fill(1)]); | |
| 169 | said.forEach((line, i) => s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w)); | |
| 170 | drawList(s, list!, c, state, rows()); | |
| 171 | }, | |
| 172 | key(e) { | |
| 173 | if (state.key(e, cards.length)) return true; | |
| 174 | if (e.ctrl || e.alt || !writer()) return false; | |
| 175 | if (e.key === "r") { | |
| 176 | ctx.push(register()); | |
| 177 | return true; | |
| 178 | } | |
| 179 | const card = cards[state.index]; | |
| 180 | if (card === undefined) return false; | |
| 181 | if (e.key === "s") ctx.push(setOne(card)); | |
| 182 | else if (e.key === "u") ctx.push(dropOne(card)); | |
| 183 | else if (e.key === "x") ctx.push(retire(card)); | |
| 184 | else if (e.key === "m") { | |
| 185 | runAsync(ctx, async () => { | |
| 186 | const pull = await makeMain(f.app, f.user, repo, card.label); | |
| 187 | await say(pull.ok ? `Pull #${pull.value.number} makes ${card.label} main; merge it to land it.` : refusal(pull.error)); | |
| 188 | }); | |
| 189 | } else return false; | |
| 190 | return true; | |
| 191 | }, | |
| 192 | }; | |
| 193 | return screen; | |
| 194 | } |
src/tui/screens/workspace.ts +2
| @@ -150,6 +150,7 @@ import { displayCommands } from "./display.ts"; | ||
| 150 | 150 | import { iterationsCommands } from "./iterations.ts"; |
| 151 | 151 | import { labelsCommands } from "./labels.ts"; |
| 152 | 152 | import { appEnvCommands } from "./appenv.ts"; |
| 153 | import { variantsCommands } from "./variants.ts"; | |
| 153 | 154 | import { autonomyCommands } from "./autonomy.ts"; |
| 154 | 155 | import { trafficCommands } from "./trafficview.ts"; |
| 155 | 156 | import { blockedCommands } from "./blocked.ts"; |
| @@ -1633,6 +1634,7 @@ export function workspaceScreen( | ||
| 1633 | 1634 | ...iterationsCommands(f, ctx, repo), |
| 1634 | 1635 | ...displayCommands(f, ctx, repo), |
| 1635 | 1636 | ...appEnvCommands(f, ctx, repo), |
| 1637 | ...variantsCommands(f, ctx, repo), | |
| 1636 | 1638 | ...autonomyCommands(f, ctx, repo), |
| 1637 | 1639 | ...trafficCommands(f, ctx, repo), |
| 1638 | 1640 | ...blockedCommands(f, ctx, repo), |
src/web/pages/app_variants.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * The variant board: an app's branches kept running as variants, side by | |
| 3 | * side — each one's preview, what it last made, and the values that set it | |
| 4 | * apart — with the controls a writer has: register a branch, set its | |
| 5 | * values, retire it, make it main (docs/features/variants.md). | |
| 6 | */ | |
| 7 | ||
| 8 | import { | |
| 9 | makeMain, | |
| 10 | registerVariant, | |
| 11 | retireVariant, | |
| 12 | setVariantValues, | |
| 13 | variantBoard, | |
| 14 | variantRefusalText, | |
| 15 | type VariantCard, | |
| 16 | } from "../../actions/app_variants.ts"; | |
| 17 | import { VARIANTS_MAX } from "../../core/variants.ts"; | |
| 18 | import { canWrite } from "../../core/repos.ts"; | |
| 19 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 20 | import type { Ctx } from "../../http/context.ts"; | |
| 21 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; | |
| 22 | import { field as formValue, readForm, type Form } from "../form.ts"; | |
| 23 | import { html, when, type Html } from "../html.ts"; | |
| 24 | import { flashTo, page } from "../layout.ts"; | |
| 25 | import { badge, btn, confirmAction, disclosure, empty, field, listCard, row } from "../ui.ts"; | |
| 26 | ||
| 27 | const hidden = (error: string): boolean => error === "unauthorized" || error === "not_found" || error === "forbidden"; | |
| 28 | ||
| 29 | const valueLines = (values: Readonly<Record<string, string>>): string => | |
| 30 | Object.entries(values).map(([name, value]) => `${name}=${value}`).join("\n"); | |
| 31 | ||
| 32 | /** One variant, one row: its facts, then a single row of actions and the values form folded under it. */ | |
| 33 | function variantRow(base: string, c: VariantCard, writer: boolean): Html { | |
| 34 | const here = `${base}/variants/${c.label}`; | |
| 35 | return row({ | |
| 36 | title: c.label, | |
| 37 | lead: badge(c.state, { tone: c.state === "running" ? "success" : c.state === "failed" ? "danger" : "default" }), | |
| 38 | trail: html`<span class="muted small mono">${c.branch}</span>`, | |
| 39 | data: { "variant-card": c.label }, | |
| 40 | facts: [ | |
| 41 | { | |
| 42 | label: "Values", | |
| 43 | value: Object.keys(c.values).length === 0 | |
| 44 | ? html`<span class="muted">the app's defaults</span>` | |
| 45 | : html`${Object.entries(c.values).map(([name, value]) => html`<code>${name}=${value}</code> `)}`, | |
| 46 | }, | |
| 47 | { | |
| 48 | label: "Last made", | |
| 49 | value: c.output === undefined | |
| 50 | ? html`<span class="muted">nothing yet</span>` | |
| 51 | : c.output.path === "" | |
| 52 | ? c.output.title | |
| 53 | : html`<a href="${base}/app/${c.env}/open?to=${encodeURIComponent(c.output.path)}">${c.output.title}</a>`, | |
| 54 | }, | |
| 55 | ], | |
| 56 | after: html`<div class="bar"> | |
| 57 | ${btn("Open preview", { href: `${base}/app/${c.env}/open`, size: "sm", variant: "outline", icon: "external" })} | |
| 58 | ${when(writer, () => html`<form method="post" action="${here}/main">${btn("Make this main", { size: "sm", variant: "outline" })}</form> | |
| 59 | ${confirmAction({ action: `${here}/retire`, verb: "Retire", size: "sm" })}`)} | |
| 60 | </div> | |
| 61 | ${when(writer, () => disclosure("Edit values", html`<form method="post" action="${here}/values"> | |
| 62 | ${field({ label: `Values for ${c.label}`, name: "values", type: "textarea", rows: 3, mono: true, spellcheck: false, value: valueLines(c.values), hint: "NAME=value, one per line." })} | |
| 63 | ${btn("Save values", { size: "sm", variant: "outline" })} | |
| 64 | </form>`, { small: true }))}`, | |
| 65 | }); | |
| 66 | } | |
| 67 | ||
| 68 | // GET /:owner/:name/variants | |
| 69 | export async function variantBoardPage(ctx: Ctx): Promise<Response> { | |
| 70 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 71 | const repo = loadRepo(ctx); | |
| 72 | if (!repo) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); | |
| 73 | const board = await variantBoard(ctx.app, repo); | |
| 74 | if (board === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); | |
| 75 | const base = repoBase(repo); | |
| 76 | const writer = ctx.auth.user !== undefined && canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 77 | const budget = board.max === null ? `${board.live} previews running` : `${board.live} of ${board.max} previews running`; | |
| 78 | const body = html`${repoHeader(ctx, repo, "branches", { compact: true })} | |
| 79 | <h2 class="mt">Variants</h2> | |
| 80 | <p class="muted small">A branch kept running beside production, with values of its own: ${budget}.</p> | |
| 81 | ${board.cards.length === 0 | |
| 82 | ? empty({ title: "No variants yet", note: "Register a branch to run it as a variant." }) | |
| 83 | : listCard(board.cards.map((c) => variantRow(base, c, writer)))} | |
| 84 | ${when(writer && board.cards.length < VARIANTS_MAX, () => html`<form method="post" action="${base}/variants" class="mt"> | |
| 85 | <h3>Run a branch as a variant</h3> | |
| 86 | ${field({ label: "Branch", name: "branch", required: true, mono: true, spellcheck: false })} | |
| 87 | ${field({ label: "Label", name: "label", hint: "Defaults from the branch name.", spellcheck: false, maxlength: 16 })} | |
| 88 | ${btn("Run as variant", { variant: "primary" })} | |
| 89 | </form>`)}`; | |
| 90 | return timePhase(ctx.request, "render", () => | |
| 91 | page({ ctx, title: `Variants · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }), | |
| 92 | ); | |
| 93 | } | |
| 94 | ||
| 95 | type Verb = (ctx: Ctx, repo: NonNullable<ReturnType<typeof loadRepo>>, form: Form) => Promise<Response>; | |
| 96 | ||
| 97 | async function mutate(ctx: Ctx, verb: Verb): Promise<Response> { | |
| 98 | const repo = loadRepo(ctx); | |
| 99 | if (!repo) return notFoundPage(ctx); | |
| 100 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 101 | const form = await readForm(ctx, "rw"); | |
| 102 | if (form instanceof Response) return form; | |
| 103 | return verb(ctx, repo, form); | |
| 104 | } | |
| 105 | ||
| 106 | const refused = (ctx: Ctx, to: string, error: string | { kind: "bad_values"; message: string }): Response => | |
| 107 | typeof error === "string" && hidden(error) | |
| 108 | ? notFoundPage(ctx) | |
| 109 | : flashTo(ctx, to, { | |
| 110 | tone: "danger", | |
| 111 | message: typeof error === "string" ? variantRefusalText(error) : variantRefusalText("bad_values", error.message), | |
| 112 | }); | |
| 113 | ||
| 114 | // POST /:owner/:name/variants | |
| 115 | export const registerSubmit = (ctx: Ctx): Promise<Response> => | |
| 116 | mutate(ctx, async (c, repo, form) => { | |
| 117 | const to = `${repoBase(repo)}/variants`; | |
| 118 | const label = formValue(form, "label"); | |
| 119 | const made = await registerVariant(c.app, c.auth.user, repo, { branch: formValue(form, "branch"), ...(label === "" ? {} : { label }) }); | |
| 120 | return made.ok ? flashTo(c, to, { message: `Running ${made.value.branch} as the ${made.value.label} variant.` }) : refused(c, to, made.error); | |
| 121 | }); | |
| 122 | ||
| 123 | // POST /:owner/:name/variants/:label/values | |
| 124 | export const valuesSubmit = (ctx: Ctx): Promise<Response> => | |
| 125 | mutate(ctx, async (c, repo, form) => { | |
| 126 | const to = `${repoBase(repo)}/variants`; | |
| 127 | const saved = await setVariantValues(c.app, c.auth.user, repo, c.params.label ?? "", formValue(form, "values")); | |
| 128 | return saved.ok ? flashTo(c, to, { message: "Saved. The variant restarts with it." }) : refused(c, to, saved.error); | |
| 129 | }); | |
| 130 | ||
| 131 | // POST /:owner/:name/variants/:label/retire | |
| 132 | export const retireSubmit = (ctx: Ctx): Promise<Response> => | |
| 133 | mutate(ctx, async (c, repo) => { | |
| 134 | const to = `${repoBase(repo)}/variants`; | |
| 135 | const gone = await retireVariant(c.app, c.auth.user, repo, c.params.label ?? ""); | |
| 136 | return gone.ok ? flashTo(c, to, { message: "Retired. The branch is kept." }) : refused(c, to, gone.error); | |
| 137 | }); | |
| 138 | ||
| 139 | // POST /:owner/:name/variants/:label/main | |
| 140 | export const mainSubmit = (ctx: Ctx): Promise<Response> => | |
| 141 | mutate(ctx, async (c, repo) => { | |
| 142 | const to = `${repoBase(repo)}/variants`; | |
| 143 | const pull = await makeMain(c.app, c.auth.user, repo, c.params.label ?? ""); | |
| 144 | return pull.ok | |
| 145 | ? flashTo(c, `${repoBase(repo)}/pulls/${pull.value.number}`, { message: "Merge this pull to make the variant main." }) | |
| 146 | : refused(c, to, pull.error); | |
| 147 | }); |
src/web/pages/search.ts +9
| @@ -810,6 +810,15 @@ const DESTRUCTIVE: { | ||
| 810 | 810 | phrase: "", |
| 811 | 811 | }), |
| 812 | 812 | }, |
| 813 | { | |
| 814 | pattern: /^\/([^/]+)\/([^/]+)\/variants\/([a-z0-9]{1,16})\/retire$/, | |
| 815 | describe: (m) => ({ | |
| 816 | verb: "Retire", | |
| 817 | title: `Retire the ${m[3]} variant?`, | |
| 818 | detail: "Its environment stops and its data is removed. The branch and its variant.json stay.", | |
| 819 | phrase: "", | |
| 820 | }), | |
| 821 | }, | |
| 813 | 822 | { |
| 814 | 823 | pattern: /^\/settings\/crew\/([^/]+)\/delete$/, |
| 815 | 824 | describe: (m) => ({ |
src/web/studio/app.ts +4 −3
| @@ -451,9 +451,10 @@ export async function studioApp( | ||
| 451 | 451 | // The rest of the app's environment — its variables, hostname, access and |
| 452 | 452 | // domains — is the admin's, on the app's settings page. |
| 453 | 453 | // Issues, Pulls and the map are the head's own tabs; only what it lacks is here. |
| 454 | const more = canAdmin(db, repo, ctx.auth.user) | |
| 455 | ? html`<p class="muted small"><a href="${base}/settings/app">Environment settings</a></p>` | |
| 456 | : null; | |
| 454 | const more = html`<p class="muted small"> | |
| 455 | <a href="${base}/variants">Variants</a> | |
| 456 | ${when(canAdmin(db, repo, ctx.auth.user), () => html`<a href="${base}/settings/app">Environment settings</a>`)} | |
| 457 | </p>`; | |
| 457 | 458 | |
| 458 | 459 | // An app that follows another says so before anything else: whether it |
| 459 | 460 | // is in step is the first thing its owner wants to know. |
test/app-variants.test.ts added +360
| @@ -0,0 +1,360 @@ | ||
| 1 | /** | |
| 2 | * Variants (`actions/app_variants.ts`): a branch kept running as an | |
| 3 | * environment of its own, with values it carries in `variant.json`. Two | |
| 4 | * variants of one app run side by side and only the one carrying | |
| 5 | * `PRINT=true` prints; the board shows each with its values and last | |
| 6 | * output; a variant outlives a reconcile that reaps previews of closed | |
| 7 | * pulls; a reader, an agent and a mirror may change nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm, symlink } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { enableApp } from "../src/actions/apps.ts"; | |
| 16 | import { makeMain, registerVariant, retireVariant, setVariantValues, variantBoard } from "../src/actions/app_variants.ts"; | |
| 17 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 18 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 19 | import { envVariant, envWords, variantEnv } from "../src/apps/data.ts"; | |
| 20 | import { parseAppHost, resolveAppHostname } from "../src/apps/proxy.ts"; | |
| 21 | import { parseAppLogin } from "../src/apps/tty.ts"; | |
| 22 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 23 | import { checkValue, readVariantValues, parseValueLines, parseVariantFile, readLastOutput, variantFileText } from "../src/apps/variant_file.ts"; | |
| 24 | import { isSlug, type AppRecord } from "../src/core/apps.ts"; | |
| 25 | import { listEvents } from "../src/core/events.ts"; | |
| 26 | import type { Repo } from "../src/core/repos.ts"; | |
| 27 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 28 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 29 | import { VARIANTS_MAX, labelFor, listVariants } from "../src/core/variants.ts"; | |
| 30 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 31 | import { resolveOid } from "../src/git/merge.ts"; | |
| 32 | import { repoDir } from "../src/git/repos.ts"; | |
| 33 | ||
| 34 | const DOMAIN = "apps.test"; | |
| 35 | ||
| 36 | /** Says whether it prints, and leaves the edition it made for the board. */ | |
| 37 | const SERVER = `import { writeFileSync } from "node:fs"; | |
| 38 | import { join } from "node:path"; | |
| 39 | const print = process.env.PRINT ?? "unset"; | |
| 40 | const voice = process.env.VOICE ?? "plain"; | |
| 41 | writeFileSync(join(process.env.FORGE_APP_DATA ?? ".", "last-output.json"), | |
| 42 | JSON.stringify({ title: "Edition " + voice + (print === "true" ? " (printed)" : ""), path: "/editions/1" })); | |
| 43 | Bun.serve({ | |
| 44 | port: Number(process.env.PORT ?? "0"), | |
| 45 | hostname: "127.0.0.1", | |
| 46 | fetch: () => new Response("PRINT=" + print + " VOICE=" + voice), | |
| 47 | }); | |
| 48 | `; | |
| 49 | ||
| 50 | describe("variant names", () => { | |
| 51 | test("the host, the login and the words read a variant back", () => { | |
| 52 | expect(variantEnv("print")).toBe("variant-print"); | |
| 53 | expect(envVariant("variant-print")).toBe("print"); | |
| 54 | expect(envVariant("pull-3")).toBeUndefined(); | |
| 55 | expect(parseAppHost(`show--v-print.${DOMAIN}`, DOMAIN)).toEqual({ slug: "show", env: "variant-print", nested: false }); | |
| 56 | expect(parseAppHost(`notes--show--v-print.${DOMAIN}`, DOMAIN)).toEqual({ slug: "show", env: "variant-print", nested: true }); | |
| 57 | // Not a label: a slug with a hyphen, as it was. | |
| 58 | expect(parseAppHost(`show--v-pr-nt.${DOMAIN}`, DOMAIN)?.env).toBe("production"); | |
| 59 | expect(parseAppHost(`show--v-${"a".repeat(17)}.${DOMAIN}`, DOMAIN)?.env).toBe("production"); | |
| 60 | expect(parseAppHost(`show--3.${DOMAIN}`, DOMAIN)?.env).toBe("pull-3"); | |
| 61 | expect(envWords("variant-print")).toBe("the print variant"); | |
| 62 | expect(parseAppLogin("show--v-print")).toBeUndefined(); // no terminal into a variant yet | |
| 63 | }); | |
| 64 | ||
| 65 | test("a variant-shaped slug is reserved, and a label comes from a branch name", () => { | |
| 66 | expect(isSlug("v-print")).toBe(false); | |
| 67 | expect(isSlug("vprint")).toBe(true); | |
| 68 | expect(labelFor("Feature/Print-On")).toBe("featureprinton"); | |
| 69 | expect(labelFor("///")).toBe("variant"); | |
| 70 | expect(labelFor("a".repeat(40))).toHaveLength(16); | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("variant.json", () => { | |
| 75 | test("values are plain, named like environment variables, and never the forge's", () => { | |
| 76 | expect(parseVariantFile('{"values":{"PRINT":"true"}}')).toEqual({ ok: true, value: { PRINT: "true" } }); | |
| 77 | expect(parseVariantFile("{}")).toEqual({ ok: true, value: {} }); | |
| 78 | for (const bad of [ | |
| 79 | "nope", | |
| 80 | "[]", | |
| 81 | '{"values":[]}', | |
| 82 | '{"extra":1}', | |
| 83 | '{"values":{"print":"x"}}', | |
| 84 | '{"values":{"PRINT":true}}', | |
| 85 | '{"values":{"PORT":"1"}}', | |
| 86 | '{"values":{"FORGE_TOKEN":"x"}}', | |
| 87 | '{"values":{"A":"line\\nbreak"}}', | |
| 88 | `{"values":{"A":"${"x".repeat(201)}"}}`, | |
| 89 | ]) | |
| 90 | expect(parseVariantFile(bad).ok).toBe(false); | |
| 91 | expect(checkValue("PRINT", "false")).toBeUndefined(); | |
| 92 | }); | |
| 93 | ||
| 94 | test("typed lines become values, and write back as the same bytes", () => { | |
| 95 | const parsed = parseValueLines("PRINT=true\n\n VOICE = dry \n"); | |
| 96 | expect(parsed).toEqual({ ok: true, value: { PRINT: "true", VOICE: "dry" } }); | |
| 97 | const text = variantFileText({ VOICE: "dry", PRINT: "true" }); | |
| 98 | expect(text).toBe('{\n "values": {\n "PRINT": "true",\n "VOICE": "dry"\n }\n}\n'); | |
| 99 | expect(parseVariantFile(text)).toEqual(parsed); | |
| 100 | expect(parseValueLines("no equals").ok).toBe(false); | |
| 101 | expect(parseValueLines("PORT=1").ok).toBe(false); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a variant.json that is a symlink is not followed", async () => { | |
| 105 | const dir = await mkdtemp(join(tmpdir(), "forge-link-")); | |
| 106 | const elsewhere = await mkdtemp(join(tmpdir(), "forge-elsewhere-")); | |
| 107 | try { | |
| 108 | await Bun.write(join(elsewhere, "host.json"), '{"values":{"STOLEN":"yes"}}'); | |
| 109 | await symlink(join(elsewhere, "host.json"), join(dir, "variant.json")); | |
| 110 | expect(readVariantValues(dir)).toEqual({}); | |
| 111 | await rm(join(dir, "variant.json")); | |
| 112 | await Bun.write(join(dir, "variant.json"), '{"values":{"OWN":"yes"}}'); | |
| 113 | expect(readVariantValues(dir)).toEqual({ OWN: "yes" }); | |
| 114 | } finally { | |
| 115 | await rm(dir, { recursive: true, force: true }); | |
| 116 | await rm(elsewhere, { recursive: true, force: true }); | |
| 117 | } | |
| 118 | }); | |
| 119 | ||
| 120 | test("the output an app leaves is read as data, bounded", async () => { | |
| 121 | const dir = await mkdtemp(join(tmpdir(), "forge-output-")); | |
| 122 | try { | |
| 123 | expect(readLastOutput(dir)).toBeUndefined(); | |
| 124 | await Bun.write(join(dir, "last-output.json"), '{"title":"Edition 4","path":"/editions/4"}'); | |
| 125 | expect(readLastOutput(dir)).toEqual({ title: "Edition 4", path: "/editions/4" }); | |
| 126 | for (const bad of ['{"title":"x","path":"//evil.test"}', '{"title":"x","path":"javascript:1"}', '{"title":""}', "nope"]) { | |
| 127 | await Bun.write(join(dir, "last-output.json"), bad); | |
| 128 | expect(readLastOutput(dir)).toBeUndefined(); | |
| 129 | } | |
| 130 | } finally { | |
| 131 | await rm(dir, { recursive: true, force: true }); | |
| 132 | } | |
| 133 | }); | |
| 134 | }); | |
| 135 | ||
| 136 | describe("variants running", () => { | |
| 137 | let dataDir: string; | |
| 138 | let app: App; | |
| 139 | let servers: Servers; | |
| 140 | let runtime: AppsRuntime; | |
| 141 | let alice: User; | |
| 142 | let bob: User; | |
| 143 | let repo: Repo; | |
| 144 | let record: AppRecord; | |
| 145 | ||
| 146 | const viaHost = async (host: string): Promise<string> => | |
| 147 | (await fetch(`${servers.url}/`, { headers: { host } })).text(); | |
| 148 | ||
| 149 | async function until<T>(read: () => T | undefined | Promise<T | undefined>, ms = 60_000): Promise<T> { | |
| 150 | const deadline = Date.now() + ms; | |
| 151 | while (Date.now() < deadline) { | |
| 152 | const value = await read(); | |
| 153 | if (value !== undefined) return value; | |
| 154 | await Bun.sleep(50); | |
| 155 | } | |
| 156 | throw new Error("did not happen in time"); | |
| 157 | } | |
| 158 | ||
| 159 | const settled = (where: (run: Run) => boolean): Promise<Run> => | |
| 160 | until(() => | |
| 161 | listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find( | |
| 162 | (r) => where(r) && (r.state === "ok" || r.state === "failed" || r.state === "cancelled"), | |
| 163 | ), | |
| 164 | ); | |
| 165 | ||
| 166 | const env = (name: string): EnvStatus | undefined => runtime.status(record).find((e) => e.env === name); | |
| 167 | ||
| 168 | const answers = (label: string, includes: string): Promise<string> => | |
| 169 | until(async () => { | |
| 170 | if (env(variantEnv(label))?.state !== "running") return undefined; | |
| 171 | const text = await viaHost(`show--v-${label}.${DOMAIN}`); | |
| 172 | return text.includes(includes) ? text : undefined; | |
| 173 | }); | |
| 174 | ||
| 175 | beforeAll(async () => { | |
| 176 | dataDir = await mkdtemp(join(tmpdir(), "forge-variants-")); | |
| 177 | app = createApp({ dataDir, port: 0 }); | |
| 178 | servers = await serve(app); | |
| 179 | runtime = new AppsRuntime(app, { domain: DOMAIN, scheme: "http", publicPort: servers.port, forgeUrl: servers.url }); | |
| 180 | await runtime.start(); | |
| 181 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 182 | if (!a.ok) throw new Error(a.error); | |
| 183 | alice = a.value; | |
| 184 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 185 | if (!b.ok) throw new Error(b.error); | |
| 186 | bob = b.value; | |
| 187 | const made = await createRepo(app, alice, { name: "show", visibility: "public" }); | |
| 188 | if (!made.ok) throw new Error(String(made.error)); | |
| 189 | repo = made.value; | |
| 190 | const c1 = await commitToBranch(app, alice, repo, { | |
| 191 | message: "the paper", | |
| 192 | files: [ | |
| 193 | { path: "package.json", content: JSON.stringify({ name: "show", scripts: { start: "bun server.ts" } }) }, | |
| 194 | { path: "server.ts", content: SERVER }, | |
| 195 | ], | |
| 196 | }); | |
| 197 | if (!c1.ok) throw new Error(JSON.stringify(c1.error)); | |
| 198 | for (const [branch, files] of [ | |
| 199 | ["printing", [{ path: "variant.json", content: variantFileText({ PRINT: "true", VOICE: "dry" }) }]], | |
| 200 | ["plain", [{ path: "variant.json", content: variantFileText({ PRINT: "false", VOICE: "warm" }) }]], | |
| 201 | ] as const) { | |
| 202 | const c = await commitToBranch(app, alice, repo, { branch, base: "main", message: `${branch} voice`, files: [...files] }); | |
| 203 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 204 | } | |
| 205 | const enabled = enableApp(app, alice, repo, {}); | |
| 206 | if (!enabled.ok) throw new Error(enabled.error); | |
| 207 | record = enabled.value; | |
| 208 | const production = await settled((r) => r.env === "production"); | |
| 209 | if (production.state !== "ok") throw new Error(production.summary); | |
| 210 | }, 90_000); | |
| 211 | ||
| 212 | afterAll(async () => { | |
| 213 | await runtime.stop(); | |
| 214 | servers.stop(); | |
| 215 | app.close(); | |
| 216 | await rm(dataDir, { recursive: true, force: true }); | |
| 217 | }); | |
| 218 | ||
| 219 | test("a reader, an agent and a missing branch change nothing", async () => { | |
| 220 | const reader = await registerVariant(app, bob, repo, { branch: "printing" }); | |
| 221 | expect(reader.ok ? undefined : reader.error).toBe("forbidden"); | |
| 222 | const anonymous = await registerVariant(app, undefined, repo, { branch: "printing" }); | |
| 223 | expect(anonymous.ok ? undefined : anonymous.error).toBe("unauthorized"); | |
| 224 | const made = await createAgent(app, alice, { username: "scout", brief: "I ship." }); | |
| 225 | if (!made.ok) throw new Error(String(made.error)); | |
| 226 | expect(addToCrew(app, alice, repo, "scout").ok).toBe(true); | |
| 227 | const agent = await registerVariant(app, getUserByName(app.db, "scout")!, repo, { branch: "printing" }); | |
| 228 | expect(agent.ok ? undefined : agent.error).toBe("forbidden"); | |
| 229 | const missing = await registerVariant(app, alice, repo, { branch: "nope" }); | |
| 230 | expect(missing.ok ? undefined : missing.error).toBe("no_branch"); | |
| 231 | const main = await registerVariant(app, alice, repo, { branch: "main" }); | |
| 232 | expect(main.ok ? undefined : main.error).toBe("default_branch"); | |
| 233 | const flag = await registerVariant(app, alice, repo, { branch: "--upload-pack=x" }); | |
| 234 | expect(flag.ok ? undefined : flag.error).toBe("no_branch"); | |
| 235 | const label = await registerVariant(app, alice, repo, { branch: "printing", label: "Bad Label" }); | |
| 236 | expect(label.ok ? undefined : label.error).toBe("invalid_label"); | |
| 237 | expect(listVariants(app.db, record.id)).toEqual([]); | |
| 238 | expect(listEvents(app.db, { kind: "app.variant_added" })).toEqual([]); | |
| 239 | }); | |
| 240 | ||
| 241 | test("two variants run side by side and only the one with PRINT=true prints", async () => { | |
| 242 | for (const branch of ["printing", "plain"]) { | |
| 243 | const v = await registerVariant(app, alice, repo, { branch }); | |
| 244 | expect(v.ok).toBe(true); | |
| 245 | } | |
| 246 | expect(listEvents(app.db, { kind: "app.variant_added" })).toHaveLength(2); | |
| 247 | expect(await answers("printing", "PRINT=")).toBe("PRINT=true VOICE=dry"); | |
| 248 | expect(await answers("plain", "PRINT=")).toBe("PRINT=false VOICE=warm"); | |
| 249 | // Production keeps the app's defaults. | |
| 250 | expect(await viaHost(`show.${DOMAIN}`)).toBe("PRINT=unset VOICE=plain"); | |
| 251 | const again = await registerVariant(app, alice, repo, { branch: "plain", label: "other" }); | |
| 252 | expect(again.ok ? undefined : again.error).toBe("branch_taken"); | |
| 253 | }); | |
| 254 | ||
| 255 | test("the board shows each variant with its values, state and last output", async () => { | |
| 256 | await until(async () => { | |
| 257 | const b = await variantBoard(app, repo); | |
| 258 | return b?.cards.every((c) => c.output !== undefined) ? b : undefined; | |
| 259 | }); | |
| 260 | const board = (await variantBoard(app, repo))!; | |
| 261 | const printing = board.cards.find((c) => c.label === "printing")!; | |
| 262 | expect(printing.values).toEqual({ PRINT: "true", VOICE: "dry" }); | |
| 263 | expect(printing.output?.title).toBe("Edition dry (printed)"); | |
| 264 | expect(board.cards.find((c) => c.label === "plain")?.output?.title).toBe("Edition warm"); | |
| 265 | expect(board.live).toBeGreaterThanOrEqual(2); | |
| 266 | ||
| 267 | const response = await fetch(`${servers.url}/alice/show/variants`); | |
| 268 | expect(response.status).toBe(200); | |
| 269 | const text = await response.text(); | |
| 270 | expect(text).toContain("Edition dry (printed)"); | |
| 271 | expect(text).toContain("PRINT=true"); | |
| 272 | expect(text).not.toContain("Make this main"); // a stranger sees no controls | |
| 273 | expect(text).toContain("previews"); | |
| 274 | }); | |
| 275 | ||
| 276 | test("setting values commits variant.json to the branch and the variant restarts with it", async () => { | |
| 277 | const denied = await setVariantValues(app, bob, repo, "plain", "PRINT=true"); | |
| 278 | expect(denied.ok).toBe(false); | |
| 279 | const bad = await setVariantValues(app, alice, repo, "plain", "PORT=1"); | |
| 280 | expect(bad.ok ? undefined : bad.error).toEqual({ kind: "bad_values", message: expect.any(String) }); | |
| 281 | const missing = await setVariantValues(app, alice, repo, "ghost", "A=1"); | |
| 282 | expect(missing.ok ? undefined : missing.error).toBe("not_a_variant"); | |
| 283 | ||
| 284 | const saved = await setVariantValues(app, alice, repo, "plain", "PRINT=true\nVOICE=warm"); | |
| 285 | expect(saved.ok).toBe(true); | |
| 286 | expect(await answers("plain", "PRINT=true")).toBe("PRINT=true VOICE=warm"); | |
| 287 | const board = (await variantBoard(app, repo))!; | |
| 288 | expect(board.cards.find((c) => c.label === "plain")?.values).toEqual({ PRINT: "true", VOICE: "warm" }); | |
| 289 | expect(listEvents(app.db, { kind: "app.variant_values" })).toHaveLength(1); | |
| 290 | }); | |
| 291 | ||
| 292 | test("a variant is not reaped with the previews of closed pulls", async () => { | |
| 293 | await runtime.reconcileApp(record, repo, "enabled"); | |
| 294 | expect(env("variant-printing")).toBeDefined(); | |
| 295 | expect(env("variant-plain")).toBeDefined(); | |
| 296 | const order = runtime.status(record).map((e) => e.env); | |
| 297 | expect(order[0]).toBe("production"); | |
| 298 | expect(order.slice(1).sort()).toEqual(["variant-plain", "variant-printing"]); | |
| 299 | }); | |
| 300 | ||
| 301 | test("make this main opens the pull from the branch, once", async () => { | |
| 302 | const first = await makeMain(app, alice, repo, "printing"); | |
| 303 | if (!first.ok) throw new Error(String(first.error)); | |
| 304 | expect(first.value.headBranch).toBe("printing"); | |
| 305 | expect(first.value.baseBranch).toBe("main"); | |
| 306 | const second = await makeMain(app, alice, repo, "printing"); | |
| 307 | expect(second.ok && second.value.number).toBe(first.value.number); | |
| 308 | const denied = await makeMain(app, bob, repo, "printing"); | |
| 309 | expect(denied.ok).toBe(false); | |
| 310 | }); | |
| 311 | ||
| 312 | test("an app keeps a bounded number of variants", () => { | |
| 313 | expect(VARIANTS_MAX).toBeGreaterThanOrEqual(2); | |
| 314 | }); | |
| 315 | ||
| 316 | test("an app already slugged like a variant label keeps its host", async () => { | |
| 317 | const old = await createRepo(app, alice, { name: "oldy", visibility: "public" }); | |
| 318 | if (!old.ok) throw new Error(String(old.error)); | |
| 319 | app.db.query("INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES ('legacy-app', ?1, 'v-xx', 'public', 'copy')").run(old.value.id); | |
| 320 | const legacy = resolveAppHostname(app, DOMAIN, `show--v-xx.${DOMAIN}`); | |
| 321 | expect(legacy).not.toBe("unknown"); | |
| 322 | expect(legacy && legacy !== "unknown" ? [legacy.app.slug, legacy.env] : undefined).toEqual(["v-xx", "production"]); | |
| 323 | const variant = resolveAppHostname(app, DOMAIN, `show--v-printing.${DOMAIN}`); | |
| 324 | expect(variant && variant !== "unknown" ? [variant.app.slug, variant.env] : undefined).toEqual(["show", "variant-printing"]); | |
| 325 | }); | |
| 326 | ||
| 327 | test("a push to a variant's branch redeploys only for a person who can write, never an agent", async () => { | |
| 328 | const tip = (await resolveOid(repoDir(app.reposRoot, repo.ownerName, repo.name), "refs/heads/printing"))!; | |
| 329 | const asked: string[] = []; | |
| 330 | const spy = spyOn(runtime as unknown as { requestDeploy: (...a: unknown[]) => unknown }, "requestDeploy").mockImplementation((...a: unknown[]) => { | |
| 331 | if (String(a[2]).startsWith("variant-")) asked.push(String(a[2])); | |
| 332 | return undefined; | |
| 333 | }); | |
| 334 | try { | |
| 335 | const push = (actorId: string | null) => | |
| 336 | app.bus.append({ kind: "push", repoId: repo.id, ...(actorId === null ? {} : { actorId }), payload: { updates: [{ ref: "refs/heads/printing", newOid: tip }] } }); | |
| 337 | const scout = getUserByName(app.db, "scout")!; | |
| 338 | push(scout.id); | |
| 339 | push(bob.id); | |
| 340 | push(null); | |
| 341 | await Bun.sleep(300); | |
| 342 | expect(asked).toEqual([]); | |
| 343 | push(alice.id); | |
| 344 | await until(() => (asked.includes("variant-printing") ? true : undefined), 5_000); | |
| 345 | } finally { | |
| 346 | spy.mockRestore(); | |
| 347 | } | |
| 348 | }); | |
| 349 | ||
| 350 | test("retiring stops the environment and keeps the branch", async () => { | |
| 351 | const denied = await retireVariant(app, bob, repo, "plain"); | |
| 352 | expect(denied.ok).toBe(false); | |
| 353 | const gone = await retireVariant(app, alice, repo, "plain"); | |
| 354 | expect(gone.ok).toBe(true); | |
| 355 | expect(env("variant-plain")).toBeUndefined(); | |
| 356 | expect(listVariants(app.db, record.id).map((v) => v.label)).toEqual(["printing"]); | |
| 357 | const again = await retireVariant(app, alice, repo, "plain"); | |
| 358 | expect(again.ok ? undefined : again.error).toBe("not_a_variant"); | |
| 359 | }); | |
| 360 | }); |
test/tui-variants.test.ts added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | /** | |
| 2 | * The terminal's variants screen (src/tui/screens/variants.ts): the board the | |
| 3 | * page draws, behind the same gates. A reader sees the variants and no | |
| 4 | * controls; a writer registers a branch, sets and drops a value, and retires | |
| 5 | * after a confirm; an agent changes nothing. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { newApp } from "../src/actions/apps.ts"; | |
| 12 | import { variantValues } from "../src/actions/app_variants.ts"; | |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 17 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 19 | import { listVariants } from "../src/core/variants.ts"; | |
| 20 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 21 | import { repoScreen } from "../src/tui/screens/repo.ts"; | |
| 22 | import { local } from "../src/tui/screens/where.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | import { proveAll } from "./helpers/verified.ts"; | |
| 26 | ||
| 27 | const RIGHT = "\x1b[C"; | |
| 28 | const open = [":", "Variants", "\r"]; | |
| 29 | ||
| 30 | let dir: string; | |
| 31 | let app: App; | |
| 32 | let alice: User; | |
| 33 | let reader: User; | |
| 34 | let scout: User; | |
| 35 | let shop: Repo; | |
| 36 | ||
| 37 | const variants = () => listVariants(app.db, getAppByRepo(app.db, shop.id)!.id); | |
| 38 | ||
| 39 | async function drive(user: User, keys: string[], until?: string): Promise<string> { | |
| 40 | const terminal = headlessTerminal(120, 40); | |
| 41 | const { app: tui, session } = createForgeApp( | |
| 42 | app, | |
| 43 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 44 | { terminal, exitHooks: false }, | |
| 45 | ); | |
| 46 | tui.start(); | |
| 47 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, shop))); | |
| 48 | await settleApp(tui); | |
| 49 | for (const k of keys) { | |
| 50 | terminal.feed(k); | |
| 51 | await settleApp(tui); | |
| 52 | } | |
| 53 | // A save commits to the branch: wait for the note it ends in, not for a frame. | |
| 54 | for (let i = 0; until !== undefined && i < 100 && !tui.screen.toText().includes(until); i++) { | |
| 55 | await new Promise((r) => setTimeout(r, 30)); | |
| 56 | await settleApp(tui); | |
| 57 | } | |
| 58 | const text = tui.screen.toText(); | |
| 59 | tui.stop?.(); | |
| 60 | return text; | |
| 61 | } | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dir = await mkdtemp(join(tmpdir(), "forge-tui-variants-")); | |
| 65 | app = createApp({ dataDir: dir, port: 0 }); | |
| 66 | const mk = async (username: string, first = false) => { | |
| 67 | const r = await createUser(app.db, { | |
| 68 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 69 | password: "a-good-passphrase", adminIfFirst: first, | |
| 70 | }); | |
| 71 | if (!r.ok) throw new Error(r.error); | |
| 72 | return r.value; | |
| 73 | }; | |
| 74 | alice = await mk("alice", true); | |
| 75 | reader = await mk("rita"); | |
| 76 | proveAll(app); | |
| 77 | const made = await newApp(app, alice, { name: "shop", visibility: "public" }); | |
| 78 | if (!made.ok) throw new Error(String(made.error)); | |
| 79 | shop = getRepo(app.db, alice.username, "shop")!; | |
| 80 | const c = await commitToBranch(app, alice, shop, { | |
| 81 | branch: "print", base: "main", message: "print", files: [{ path: "note.txt", content: "x" }], | |
| 82 | }); | |
| 83 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 84 | const agent = await createAgent(app, alice, { username: "scout", brief: "I ship." }); | |
| 85 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 86 | expect(addToCrew(app, alice, shop, "scout").ok).toBe(true); | |
| 87 | scout = getUserByName(app.db, "scout")!; | |
| 88 | }); | |
| 89 | afterAll(async () => { | |
| 90 | app.db.close(); | |
| 91 | await rm(dir, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | describe("the terminal's variants screen", () => { | |
| 95 | test("a reader is offered the screen, sees no controls, and changes nothing", async () => { | |
| 96 | const text = await drive(reader, [...open, "r"]); | |
| 97 | expect(text).toContain("No variants yet."); | |
| 98 | expect(text).not.toContain("Press r"); | |
| 99 | expect(text).not.toContain("Run a branch as a variant"); | |
| 100 | expect(variants()).toEqual([]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("an agent is not offered the controls either", async () => { | |
| 104 | const text = await drive(scout, [...open, "r"]); | |
| 105 | expect(text).not.toContain("Run a branch as a variant"); | |
| 106 | expect(variants()).toEqual([]); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a writer registers a branch, and a missing one is refused in the page's words", async () => { | |
| 110 | const refused = await drive(alice, [...open, "r", "nope", "\r", "\r"]); | |
| 111 | expect(refused).toContain("That branch does not exist in this repository."); | |
| 112 | expect(variants()).toEqual([]); | |
| 113 | const text = await drive(alice, [...open, "r", "print", "\r", "\r"]); | |
| 114 | expect(text).toContain("Running print as the print variant."); | |
| 115 | expect(variants().map((v) => [v.label, v.branch])).toEqual([["print", "print"]]); | |
| 116 | }); | |
| 117 | ||
| 118 | test("s sets a value onto the branch's variant.json, u drops it", async () => { | |
| 119 | const text = await drive(alice, [...open, "s", "PRINT=true", "\r"], "PRINT is set for print."); | |
| 120 | expect(text).toContain("PRINT is set for print."); | |
| 121 | const v = variants()[0]!; | |
| 122 | expect(await variantValues(app, shop, v)).toEqual({ PRINT: "true" }); | |
| 123 | const bad = await drive(alice, [...open, "s", "PORT=1", "\r"]); | |
| 124 | expect(bad).toContain("PORT"); | |
| 125 | expect(await variantValues(app, shop, v)).toEqual({ PRINT: "true" }); | |
| 126 | await drive(alice, [...open, "u", "PRINT", "\r"], "PRINT is dropped from print."); | |
| 127 | expect(await variantValues(app, shop, v)).toEqual({}); | |
| 128 | }); | |
| 129 | ||
| 130 | test("m opens the pull that makes it main", async () => { | |
| 131 | const text = await drive(alice, [...open, "m"]); | |
| 132 | expect(text).toMatch(/Pull #\d+ makes print main/); | |
| 133 | }); | |
| 134 | ||
| 135 | test("x asks first; keeping it leaves it, confirming retires it", async () => { | |
| 136 | const asked = await drive(alice, [...open, "x"]); | |
| 137 | expect(asked).toContain("Retire this variant"); | |
| 138 | expect(variants().length).toBe(1); | |
| 139 | await drive(alice, [...open, "x", "\r"]); | |
| 140 | expect(variants().length).toBe(1); | |
| 141 | const text = await drive(alice, [...open, "x", RIGHT, "\r"]); | |
| 142 | expect(variants()).toEqual([]); | |
| 143 | expect(text).toContain("print is retired."); | |
| 144 | }); | |
| 145 | }); |