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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10550-variants-a-branch-is-a-living-variant-of → main 4 commits fork

  • Merged. mason/10550-variants-a-branch-is-a-living-variant-of was merged into main as 5914f4e.

Review

Sign in to review this change.
21 files changed +1524 −19
docs/design/attention-budget.json +4 −4
@@ -13,24 +13,24 @@
1313 },
1414 "repository": {
1515 "390": {
16 "words": 116,
16 "words": 117,
1717 "blocks": 18,
1818 "colours": 2
1919 },
2020 "1280": {
21 "words": 116,
21 "words": 117,
2222 "blocks": 18,
2323 "colours": 2
2424 }
2525 },
2626 "app": {
2727 "390": {
28 "words": 116,
28 "words": 117,
2929 "blocks": 18,
3030 "colours": 2
3131 },
3232 "1280": {
33 "words": 116,
33 "words": 117,
3434 "blocks": 18,
3535 "colours": 2
3636 }
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 @@
66 * the repository may look at it and open its environments.
77 */
88
9 import { getVariant } from "../core/variants.ts";
910 import { pullDir } from "../core/pulls.ts";
1011 import { getIssue } from "../core/issues.ts";
1112 import { mayView } from "../core/sealed.ts";
@@ -27,6 +28,7 @@ import type { FileWrite } from "../git/write.ts";
2728 import { TEMPLATE_ERRORS, templateFiles, type TemplateError } from "../apps/template_tip.ts";
2829 import {
2930 envBuild,
31 envVariant,
3032 isEnvName,
3133 previewEnv,
3234 type EnvName,
@@ -788,6 +790,11 @@ async function commitFor(
788790 // serves is the one the runtime holds for it, while it is held.
789791 if (envBuild(env) !== undefined)
790792 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 }
791798 const pull = getPull(app.db, repo.id, Number(env.slice(5)));
792799 if (!pull || pull.state !== "open") return undefined;
793800 const at = await pullDir(app.db, app.reposRoot, repo, pull);
src/actions/env_words.ts +3 −1
@@ -4,7 +4,7 @@
44 * terminal's deploys screen, from here.
55 */
66
7 import { envBuild, envPull, type EnvName } from "../apps/data.ts";
7 import { envBuild, envPull, envVariant, type EnvName } from "../apps/data.ts";
88 import type { Run } from "../core/runs.ts";
99 import { VERIFY_FIRST } from "./verify_words.ts";
1010
@@ -12,6 +12,8 @@ import { VERIFY_FIRST } from "./verify_words.ts";
1212 export function envLabel(env: EnvName): string {
1313 const build = envBuild(env);
1414 if (build !== undefined) return `build ${build}`;
15 const variant = envVariant(env);
16 if (variant !== undefined) return `the ${variant} variant`;
1517 const number = envPull(env);
1618 return number === undefined ? "production" : `preview of #${number}`;
1719 }
src/apps/data.ts +22 −1
@@ -38,7 +38,7 @@ import { defect } from "../kernel/error.ts";
3838 * `production`, `pull-<n>` for the preview of pull `n`, or `build-<sha7>`
3939 * for a build preview: one commit a writer asked to see now, short-lived.
4040 */
41 export type EnvName = "production" | `pull-${number}` | `build-${string}`;
41 export type EnvName = "production" | `pull-${number}` | `build-${string}` | `variant-${string}`;
4242
4343 export const previewEnv = (pullNumber: number): EnvName => `pull-${pullNumber}`;
4444
@@ -50,9 +50,26 @@ export function envBuild(env: EnvName): string | undefined {
5050 return /^build-[0-9a-f]{7}$/.test(env) ? env.slice(6) : undefined;
5151 }
5252
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
5369 export function isEnvName(value: string): value is EnvName {
5470 return (
5571 value === "production" ||
72 /^variant-[a-z0-9]{1,16}$/.test(value) ||
5673 /^pull-[1-9]\d*$/.test(value) ||
5774 /^build-[0-9a-f]{7}$/.test(value)
5875 );
@@ -73,6 +90,8 @@ export function envHostLabel(slug: string, env: EnvName): string {
7390 if (env === "production") return slug;
7491 const build = envBuild(env);
7592 if (build !== undefined) return `${slug}--b${build}`;
93 const variant = envVariant(env);
94 if (variant !== undefined) return `${slug}--v-${variant}`;
7695 return `${slug}--${env.slice(5)}`;
7796 }
7897
@@ -90,6 +109,8 @@ export function envWords(env: EnvName): string {
90109 if (env === "production") return "production";
91110 const build = envBuild(env);
92111 if (build !== undefined) return `the build at ${build}`;
112 const variant = envVariant(env);
113 if (variant !== undefined) return `the ${variant} variant`;
93114 return `the preview of #${env.slice(5)}`;
94115 }
95116
src/apps/proxy.ts +13
@@ -43,6 +43,8 @@ import { err, ok, type Result } from "../kernel/result.ts";
4343 import { loadKey } from "../kernel/secret.ts";
4444 import {
4545 buildEnvFromLabel,
46 variantEnvFromLabel,
47 envVariant,
4648 envBuild,
4749 envWords,
4850 previewEnv,
@@ -117,6 +119,10 @@ export function parseAppHost(
117119 if (build !== undefined) {
118120 return { slug: parts.at(-2)!, env: build, nested: parts.length > 2 };
119121 }
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 }
120126 if (parts.length >= 2 && /^\d+$/.test(last)) {
121127 if (!/^[1-9]\d*$/.test(last)) return undefined;
122128 return {
@@ -152,6 +158,13 @@ export function resolveAppHostname(
152158 ): AppTarget | "unknown" | undefined {
153159 const parsed = parseAppHost(host, domain);
154160 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 }
155168 const record = getAppBySlug(app.db, parsed.slug);
156169 return record && shown(app, record)
157170 ? { app: record, env: parsed.env, nested: parsed.nested }
src/apps/runtime.ts +61 −8
@@ -30,6 +30,8 @@ import type { App } from "../app.ts";
3030 import { getAppByRepo, listApps, type AppRecord } from "../core/apps.ts";
3131 import { resolveAppEnv } from "../core/app_env.ts";
3232 import { featureVarsAt } from "./feature_env.ts";
33 import { getVariant, listVariants, variantOfBranch } from "../core/variants.ts";
34 import { readVariantValues } from "./variant_file.ts";
3335 import { mintAppToken, renewAppToken, revokeAppToken } from "./token.ts";
3436 import type { ForgeEvent } from "../core/events.ts";
3537 import { announceRun } from "../actions/runs.ts";
@@ -39,12 +41,12 @@ import { getPull, listPulls, type Pull } from "../core/pulls.ts";
3941 import { isContributionFork } from "../core/contribute.ts";
4042 import { pullMayRun } from "../core/pull_trust.ts";
4143 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";
4345 import { runningLimitSentence } from "../settings/quotas.ts";
4446 import { placeFor } from "../core/placement_policy.ts";
4547 import { recordPlacement } from "../core/decisions.ts";
4648 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";
4850 import {
4951 cancelRun,
5052 getRun,
@@ -87,6 +89,8 @@ import {
8789 appsRoot,
8890 buildEnv,
8991 envBuild,
92 envVariant,
93 variantEnv,
9094 envHostLabel,
9195 ensureEnvData,
9296 envDataDir,
@@ -467,7 +471,7 @@ export function envsFor(
467471 * pull number — a number, not a string, so `pull-10` follows `pull-9`.
468472 */
469473 const envRank = (env: EnvName): number =>
470 env === "production" ? -1 : (envPull(env) ?? 0);
474 env === "production" ? -1 : envVariant(env) !== undefined ? -0.5 : (envPull(env) ?? 0);
471475 const byEnv = (a: EnvStatus, b: EnvStatus): number => envRank(a.env) - envRank(b.env);
472476
473477 export class AppsRuntime {
@@ -825,6 +829,15 @@ export class AppsRuntime {
825829 if (tip !== undefined && only !== "preview")
826830 this.requestDeploy(record, repo, "production", tip, reason);
827831 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 }
828841 // A build preview lives only in memory: one this forge is running stays,
829842 // and one an earlier life left on disk goes (`previewBuild`).
830843 for (const state of this.envs.values())
@@ -888,6 +901,15 @@ export class AppsRuntime {
888901
889902 // ---- events ----
890903
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
891913 private async onEvent(event: ForgeEvent): Promise<void> {
892914 if (this.stopped || event.repoId === null) return;
893915 if (event.kind === "repo.deleted") {
@@ -929,6 +951,19 @@ export class AppsRuntime {
929951 event.actorId ?? undefined,
930952 );
931953 }
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 );
932967 // Every open pull whose head is this branch previews the new tip.
933968 for (const pull of listPulls(this.app.db, repo.id, { sealed: true,
934969 state: "open",
@@ -1172,6 +1207,20 @@ export class AppsRuntime {
11721207 return { env, run };
11731208 }
11741209
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
11751224 private pullIssueId(repo: Repo, env: EnvName): string | undefined {
11761225 const pull = getPull(this.app.db, repo.id, Number(env.slice(5)));
11771226 return pull?.id;
@@ -1524,7 +1573,7 @@ export class AppsRuntime {
15241573 ssh: this.sshFor(state.app, state.env),
15251574 // Read at start, never cached: a change is a redeploy, and the
15261575 // 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),
15281577 feature: this.featureFor(repo, state.app.id, state.env, dir),
15291578 log: write,
15301579 warm,
@@ -1802,14 +1851,18 @@ export class AppsRuntime {
18021851 appId: string,
18031852 env: EnvName,
18041853 forgeToken: string,
1854 dir?: string,
18051855 ): { vars: Record<string, string>; forgeToken: string | undefined } {
18061856 const number = envPull(env);
18071857 const pull =
18081858 number === undefined ? undefined : getPull(this.app.db, repo.id, number);
18091859 if (pull !== undefined && guestWaits(this.app.db, pull))
18101860 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) : {};
18111864 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 },
18131866 forgeToken,
18141867 };
18151868 }
@@ -1834,7 +1887,7 @@ export class AppsRuntime {
18341887 if (isContributionFork(this.app.db, repo.id)) return false;
18351888 // Production is the default branch and a build preview a commit a writer asked to see
18361889 // (`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)
18381891 return true;
18391892 const number = envPull(env);
18401893 const pull =
@@ -1903,7 +1956,7 @@ export class AppsRuntime {
19031956 forgeUrl: this.options.forgeUrl,
19041957 identityKey: identityKey(this.app.config.dataDir, record.id),
19051958 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),
19071960 feature: this.featureFor(repo, record.id, env, asleep.dir),
19081961 log: write,
19091962 signal: new AbortController().signal,
@@ -2141,7 +2194,7 @@ export class AppsRuntime {
21412194 continue;
21422195 const window = sleepWindow(
21432196 source,
2144 state.env === "production",
2197 state.env === "production" || envVariant(state.env) !== undefined,
21452198 live.manifest.alwaysOn === true,
21462199 );
21472200 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 => ({
4747
4848 /**
4949 * 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.
5151 */
5252 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);
5454
5555 /**
5656 * 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 {
200200 branchesPage,
201201 } from "../web/pages/branches.ts";
202202 import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts";
203 import { mainSubmit, registerSubmit, retireSubmit, valuesSubmit, variantBoardPage } from "../web/pages/app_variants.ts";
203204 import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts";
204205 import { shotImage, shotsPage } from "../web/pages/build_shots.ts";
205206 import { cartArt, cartPage } from "../web/pages/build_cart.ts";
@@ -1395,6 +1396,11 @@ export function buildRouter(): Router<Handler> {
13951396 router.add("GET", "/:owner/:name/iterations", iterationsPage);
13961397 router.add("POST", "/:owner/:name/builds", pinBuildSubmit);
13971398 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);
13981404 router.add("POST", "/:owner/:name/builds/:label/delete", deletePinSubmit);
13991405 router.add("POST", "/:owner/:name/builds/:label/note", noteSubmit);
14001406 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";
150150 import { iterationsCommands } from "./iterations.ts";
151151 import { labelsCommands } from "./labels.ts";
152152 import { appEnvCommands } from "./appenv.ts";
153 import { variantsCommands } from "./variants.ts";
153154 import { autonomyCommands } from "./autonomy.ts";
154155 import { trafficCommands } from "./trafficview.ts";
155156 import { blockedCommands } from "./blocked.ts";
@@ -1633,6 +1634,7 @@ export function workspaceScreen(
16331634 ...iterationsCommands(f, ctx, repo),
16341635 ...displayCommands(f, ctx, repo),
16351636 ...appEnvCommands(f, ctx, repo),
1637 ...variantsCommands(f, ctx, repo),
16361638 ...autonomyCommands(f, ctx, repo),
16371639 ...trafficCommands(f, ctx, repo),
16381640 ...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: {
810810 phrase: "",
811811 }),
812812 },
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 },
813822 {
814823 pattern: /^\/settings\/crew\/([^/]+)\/delete$/,
815824 describe: (m) => ({
src/web/studio/app.ts +4 −3
@@ -451,9 +451,10 @@ export async function studioApp(
451451 // The rest of the app's environment — its variables, hostname, access and
452452 // domains — is the admin's, on the app's settings page.
453453 // 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>`;
457458
458459 // An app that follows another says so before anything else: whether it
459460 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs