Public: anyone can read this; the crew can change it.

An app the owner no longer cares about is archived in one tap and restored in one, starting from the parked archive branch #10890

Merged opened this

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

Merged

mason/forge:mason/10872-an-app-the-owner-no-longer-cares-about-i → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/10872-an-app-the-owner-no-longer-cares-about-i was merged into main as 86d1854.

Review

Sign in to review this change.
16 files changed +95 −38
docs/TOUR.md +10 −6
@@ -300,16 +300,20 @@ An admin puts a repository away from **Settings → Archive**, with
300300 `PATCH /api/v1/repos/:owner/:name {"archived":true|false}` (admins only; anyone
301301 else gets 403), or in the terminal with `a` on the About card (`I`). The
302302 repository page and the card then say when it was archived, and an admin gets
303 a Wake button or the same key to undo it. The archived list
304 (`/apps?archived=1`) has a Wake button on each row its viewer administers, and
305 returns to the list; the terminal wakes from the About card with the same key
303 a Restore button or the same key to undo it. One tap from an app: an admin opens the app's page, presses Archive under
304 the page, and confirms on the page that follows (`POST /:owner/:name/archive`,
305 the same `updateRepoSettings` as the Settings form and the `archive` MCP tool).
306 Production and previews stop, the app leaves home and `/apps`, and its address
307 still answers. In the terminal the door is `a` on the About card. The archived list
308 (`/apps?archived=1`) has a Restore button on each row its viewer administers, and
309 returns to the list; the terminal restores from the About card with the same key
306310 (`src/tui/screens/about.ts`). Archiving twice keeps the first date.
307311 An archived repository starts no new crew run. Assigning an issue to an agent,
308312 queueing a run again, asking the crew to propose a resolution, redirecting a
309 wave and converging a fork each answer "This repository is archived; wake it to
313 wave and converging a fork each answer "This repository is archived; restore it to
310314 start a run."; the API answers 409 `archived`, and the terminal and MCP show
311315 the same line. The autonomy tick skips it. A run already going finishes, and
312 Wake undoes all of it. Clone, push and links keep working, and the repository
316 Restore undoes all of it. Clone, push and links keep working, and the repository
313317 page still answers.
314318
315319 An archived repository leaves the web home's apps, the inbox (work assigned to
@@ -340,7 +344,7 @@ it (`undoFleetOp`). A non-admin has neither key.
340344
341345 `docs/FLEET.md` is one change across many repositories, runs or issues: a
342346 query, a dry-run plan of every row before → after, a receipt, and undo.
343 `archive` (true or false) archives or wakes many repositories; its undo is kept a day.
347 `archive` (true or false) archives or restores many repositories; its undo is kept a day.
344348
345349 ## Where it came from
346350
src/actions/repos.ts +2 −2
@@ -340,7 +340,7 @@ function inTurn<T>(app: App, repoId: string, work: () => Promise<T>): Promise<T>
340340 }
341341
342342 /**
343 * Archive (true) or wake (false), and act on it only if this call moved the
343 * Archive (true) or restore (false), and act on it only if this call moved the
344344 * repository (`moveArchived`). The write and its effect take one turn per
345345 * repository, so a Wake asked while an Archive is still stopping the app
346346 * starts it after that stop, not before it. True when this call moved it.
@@ -543,7 +543,7 @@ export interface ArchiveAnswer {
543543 }
544544
545545 /**
546 * Archive (true) or wake (false) a repository, for the MCP: the same
546 * Archive (true) or restore (false) a repository, for the MCP: the same
547547 * `updateRepoSettings` the API's PATCH goes through. A repository admin's,
548548 * never an agent's; a dry run answers what would change and changes nothing.
549549 */
src/core/repos.ts +1 −1
@@ -1325,7 +1325,7 @@ export const withoutArchived = (repos: readonly Repo[]): Repo[] =>
13251325 repos.filter((repo) => repo.archivedAt === null);
13261326
13271327 /** The one line said wherever the crew declines to start a run here. */
1328 export const ARCHIVED_WHY = "This repository is archived; wake it to start a run.";
1328 export const ARCHIVED_WHY = "This repository is archived; restore it to start a run.";
13291329
13301330 /**
13311331 * Update the mutable repo fields, leaving unspecified ones untouched.
src/http/mcp/tools/archive.ts +1 −1
@@ -43,7 +43,7 @@ export const tool: Tool = {
4343 ? `This is the plan; nothing changed. It is ${set.value.was ? "archived" : "awake"} now; send dryRun: false to ${set.value.archived ? "archive" : "wake"} it.`
4444 : set.value.archived
4545 ? "Archived: put away, not deleted."
46 : "Awake again.",
46 : "Restored.",
4747 });
4848 },
4949 };
src/http/server.ts +2
@@ -393,6 +393,7 @@ import {
393393 settingsUpdate,
394394 waveCut,
395395 archiveSubmit,
396 archiveConfirmed,
396397 transferSubmit,
397398 provenanceSubmit,
398399 mirrorStop,
@@ -1472,6 +1473,7 @@ export function buildRouter(): Router<Handler> {
14721473 router.add("POST", "/:owner/:name/settings", settingsUpdate);
14731474 router.add("POST", "/:owner/:name/settings/wave", waveCut);
14741475 router.add("POST", "/:owner/:name/settings/archive", archiveSubmit);
1476 router.add("POST", "/:owner/:name/archive", archiveConfirmed);
14751477 router.add("POST", "/:owner/:name/settings/transfer", transferSubmit);
14761478 router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit);
14771479 router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop);
src/tui/screens/about.ts +2 −2
@@ -201,7 +201,7 @@ export function aboutScreen(
201201 ? [
202202 binding(
203203 ["a"],
204 repo?.archivedAt === null ? "archive" : "wake",
204 repo?.archivedAt === null ? "archive" : "restore",
205205 ),
206206 ]
207207 : []),
@@ -485,7 +485,7 @@ export function aboutScreen(
485485 if (done.ok) {
486486 repo = getRepoById(f.db, done.value.id) ?? done.value;
487487 rows = factRows();
488 ctx.toast(waking ? "awake again" : "archived", "success");
488 ctx.toast(waking ? "restored" : "archived", "success");
489489 } else ctx.toast("that could not be changed", "danger");
490490 ctx.invalidate();
491491 });
src/tui/screens/archived.ts +7 −7
@@ -1,7 +1,7 @@
11 /**
22 * Archived repositories in the terminal: what /apps?archived=1 lists on the
33 * web. Archiving hides a repository from the dashboard, never from its
4 * readers; this screen is the way back. Enter opens the About card; `a` wakes
4 * readers; this screen is the way back. Enter opens the About card; `a` restores
55 * the selected one through `updateRepoSettings`, offered only to a person who
66 * administers it.
77 */
@@ -35,12 +35,12 @@ export function archivedScreen(f: Session, ctx: Ctx): Screen {
3535 let { repos, withApp } = archivedRepos(f);
3636 const selected = (): Repo | undefined => repos[state.index];
3737 // A person's, an admin's: the About card's gate for `a`.
38 const mayWake = (repo: Repo | undefined): repo is Repo =>
38 const mayRestore = (repo: Repo | undefined): repo is Repo =>
3939 repo !== undefined && f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.db, repo, f.user);
4040
4141 const screen: Screen = {
4242 title: "Archived repositories",
43 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "about"), binding(["a"], "wake")],
43 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "about"), binding(["a"], "restore")],
4444 render(area, s) {
4545 const c = ctx.theme.c;
4646 const [head, body] = split(area, "v", [len(1), fill(1)]);
@@ -53,7 +53,7 @@ export function archivedScreen(f: Session, ctx: Ctx): Screen {
5353 secondary: (r.archivedAt ?? "").slice(0, 10),
5454 meta: withApp.has(r.id) ? "app spun down" : "",
5555 }));
56 const line = mayWake(selected()) ? "enter opens it · a wakes it" : "enter opens it";
56 const line = mayRestore(selected()) ? "enter opens it · a restores it" : "enter opens it";
5757 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
5858 drawList(s, body!, c, state, rows);
5959 },
@@ -66,12 +66,12 @@ export function archivedScreen(f: Session, ctx: Ctx): Screen {
6666 ctx.push(aboutScreen(f, ctx, screen, local(f, repo), undefined, ""));
6767 return true;
6868 }
69 if (e.key === "a" && mayWake(repo)) {
69 if (e.key === "a" && mayRestore(repo)) {
7070 void updateRepoSettings(f.app, f.user, repo, { archived: false }).then((done) => {
7171 if (done.ok) {
7272 ({ repos, withApp } = archivedRepos(f));
7373 state.clamp(repos.length);
74 ctx.toast("awake again", "success");
74 ctx.toast("restored", "success");
7575 } else ctx.toast("that could not be changed", "danger");
7676 ctx.invalidate();
7777 });
@@ -87,7 +87,7 @@ export function archivedScreen(f: Session, ctx: Ctx): Screen {
8787 export const entry: PaletteEntry = {
8888 id: "archived",
8989 title: "Archived repositories",
90 hint: "put away, not gone: open one or wake it",
90 hint: "put away, not gone: open one or restore it",
9191 offered: (session) => session.user !== undefined,
9292 run: (session, ctx) => ctx.push(archivedScreen(session, ctx)),
9393 };
src/web/pages/apps.ts +4 −4
@@ -147,8 +147,8 @@ export function archivedAppCount(ctx: Ctx): number {
147147 ).size;
148148 }
149149
150 /** The archived list's Wake: a small form under each row the viewer administers. */
151 function wakeForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> {
150 /** The archived list's Restore: a small form under each row the viewer administers. */
151 function restoreForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> {
152152 const out = new Map<string, Html>();
153153 for (const repo of repos) {
154154 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) continue;
@@ -158,7 +158,7 @@ function wakeForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> {
158158 <form method="post" action="${repoBase(repo)}/settings/archive">
159159 <input type="hidden" name="archived" value="0" />
160160 <input type="hidden" name="to" value="/apps?archived=1" />
161 ${btn(html`Wake<span class="sr-only"> ${repo.ownerName}/${repo.name}</span>`)}
161 ${btn(html`Restore<span class="sr-only"> ${repo.ownerName}/${repo.name}</span>`)}

Principle 8 (plain words): Restore here, but Wake remains in settings.ts:413/416, repo.ts:1336 and the TUI archived screen. Use one word for the undo.

162162 </form>
163163 </li>`,
164164 );
@@ -225,7 +225,7 @@ export async function appsList(ctx: Ctx): Promise<Response> {
225225 },
226226 ])}
227227 </div>`;
228 const wake = archivedView ? wakeForms(ctx, shown.map((e) => e.repo)) : undefined;
228 const wake = archivedView ? restoreForms(ctx, shown.map((e) => e.repo)) : undefined;
229229 const rows = await timePhaseAsync(ctx.request, "shelf", () =>
230230 shelfRows(
231231 ctx,
src/web/pages/repo.ts +2 −2
@@ -1318,7 +1318,7 @@ function movedLine(ctx: Ctx, repo: Repo): Html | null {
13181318 }
13191319
13201320 /**
1321 * "Archived on <date>" with, for an admin, the Wake button. A repository put
1321 * "Archived on <date>" with, for an admin, the Restore button. A repository put
13221322 * away still clones, pushes and links as before; this only says so.
13231323 */
13241324 function archivedLine(ctx: Ctx, repo: Repo): Html | null {
@@ -1333,7 +1333,7 @@ function archivedLine(ctx: Ctx, repo: Repo): Html | null {
13331333 () => html`<form method="post" action="${repoBase(repo)}/settings/archive">
13341334 <input type="hidden" name="archived" value="0" />
13351335 <input type="hidden" name="to" value="repo" />
1336 ${btn("Wake", { size: "sm" })}
1336 ${btn("Restore", { size: "sm" })}
13371337 </form>`,
13381338 )}
13391339 </div>`;
src/web/pages/search.ts +10
@@ -564,6 +564,16 @@ const DESTRUCTIVE: {
564564 pattern: RegExp;
565565 describe: (m: RegExpMatchArray, ctx: Ctx) => Destructive;
566566 }[] = [
567 {
568 pattern: /^\/([^/]+)\/([^/]+)\/archive$/,
569 describe: (m) => ({
570 verb: "Archive",
571 title: `Archive ${m[1]}/${m[2]}?`,
572 detail:
573 "Its process stops and it leaves home and the apps list. Its data and git stay, its address still answers, and Restore on the Archived list starts it again.",
574 phrase: "",
575 }),
576 },
567577 {
568578 pattern: /^\/([^/]+)\/([^/]+)\/settings\/delete$/,
569579 describe: (m) => ({
src/web/pages/settings.ts +17 −5
@@ -409,10 +409,10 @@ export async function settingsPage(
409409 <p class="muted">
410410 ${repo.archivedAt === null
411411 ? "Put a finished repository away without deleting it. Its page says when it was archived; links, clone and push keep working."
412 : html`Archived on ${repo.archivedAt.slice(0, 10)}. Wake it to take the label off.`}
412 : html`Archived on ${repo.archivedAt.slice(0, 10)}. Restore it to take the label off.`}
413413 </p>
414414 <div class="form-actions">
415 ${btn(repo.archivedAt === null ? "Archive" : "Wake", {
415 ${btn(repo.archivedAt === null ? "Archive" : "Restore", {
416416 variant: "primary",
417417 })}
418418 </div>
@@ -545,14 +545,26 @@ function samePath(ctx: Ctx, to: string | undefined): string | undefined {
545545 }
546546
547547 /**
548 * POST /:owner/:name/settings/archive — archive or wake. The state asked
548 * POST /:owner/:name/settings/archive — archive or restore. The state asked
549549 * for travels in the form, so a double submit is idempotent.
550550 */
551551 export async function archiveSubmit(ctx: Ctx): Promise<Response> {
552 return archiveWith(ctx, undefined);
553 }
554
555 /**
556 * POST /:owner/:name/archive — the one-tap Archive, reached only through
557 * `/-/confirm`: the form there carries no field, so the answer is fixed here.
558 */
559 export async function archiveConfirmed(ctx: Ctx): Promise<Response> {
560 return archiveWith(ctx, "1");
561 }
562
563 async function archiveWith(ctx: Ctx, fixed: "1" | undefined): Promise<Response> {
552564 const gate = await adminForm(ctx);
553565 if (gate instanceof Response) return gate;
554566 const { repo, form } = gate;
555 const asked = field(form, "archived");
567 const asked = fixed ?? field(form, "archived");
556568 if (asked !== "0" && asked !== "1")
557569 return flashTo(ctx, `${repoBase(repo)}/settings`, {
558570 tone: "danger",
@@ -571,7 +583,7 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> {
571583 ctx,
572584 back,
573585 updated.ok
574 ? { message: archived ? "Archived." : "Awake again." }
586 ? { message: archived ? "Archived." : "Restored." }
575587 : { tone: "danger", message: NOT_CHANGED },
576588 );
577589 }
src/web/studio/app.ts +1
@@ -454,6 +454,7 @@ export async function studioApp(
454454 const more = html`<p class="muted small">
455455 <a href="${base}/variants">Variants</a>
456456 ${when(canAdmin(db, repo, ctx.auth.user), () => html`<a href="${base}/settings/app">Environment settings</a>`)}
457 ${when(canAdmin(db, repo, ctx.auth.user), () => html`<a href="/-/confirm?to=${encodeURIComponent(`${base}/archive`)}">Archive</a>`)}
457458 </p>`;
458459
459460 // An app that follows another says so before anything else: whether it
test/apps-archived.test.ts +29 −1
@@ -139,7 +139,7 @@ test("the archived list wakes an app in place, and ignores a return address off
139139 expect(list).toContain('action="/alice/shelved/settings/archive"');
140140 expect(list).toContain('name="to" value="/apps?archived=1"');
141141 // Each button names its app, so a screen reader can tell the Wakes apart.
142 expect(list).toMatch(/Wake<span class="sr-only"> alice\/shelved<\/span>/);
142 expect(list).toMatch(/Restore<span class="sr-only"> alice\/shelved<\/span>/);
143143 const post = (to: string, headers: Record<string, string> = { origin: servers.url }) =>
144144 fetch(`${servers.url}/alice/shelved/settings/archive`, {
145145 method: "POST",
@@ -176,3 +176,31 @@ test("a viewer who does not administer an archived app gets no Wake button", asy
176176 expect(anon).not.toContain("settings/archive");
177177 expect((await get("/apps?archived=1")).body).toContain("/alice/openshelf/settings/archive");
178178 });
179
180 test("one tap: the app's page offers Archive through the confirm page, which archives and Restore undoes", async () => {
181 const page = await get("/alice/keeper");
182 expect(page.body).toContain("/-/confirm?to=%2Falice%2Fkeeper%2Farchive");
183 const confirm = await get("/-/confirm?to=%2Falice%2Fkeeper%2Farchive");
184 expect(confirm.status).toBe(200);
185 expect(confirm.body).toContain('action="/alice/keeper/archive"');
186 const post = (headers: Record<string, string>) =>
187 fetch(`${servers.url}/alice/keeper/archive`, { method: "POST", headers: { cookie, ...headers }, redirect: "manual" });
188 expect((await post({ origin: "https://evil.example" })).status).toBeGreaterThanOrEqual(400);
189 expect(getRepo(app.db, "alice", "keeper")!.archivedAt).toBeNull();
190 expect((await post({ origin: servers.url })).status).toBe(303);
191 expect(getRepo(app.db, "alice", "keeper")!.archivedAt).not.toBeNull();
192 expect((await get("/apps")).body).not.toContain(ROW_KEEPER);
193 expect((await get("/apps?archived=1")).body).toContain(ROW_KEEPER);
194 expect((await get("/alice/keeper")).status).toBe(200);
195 // Restore is the button's own route: the same form the archived list draws.
outdated

Restore is done through updateRepoSettings, so the Restore button's route is untested for this flow. POST to /alice/keeper/settings/archive with archived=0 and assert the app is listed in /apps again, and that its process is started.

196 const restore = await fetch(`${servers.url}/alice/keeper/settings/archive`, {
197 method: "POST",
198 headers: { cookie, origin: servers.url },
199 body: new URLSearchParams({ archived: "0", to: "/apps?archived=1" }),
200 redirect: "manual",
201 });
202 expect(restore.status).toBe(303);
203 expect(getRepo(app.db, "alice", "keeper")!.archivedAt).toBeNull();
204 expect((await get("/apps")).body).toContain(ROW_KEEPER);
205 expect((await get("/apps?archived=1")).body).not.toContain(ROW_KEEPER);
206 });
test/archive.test.ts +1 −1
@@ -149,7 +149,7 @@ describe("archive and wake", () => {
149149 ).text();
150150 expect(asOwner).toContain(`Archived on`);
151151 expect(asOwner).toContain(day);
152 expect(asOwner).toContain("Wake");
152 expect(asOwner).toContain("Restore");
153153
154154 const asStranger = await (
155155 await fetch(`${base}/ada/old`, { headers: { cookie: stranger } })
test/palette-roster.test.ts +3 −3
@@ -152,7 +152,7 @@ test("a site-admin person is offered the whole roster", () => {
152152 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
153153 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
154154 ["security-controls", "Security controls", "each safeguard, and when it was last tested"],
155 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
155 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
156156 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
157157 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
158158 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
@@ -233,7 +233,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
233233 ["bugs", "Bugs", "open bugs and the bug clock"],
234234 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
235235 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
236 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
236 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
237237 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
238238 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
239239 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes, h who liked"],
@@ -310,7 +310,7 @@ test("a member person is offered the signed-in roster without the admin entries"
310310 ["can", "What I can do", "your role and what it allows here"],
311311 ["bugs", "Bugs", "open bugs and the bug clock"],
312312 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
313 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
313 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
314314 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
315315 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
316316 ["origins", "Connected accounts", "the hosts your mirrors come from"],
test/tui-archived.test.ts +3 −3
@@ -95,10 +95,10 @@ describe("archived repositories in the terminal", () => {
9595 await h.palette();
9696 // Newest archived first: bob/theirs was archived last.
9797 await h.press("j");
98 expect(h.text()).toContain("a wakes it");
98 expect(h.text()).toContain("a restores it");
9999 await h.press("a");
100100 expect(getRepo(forge.db, "carol", "shelved")!.archivedAt).toBeNull();
101 expect(h.text()).toContain("awake again");
101 expect(h.text()).toContain("restored");
102102 expect(h.text()).not.toContain("carol/shelved");
103103 h.stop();
104104 });
@@ -107,7 +107,7 @@ describe("archived repositories in the terminal", () => {
107107 const h = boot("carol");
108108 await h.palette();
109109 expect(h.text()).toContain("bob/theirs");
110 expect(h.text()).not.toContain("a wakes it");
110 expect(h.text()).not.toContain("a restores it");
111111 await h.press("a");
112112 expect(getRepo(forge.db, "bob", "theirs")!.archivedAt).not.toBeNull();
113113 h.stop();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs