Public: anyone can read this; the crew can change it.
A forge's host can see which hosted forges are in use, and archive one: expor… #12266
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12262-a-forge-s-host-can-see-which-hosted-forg → main 15 commits fork
26 files
changed +1140
−40
docs/MCP.md +1 −1
| @@ -117,7 +117,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 117 | 117 | | `forge` | — | How is my forge? What needs you, recommended; `apply` takes them | 199 | |
| 118 | 118 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 119 | 119 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 282 | |
| 120 | | `forges` | `refused` | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 537 | | |
| 120 | | `forges` | `refused` | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 629 | | |
| 121 | 121 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 122 | 122 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 440 | |
| 123 | 123 | | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 480 | |
docs/design/audit.md +1
| @@ -95,6 +95,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 95 | 95 | |
| 96 | 96 | - `/forges` — `pages/forges.ts` · the forges this one sent, as a client list · `/family` · colour 1. |
| 97 | 97 | - `/forges/refused` — `pages/forges-refused.ts` · the last 20 first asks the forge refused, with its words; an admin's only · `/system` · none seen; reuses the list and muted text. |
| 98 | - `/forges/archive` — `pages/forges.ts` · what archiving does for the forges named, and the one button · `/forges` · colour 1; reuses the list and button. | |
| 98 | 99 | - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1. |
| 99 | 100 | - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1. |
| 100 | 101 | - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1. |
docs/features/forge-archive.md added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | # Which hosted forges are in use, and archiving one | |
| 2 | ||
| 3 | For the host (a site admin). `/forges` answers "is this forge in use?" and lets you archive the ones that are not. | |
| 4 | ||
| 5 | ## What each row says | |
| 6 | ||
| 7 | - **A person last did something here**: a sign-in, a token used (a push), an account made by the claim, an issue, a comment, a run a person started. Never a heartbeat, telemetry, the engine's own ticks or what the crew did by itself. The host reads it from the forge's own tables, so a sleeping forge and one on older code answer alike (`src/spawn/person_activity.ts`, asked through `src/spawn/child_db.ts`). | |
| 8 | - **Claimed by** who, and when. | |
| 9 | - **What it costs to keep**: its data directory's size, its memory while awake, the credit it has spent. | |
| 10 | - Sort with the links above the list: needs you first, quietest first, most recent activity, costliest to keep (`?sort=quiet|recent|cost`). | |
| 11 | ||
| 12 | ## Archive and Restore | |
| 13 | ||
| 14 | Tick one or several forges and press "Archive selected", or press "Archive" on a row; the next page says what will happen. Then, for each forge, in order: | |
| 15 | ||
| 16 | 0. the destination takes and returns a small test file, so a forge is not stopped for a copy that cannot be kept; one archive or restore of a name runs at a time (a second ask is refused); | |
| 17 | 1. the forge is stopped; | |
| 18 | 2. its data directory and ledger are written as one `tar.gz` (at most 256 MiB: it is held in memory to be verified; a larger forge is refused with a sentence, asleep and untouched) and uploaded to the host's backup destination (the one on the Backups page, or `FORGE_SNAPSHOT_DIR`), under `forge-archives/<name>/`; | |
| 19 | 3. the copy is read back and compared byte for byte (sha-256). **If it does not match, or there is no destination, nothing is removed**: the forge stays, asleep (it wakes on its next request), and the local copy is kept under `<data>/archive/files/`; | |
| 20 | 4. only then the forge is removed, the local copy is deleted, and one line is left on `/forges` under **Archived** naming where the copy is. | |
| 21 | ||
| 22 | **Restore** on that line brings the forge back under the same name, from the copy (checked against its recorded hash first). It refuses if the name has been taken since, and refuses any copy that holds anything but plain files and directories under the forge's own names (the backup store is less trusted than the host). | |
| 23 | ||
| 24 | The MCP `forges` tool does the same with `archive: <name or names>` and `restore: <name>`, planned unless `dryRun: false`. The MCP archives at most five per call. In the terminal, `a` on the forges screen archives one (the dialog names where the copy goes) and `r` restores one; the terminal has no sort, the page and the MCP list do. | |
| 25 | ||
| 26 | ## Proposals | |
| 27 | ||
| 28 | A forge that nobody claimed and no person has touched for 30 days is proposed for archive in the needs-you line on the home, with "archive" as the recommended choice. **Nothing is archived by itself.** | |
| 29 | ||
| 30 | A claimed forge belongs to its claimant. After 30 untouched days the host mails them (to the address on their account there; a notice counts only when the mail was sent). That address is read from the forge's own data, which the forge writes, so it is not trusted as an identity: at most one notice is ever sent per forge, at most five per pass, and the words are this host's own, never the forge's. The forge is proposed only a week after that, and not at all if a person came back since. A host with no mail configured never proposes a claimed forge. | |
| 31 | ||
| 32 | Events: `forge.archived`, `forge.restored`, `forge.archive_noticed`. |
src/contract/openapi/routes/getApiV1Forges.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt, running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true, role: "admin" }; | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt (a person or the crew last did something there, never a heartbeat), claimedBy, claimedAt, keepBytes, archive (why it is proposed for archive, or null), running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true, role: "admin" }; |
src/core/event_audience.ts +3
| @@ -110,6 +110,9 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 110 | 110 | "forge.spawn.started", |
| 111 | 111 | "forge.spawned", |
| 112 | 112 | "forge.removed", |
| 113 | "forge.archived", | |
| 114 | "forge.restored", | |
| 115 | "forge.archive_noticed", | |
| 113 | 116 | "forge.stopped", |
| 114 | 117 | // A forge's own one line to its people: it runs in a sandbox now (`spawn/sandboxed.ts`). It names |
| 115 | 118 | // nothing. |
src/http/mcp/tools/forges.ts +22 −3
| @@ -4,19 +4,21 @@ import { REFUSED_SHOWN, listRefusals, mayReadRefusals } from "../../../core/firs | ||
| 4 | 4 | import { publicOrigin } from "../../../core/public_origin.ts"; |
| 5 | 5 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 6 | 6 | import { clientRows } from "../../../spawn/clients.ts"; |
| 7 | import { archiveForge, archivedForges, restoreForge } from "../../../spawn/archive.ts"; | |
| 8 | import { CHILD_NAME } from "../../../spawn/placement.ts"; | |
| 7 | 9 | import { checkMove } from "../../../spawn/move_check.ts"; |
| 8 | 10 | import { moveToCell } from "../../../spawn/move_cell.ts"; |
| 9 | 11 | import { good, bad, type Tool } from "../shared.ts"; |
| 10 | 12 | |
| 11 | 13 | export const tool: Tool = { |
| 12 | 14 | name: "forges", |
| 13 | doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity, reachable, size, use; `{ forge, plan?, dryRun? }` reads a forge's plan (small|medium|large, status, source, changes) or, with `plan`, grants it by hand; `{ forge, sandbox: true }` reads what moving a forge out of this forge's own process into a sandbox would meet (hooks and command-running config keys by name, size) and with `dryRun: false` moves it, proven and put back on any failure (`acknowledge: true` when it was held for what its repositories hold); each forge carries `placement` and `where`; gift codes: `codes` true lists, `code` {plan, uses?, note?, expires?} makes (shown once), `revoke` id; planned unless `dryRun` is false; `kind` refused lists the last 20 refused first asks", | |
| 15 | doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity (a person or the crew, never a heartbeat), who claimed it and when, what it costs to keep (keepBytes, use, credit), archive (why it is proposed for archive), reachable, size, use; `{ forge, plan?, dryRun? }` reads a forge's plan (small|medium|large, status, source, changes) or, with `plan`, grants it by hand; `{ forge, sandbox: true }` reads what moving a forge out of this forge's own process into a sandbox would meet (hooks and command-running config keys by name, size) and with `dryRun: false` moves it, proven and put back on any failure (`acknowledge: true` when it was held for what its repositories hold); each forge carries `placement` and `where`; gift codes: `codes` true lists, `code` {plan, uses?, note?, expires?} makes (shown once), `revoke` id; planned unless `dryRun` is false; `archive` a name or names stops each, writes its data and ledger as one file to the backup destination, then removes it; `restore` a name brings an archived one back; `kind` refused lists the last 20 refused first asks", | |
| 14 | 16 | description: |
| 15 | 17 | "Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant.", |
| 16 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["refused"] }, forge: { type: "string" }, plan: { type: "string" }, sandbox: { type: "boolean" }, acknowledge: { type: "boolean" }, codes: { type: "boolean" }, code: { type: "object" }, revoke: { type: "string" }, dryRun: { type: "boolean" } } }, | |
| 18 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["refused"] }, forge: { type: "string" }, plan: { type: "string" }, codes: { type: "boolean" }, code: { type: "object" }, revoke: { type: "string" }, archive: { type: ["string", "array"], items: { type: "string" } }, restore: { type: "string" }, sandbox: { type: "boolean" }, acknowledge: { type: "boolean" }, dryRun: { type: "boolean" } } }, | |
| 17 | 19 | writes: true, |
| 18 | 20 | dryByDefault: true, |
| 19 | readsWithout: ["forge", "plan", "code", "revoke"], | |
| 21 | readsWithout: ["forge", "plan", "code", "revoke", "archive", "restore"], | |
| 20 | 22 | async run(ctx, user, args) { |
| 21 | 23 | if (!user.isAdmin) |
| 22 | 24 | return bad("forbidden", "only an admin of this forge sees the forges it sent"); |
| @@ -47,6 +49,23 @@ export const tool: Tool = { | ||
| 47 | 49 | const made = makeCode(ctx.app, user, input, publicOrigin(ctx.app.config.url, ctx.url.origin)); |
| 48 | 50 | return made.ok ? good({ code: made.value.code, link: made.value.link, says: "Shown once: the plain code is not kept." }) : no(made.error); |
| 49 | 51 | } |
| 52 | if (args["archive"] !== undefined || args["restore"] !== undefined) { | |
| 53 | const live = args["dryRun"] === false; | |
| 54 | if (live && !scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "archiving needs an admin token"); | |
| 55 | if (args["restore"] !== undefined) { | |
| 56 | const name = String(args["restore"]); | |
| 57 | if (!CHILD_NAME.test(name)) return bad("invalid", "restore names an archived forge"); | |
| 58 | if (!live) return good({ says: `This would restore ${name} from its archive under the same name; nothing changed. Send dryRun: false to do it.`, archived: archivedForges(ctx.app) }); | |
| 59 | const done = await restoreForge(ctx.app, name, { actorId: user.id }); | |
| 60 | return done.ok ? good({ says: done.says }) : bad("invalid", done.says); | |
| 61 | } | |
| 62 | const names = (Array.isArray(args["archive"]) ? args["archive"] : [args["archive"]]).map(String); | |
| 63 | if (names.length === 0 || names.length > 5 || !names.every((n) => CHILD_NAME.test(n))) return bad("invalid", "archive names one to five forges"); | |
| 64 | if (!live) return good({ says: `This would stop ${names.join(", ")}, write each one's data and ledger as one file to the backup destination, then remove it; nothing changed. Send dryRun: false to do it.` }); | |
| 65 | const says: string[] = []; | |
| 66 | for (const name of names) says.push((await archiveForge(ctx.app, name, { actorId: user.id })).says); | |
| 67 | return good({ says: says.join(" · ") }); | |
| 68 | } | |
| 50 | 69 | // Before a forge in this forge's own process is moved into a sandbox: what the move would meet, |
| 51 | 70 | // names only. |
| 52 | 71 | if (args["sandbox"] === true) { |
src/http/route_marks.ts +2
| @@ -219,6 +219,8 @@ export const ROUTES: Readonly<Record<string, Mark>> = { | ||
| 219 | 219 | "/forges/:name/reach/revoke": "admin", |
| 220 | 220 | "/forges/:name/own-code": "admin", |
| 221 | 221 | "/forges/:name/remove": "admin", |
| 222 | "/forges/:name/restore": "admin", | |
| 223 | "/forges/archive": "admin", | |
| 222 | 224 | "/forges/:name/ready": "open", |
| 223 | 225 | "/forges/new": "open", |
| 224 | 226 | "/forges/start": "open", |
src/http/server.ts +4 −1
| @@ -296,7 +296,7 @@ import { forgeName } from "../web/pages/forge-name.ts"; | ||
| 296 | 296 | import { claimFragmentPage, claimOptions, claimSubmit, welcomePage, welcomeSubmit } from "../web/pages/claim.ts"; |
| 297 | 297 | import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts"; |
| 298 | 298 | import { familyPage } from "../web/pages/family.ts"; |
| 299 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, | |
| 299 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeArchiveConfirm, forgeArchiveSubmit, forgeRestoreSubmit, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, | |
| 300 | 300 | forgeOwnCodeSubmit, forgeSandboxSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 301 | 301 | import { forgesRefusedPage } from "../web/pages/forges-refused.ts"; |
| 302 | 302 | import { forgeReachAllow, forgeReachLater, forgeReachRevoke } from "../web/pages/hosted-forges.ts"; |
| @@ -899,6 +899,9 @@ export function buildRouter(): Router<Handler> { | ||
| 899 | 899 | // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts). |
| 900 | 900 | router.add("GET", "/forges", forgesPage); |
| 901 | 901 | router.add("GET", "/forges/refused", forgesRefusedPage); |
| 902 | router.add("GET", "/forges/archive", forgeArchiveConfirm); | |
| 903 | router.add("POST", "/forges/archive", forgeArchiveSubmit); | |
| 904 | router.add("POST", "/forges/:name/restore", forgeRestoreSubmit); | |
| 902 | 905 | router.add("GET", "/family", familyPage); |
| 903 | 906 | router.add("GET", "/forges/name/:name", forgeName); |
| 904 | 907 | router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit); |
src/spawn/app_follow.ts +9 −2
| @@ -20,6 +20,7 @@ import { getFollow, listFollowing } from "../core/upstream_follow.ts"; | ||
| 20 | 20 | import { resolveOid } from "../git/merge.ts"; |
| 21 | 21 | import { repoDir } from "../git/repos.ts"; |
| 22 | 22 | import { applyAppLinks, entriesOf, reasonOf, reconcileAppLinks } from "./app_links.ts"; |
| 23 | import { noticeOwners } from "./archive.ts"; | |
| 23 | 24 | import { refreshServed } from "./clients.ts"; |
| 24 | 25 | import { hostedChildOpen, openChildren } from "./host.ts"; |
| 25 | 26 | |
| @@ -81,8 +82,14 @@ export async function followTick(app: App, boot = false): Promise<void> { | ||
| 81 | 82 | } |
| 82 | 83 | } |
| 83 | 84 | // The list /forges serves is read here, off any request: with a question to each child at a start, never after. |
| 84 | await refreshServed(app, { probe: boot }).catch((error) => | |
| 85 | app.log.warn("the forges list was not read", { reason: reasonOf(error) }), | |
| 85 | const rows = await refreshServed(app, { probe: boot }).catch((error) => { | |
| 86 | app.log.warn("the forges list was not read", { reason: reasonOf(error) }); | |
| 87 | return []; | |
| 88 | }); | |
| 89 | // A claimed forge nobody has touched for 30 days: its owner is told before it can be proposed | |
| 90 | // (#12262). | |
| 91 | await noticeOwners(app, rows, Date.now()).catch((error) => | |
| 92 | app.log.warn("an archive notice was not sent", { reason: reasonOf(error) }), | |
| 86 | 93 | ); |
| 87 | 94 | } |
| 88 | 95 |
src/spawn/archive.ts added +295
| @@ -0,0 +1,295 @@ | ||
| 1 | /** | |
| 2 | * Archive a hosted forge (#12262): stop it, write its data directory and | |
| 3 | * ledger as ONE file to the host's backup destination (kept here until the | |
| 4 | * copy is read back and matches), remove it, and leave one record under | |
| 5 | * "Archived" saying where the copy is. Restore brings it back under the same | |
| 6 | * name. Nothing is removed before the copy is verified: a failure leaves the | |
| 7 | * forge stopped and its data where it was. | |
| 8 | * | |
| 9 | * Kept under `<data>/archive/`: `<name>.json` the record, `files/` the local | |
| 10 | * copy until verified, `notices.json` when each claimant was told. | |
| 11 | */ | |
| 12 | ||
| 13 | import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"; | |
| 14 | import { rm } from "node:fs/promises"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import type { App } from "../app.ts"; | |
| 17 | import { mailerOf } from "../actions/verify.ts"; | |
| 18 | import { childDirOf, listChildren } from "../actions/spawn.ts"; | |
| 19 | import { sha256 } from "../kernel/hash.ts"; | |
| 20 | import { resolveDestination, type Destination } from "../snapshot/destination.ts"; | |
| 21 | import { childPerson } from "./child_db.ts"; | |
| 22 | import { dropServed, type ClientRow } from "./clients.ts"; | |
| 23 | import { needsNotice } from "./archive_proposal.ts"; | |
| 24 | import { removeForge, type Managed } from "./manage.ts"; | |
| 25 | import { sleepChild } from "./host.ts"; | |
| 26 | import { CHILD_NAME } from "./placement.ts"; | |
| 27 | ||
| 28 | export interface ArchiveRecord { | |
| 29 | readonly name: string; | |
| 30 | readonly bodyId: string; | |
| 31 | readonly archivedAt: string; | |
| 32 | /** The store it was written to (`host/bucket` or a directory), never a key. */ | |
| 33 | readonly where: string; | |
| 34 | readonly key: string; | |
| 35 | readonly bytes: number; | |
| 36 | readonly sha256: string; | |
| 37 | readonly for: string | null; | |
| 38 | readonly apps: readonly string[]; | |
| 39 | } | |
| 40 | ||
| 41 | /** Tests swap where the copy goes. */ | |
| 42 | export interface ArchiveOptions { | |
| 43 | readonly destination?: Destination | undefined; | |
| 44 | readonly now?: number; | |
| 45 | readonly actorId?: string | undefined; | |
| 46 | /** Tests lower the cap. */ | |
| 47 | readonly maxBytes?: number; | |
| 48 | } | |
| 49 | ||
| 50 | /** The largest archive written or restored: it is read into memory whole (twice, to verify it). */ | |
| 51 | export const ARCHIVE_MAX_BYTES = 256 * 1_048_576; | |
| 52 | ||
| 53 | /** Forges being archived or restored now, by name: a second ask for one is refused, not raced. */ | |
| 54 | const busy = new Set<string>(); | |
| 55 | ||
| 56 | const root = (app: App): string => join(app.config.dataDir, "archive"); | |
| 57 | const recordPath = (app: App, name: string): string => join(root(app), `${name}.json`); | |
| 58 | const BODY_ID = /^[A-Za-z0-9_-]{1,64}$/; | |
| 59 | ||
| 60 | /** The archived forges, newest first: one line each on /forges. */ | |
| 61 | export function archivedForges(app: App): ArchiveRecord[] { | |
| 62 | let files: string[]; | |
| 63 | try { | |
| 64 | files = readdirSync(root(app)); | |
| 65 | } catch { | |
| 66 | return []; | |
| 67 | } | |
| 68 | const out: ArchiveRecord[] = []; | |
| 69 | for (const f of files.filter((f) => f.endsWith(".json") && f !== "notices.json")) { | |
| 70 | try { | |
| 71 | const r = JSON.parse(readFileSync(join(root(app), f), "utf8")) as ArchiveRecord; | |
| 72 | if (CHILD_NAME.test(r.name) && BODY_ID.test(r.bodyId) && typeof r.key === "string") out.push(r); | |
| 73 | } catch {} | |
| 74 | } | |
| 75 | return out.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt)); | |
| 76 | } | |
| 77 | ||
| 78 | async function tar(args: readonly string[]): Promise<{ ok: boolean; out: string; err: string }> { | |
| 79 | const p = Bun.spawn(["tar", ...args], { stdout: "pipe", stderr: "pipe" }); | |
| 80 | const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]); | |
| 81 | return { ok: (await p.exited) === 0, out, err: err.trim().slice(0, 200) }; | |
| 82 | } | |
| 83 | ||
| 84 | /** Where an archive would be written, in words: the store, or that there is none. */ | |
| 85 | export function destinationWords(app: App): string { | |
| 86 | const d = resolveDestination({ db: app.db, dataDir: app.config.dataDir, bus: app.bus }); | |
| 87 | return d === undefined ? "a backup destination (none is set)" : d.store.where; | |
| 88 | } | |
| 89 | ||
| 90 | export async function archiveForge(app: App, name: string, options: ArchiveOptions = {}): Promise<Managed> { | |
| 91 | if (!CHILD_NAME.test(name)) return { ok: false, says: `no forge named ${name}` }; | |
| 92 | if (busy.has(name)) return { ok: false, says: `${name} is being archived or restored already` }; | |
| 93 | busy.add(name); | |
| 94 | try { | |
| 95 | return await archiveOne(app, name, options); | |
| 96 | } finally { | |
| 97 | busy.delete(name); | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | const STOPPED_SAFE = "it is stopped, wakes on its next request, and its data is untouched"; | |
| 102 | ||
| 103 | async function archiveOne(app: App, name: string, options: ArchiveOptions): Promise<Managed> { | |
| 104 | const child = (await listChildren(app)).find((c) => c.name === name); | |
| 105 | if (child === undefined) return { ok: false, says: `no forge named ${name}` }; | |
| 106 | if (!child.done) return { ok: false, says: `${name} is still being made` }; | |
| 107 | if (!BODY_ID.test(child.bodyId)) return { ok: false, says: `${name} has no data directory to archive` }; | |
| 108 | const destination = options.destination ?? resolveDestination({ db: app.db, dataDir: app.config.dataDir, bus: app.bus }); | |
| 109 | if (destination === undefined) | |
| 110 | return { ok: false, says: `${name} was not archived: this forge has no backup destination to write the copy to (set one on the Backups page)` }; | |
| 111 | const now = options.now ?? Date.now(); | |
| 112 | // The destination answers before anything is stopped: a forge is not put to sleep for a copy that | |
| 113 | // cannot be kept. | |
| 114 | try { | |
| 115 | const probe = `forge-archives/.probe-${name}`; | |
| 116 | await destination.store.put(probe, "ok"); | |
| 117 | const back = await destination.store.get(probe); | |
| 118 | await destination.store.delete(probe).catch(() => {}); | |
| 119 | if (back === undefined) throw new Error("the probe did not read back"); | |
| 120 | } catch (error) { | |
| 121 | return { ok: false, says: `${name} was not archived: ${destination.store.where} did not take a test write (${String(error).slice(0, 160)}); ${name} is running as before` }; | |
| 122 | } | |
| 123 | const stamp = new Date(now).toISOString().replace(/[-:.]/g, "").slice(0, 15); | |
| 124 | mkdirSync(join(root(app), "files"), { recursive: true }); | |
| 125 | const local = join(root(app), "files", `${name}-${stamp}.tar.gz`); | |
| 126 | // Stopped first, so nothing writes while it is read. | |
| 127 | await sleepChild(app, name); | |
| 128 | const made = await tar(["-czf", local, "-C", join(app.config.dataDir, "spawn"), `${name}.json`, "-C", join(app.config.dataDir, "children"), child.bodyId]); | |
| 129 | if (!made.ok) { | |
| 130 | await rm(local, { force: true }); | |
| 131 | return { ok: false, says: `${name} was not archived: the copy could not be written (${made.err}); ${STOPPED_SAFE}` }; | |
| 132 | } | |
| 133 | const size = statSync(local).size; | |
| 134 | const cap = options.maxBytes ?? ARCHIVE_MAX_BYTES; | |
| 135 | if (size > cap) { | |
| 136 | await rm(local, { force: true }); | |
| 137 | return { ok: false, says: `${name} was not archived: its data is ${mib(size)}, over the ${mib(cap)} one archive can hold; ${STOPPED_SAFE}` }; | |
| 138 | } | |
| 139 | const body = new Uint8Array(await Bun.file(local).arrayBuffer()); | |
| 140 | const digest = sha256(body); | |
| 141 | const key = `forge-archives/${name}/${stamp}.tar.gz`; | |
| 142 | try { | |
| 143 | await destination.store.put(key, body); | |
| 144 | const back = await destination.store.get(key); | |
| 145 | if (back === undefined || back.length !== body.length || sha256(back) !== digest) | |
| 146 | return { ok: false, says: `${name} was not archived: the copy at ${destination.store.where} did not read back the same; ${STOPPED_SAFE}, and the local copy is kept at ${local}` }; | |
| 147 | } catch (error) { | |
| 148 | return { ok: false, says: `${name} was not archived: the upload to ${destination.store.where} failed (${String(error).slice(0, 160)}); ${STOPPED_SAFE}, and the local copy is kept at ${local}` }; | |
| 149 | } | |
| 150 | const record: ArchiveRecord = { | |
| 151 | name, | |
| 152 | bodyId: child.bodyId, | |
| 153 | archivedAt: new Date(now).toISOString(), | |
| 154 | where: destination.store.where, | |
| 155 | key, | |
| 156 | bytes: body.length, | |
| 157 | sha256: digest, | |
| 158 | for: child.for, | |
| 159 | apps: child.apps, | |
| 160 | }; | |
| 161 | writeFileSync(recordPath(app, name), JSON.stringify(record)); | |
| 162 | await removeForge(app, name); | |
| 163 | await rm(local, { force: true }); | |
| 164 | forgetNotice(app, name); | |
| 165 | dropServed(app); | |
| 166 | app.bus.append({ kind: "forge.archived", ...(options.actorId === undefined ? {} : { actorId: options.actorId }), payload: { name, where: record.where, key, bytes: record.bytes } }); | |
| 167 | return { ok: true, says: `${name} is archived: ${mib(record.bytes)} written to ${record.where} (${key}), then removed here` }; | |
| 168 | } | |
| 169 | ||
| 170 | const mib = (n: number): string => `${(n / 1_048_576).toFixed(1)} MiB`; | |
| 171 | ||
| 172 | export async function restoreForge(app: App, name: string, options: ArchiveOptions = {}): Promise<Managed> { | |
| 173 | if (!CHILD_NAME.test(name)) return { ok: false, says: `no archived forge named ${name}` }; | |
| 174 | if (busy.has(name)) return { ok: false, says: `${name} is being archived or restored already` }; | |
| 175 | busy.add(name); | |
| 176 | try { | |
| 177 | return await restoreOne(app, name, options); | |
| 178 | } finally { | |
| 179 | busy.delete(name); | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | async function restoreOne(app: App, name: string, options: ArchiveOptions): Promise<Managed> { | |
| 184 | const record = archivedForges(app).find((r) => r.name === name); | |
| 185 | if (record === undefined) return { ok: false, says: `no archived forge named ${name}` }; | |
| 186 | if ((await listChildren(app)).some((c) => c.name === name)) | |
| 187 | return { ok: false, says: `${name} was not restored: a forge of that name exists now` }; | |
| 188 | const dir = childDirOf(app, record.bodyId); | |
| 189 | if (existsSync(dir)) return { ok: false, says: `${name} was not restored: its data directory is taken` }; | |
| 190 | const destination = options.destination ?? resolveDestination({ db: app.db, dataDir: app.config.dataDir, bus: app.bus }); | |
| 191 | if (destination === undefined) return { ok: false, says: `${name} was not restored: no backup destination to read the copy from` }; | |
| 192 | if (record.bytes > ARCHIVE_MAX_BYTES) return { ok: false, says: `${name} was not restored: its copy is ${mib(record.bytes)}, over the ${mib(ARCHIVE_MAX_BYTES)} one restore reads` }; | |
| 193 | let body: Uint8Array | undefined; | |
| 194 | try { | |
| 195 | body = await destination.store.get(record.key); | |
| 196 | } catch (error) { | |
| 197 | return { ok: false, says: `${name} was not restored: ${destination.store.where} did not answer (${String(error).slice(0, 160)})` }; | |
| 198 | } | |
| 199 | if (body === undefined || body.length !== record.bytes || sha256(body) !== record.sha256) | |
| 200 | return { ok: false, says: `${name} was not restored: the copy at ${destination.store.where} is missing or is not what was archived` }; | |
| 201 | const stage = join(root(app), `restoring-${name}`); | |
| 202 | await rm(stage, { recursive: true, force: true }); | |
| 203 | mkdirSync(stage, { recursive: true }); | |
| 204 | const file = join(stage, "copy.tar.gz"); | |
| 205 | const extracted = join(stage, "out"); | |
| 206 | mkdirSync(extracted); | |
| 207 | await Bun.write(file, body); | |
| 208 | // The copy comes from a store less trusted than this host: names are checked, and so is the kind | |
| 209 | // of every entry, so no link can send a later entry's write elsewhere. | |
| 210 | const listed = await tar(["-tzf", file]); | |
| 211 | const kinds = await tar(["-tvzf", file]); | |
| 212 | const entries = listed.out.split("\n").filter((l) => l !== ""); | |
| 213 | const types = kinds.out.split("\n").filter((l) => l !== ""); | |
| 214 | const plain = kinds.ok && types.length === entries.length && types.every((l) => l[0] === "-" || l[0] === "d"); | |
| 215 | const clean = listed.ok && plain && entries.length > 0 && entries.every((e) => !e.split("/").includes("..") && !e.startsWith("/") && (e === `${name}.json` || e === `${record.bodyId}/` || e.startsWith(`${record.bodyId}/`))); | |
| 216 | const out = clean ? await tar(["-xzf", file, "--no-same-owner", "-C", extracted]) : undefined; | |
| 217 | if (out === undefined || !out.ok || !existsSync(join(extracted, `${name}.json`))) { | |
| 218 | await rm(stage, { recursive: true, force: true }); | |
| 219 | return { ok: false, says: `${name} was not restored: the copy is not a forge's archive` }; | |
| 220 | } | |
| 221 | mkdirSync(join(app.config.dataDir, "children"), { recursive: true }); | |
| 222 | mkdirSync(join(app.config.dataDir, "spawn"), { recursive: true }); | |
| 223 | if (existsSync(join(extracted, record.bodyId))) renameSync(join(extracted, record.bodyId), dir); | |
| 224 | else mkdirSync(dir, { recursive: true }); | |
| 225 | renameSync(join(extracted, `${name}.json`), join(app.config.dataDir, "spawn", `${name}.json`)); | |
| 226 | await rm(stage, { recursive: true, force: true }); | |
| 227 | await rm(recordPath(app, name), { force: true }); | |
| 228 | dropServed(app); | |
| 229 | app.bus.append({ kind: "forge.restored", ...(options.actorId === undefined ? {} : { actorId: options.actorId }), payload: { name, from: record.where, key: record.key } }); | |
| 230 | return { ok: true, says: `${name} is back under the same name, restored from ${record.where}` }; | |
| 231 | } | |
| 232 | ||
| 233 | // ---- telling a claimant before their forge is proposed ---- | |
| 234 | ||
| 235 | const noticesPath = (app: App): string => join(root(app), "notices.json"); | |
| 236 | ||
| 237 | /** When each claimant was told, by forge name. */ | |
| 238 | export function readNotices(app: App): Record<string, string> { | |
| 239 | try { | |
| 240 | const v: unknown = JSON.parse(readFileSync(noticesPath(app), "utf8")); | |
| 241 | return typeof v === "object" && v !== null ? (v as Record<string, string>) : {}; | |
| 242 | } catch { | |
| 243 | return {}; | |
| 244 | } | |
| 245 | } | |
| 246 | ||
| 247 | function writeNotices(app: App, notices: Record<string, string>): void { | |
| 248 | mkdirSync(root(app), { recursive: true }); | |
| 249 | writeFileSync(noticesPath(app), JSON.stringify(notices)); | |
| 250 | } | |
| 251 | ||
| 252 | function forgetNotice(app: App, name: string): void { | |
| 253 | const n = readNotices(app); | |
| 254 | if (name in n) { | |
| 255 | delete n[name]; | |
| 256 | writeNotices(app, n); | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Tell the claimant of each claimed forge untouched for 30 days that it will be proposed for | |
| 262 | * archive in a | |
| 263 | * week, by mail to the address on their account there. A notice is kept only when the mail went: a | |
| 264 | * forge whose owner could not be told is never proposed. The address is read from the forge's own | |
| 265 | * data, which a hosted forge writes: so one mail goes per forge, ever, and at most NOTICES_PER_PASS | |
| 266 | * in a pass, and what it says is this host's own words, never the forge's. Returns the names told. | |
| 267 | */ | |
| 268 | const NOTICES_PER_PASS = 5; | |
| 269 | ||
| 270 | export async function noticeOwners(app: App, rows: readonly ClientRow[], now: number): Promise<string[]> { | |
| 271 | const notices = readNotices(app); | |
| 272 | const due = rows.filter((r) => needsNotice({ ...r, noticedAt: notices[r.name] ?? null }, now)).slice(0, NOTICES_PER_PASS); | |
| 273 | if (due.length === 0) return []; | |
| 274 | const children = await listChildren(app); | |
| 275 | const admin = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE is_admin = 1 AND is_agent = 0 ORDER BY created_at LIMIT 1").get(); | |
| 276 | if (admin === null) return []; | |
| 277 | const mailer = mailerOf(app, admin); | |
| 278 | if (mailer === undefined) return []; | |
| 279 | const told: string[] = []; | |
| 280 | for (const r of due) { | |
| 281 | const person = await childPerson(childDirOf(app, children.find((c) => c.name === r.name)?.bodyId ?? "")); | |
| 282 | if (person?.claimantEmail == null) continue; | |
| 283 | const sent = await mailer({ | |
| 284 | to: person.claimantEmail, | |
| 285 | subject: `Your forge ${r.name} will be archived`, | |
| 286 | text: `Nobody has used your forge ${r.name} for 30 days. In a week the host may archive it: its data is kept in a backup and can be restored under the same name. Sign in to ${r.url} to keep it.`, | |
| 287 | }).catch(() => false); | |
| 288 | if (!sent) continue; | |
| 289 | notices[r.name] = new Date(now).toISOString(); | |
| 290 | told.push(r.name); | |
| 291 | app.bus.append({ kind: "forge.archive_noticed", payload: { name: r.name } }); | |
| 292 | } | |
| 293 | if (told.length > 0) writeNotices(app, notices); | |
| 294 | return told; | |
| 295 | } |
src/spawn/archive_proposal.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * Which hosted forges the host is PROPOSED to archive (#12262): never done by | |
| 3 | * itself. A forge nobody has touched for 30 days and nobody claimed is | |
| 4 | * proposed at once. A claimed forge belongs to its claimant: it is proposed | |
| 5 | * only after they were told (`needsNotice`, then the notice's week), and not | |
| 6 | * if they came back since. Plain data in, plain data out. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { ClientRow } from "./clients.ts"; | |
| 10 | ||
| 11 | export const UNTOUCHED_MS = 30 * 86_400_000; | |
| 12 | /** How long a claimant has after being told before the forge is proposed. */ | |
| 13 | export const NOTICE_MS = 7 * 86_400_000; | |
| 14 | ||
| 15 | export interface ArchiveProposal { | |
| 16 | readonly why: string; | |
| 17 | /** The recommended choice, the same word the button carries. */ | |
| 18 | readonly recommended: "Archive"; | |
| 19 | /** When the claimant was told; null for a forge nobody claimed. */ | |
| 20 | readonly ownerTold: string | null; | |
| 21 | } | |
| 22 | ||
| 23 | type Facts = Pick<ClientRow, "made" | "claimed" | "lastActivityAt" | "claimedAt" | "sentAt"> & { readonly noticedAt?: string | null | undefined }; | |
| 24 | ||
| 25 | const ms = (iso: string | null | undefined): number => (iso === null || iso === undefined ? NaN : Date.parse(iso)); | |
| 26 | ||
| 27 | /** When anything last touched it: a person, else its claim, else when it was sent. */ | |
| 28 | export function touchedAt(r: Facts): number { | |
| 29 | const t = [r.lastActivityAt, r.claimedAt, r.sentAt].map(ms).filter((n) => !Number.isNaN(n)); | |
| 30 | return t.length === 0 ? NaN : Math.max(...t); | |
| 31 | } | |
| 32 | ||
| 33 | const days = (n: number): string => `${Math.floor(n / 86_400_000)} days`; | |
| 34 | ||
| 35 | export function archiveProposal(r: Facts, now: number): ArchiveProposal | null { | |
| 36 | if (!r.made || r.claimed === null) return null; | |
| 37 | const touched = touchedAt(r); | |
| 38 | if (Number.isNaN(touched) || now - touched < UNTOUCHED_MS) return null; | |
| 39 | if (r.claimed === false) | |
| 40 | return { why: `unclaimed and untouched for ${days(now - touched)}`, recommended: "Archive", ownerTold: null }; | |
| 41 | const told = ms(r.noticedAt); | |
| 42 | if (Number.isNaN(told) || told < touched || now - told < NOTICE_MS) return null; | |
| 43 | return { | |
| 44 | why: `claimed, untouched for ${days(now - touched)}, and its owner was told on ${r.noticedAt!.slice(0, 10)}`, | |
| 45 | recommended: "Archive", | |
| 46 | ownerTold: r.noticedAt!, | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * A claimed forge untouched for 30 days whose owner has not been told since: the one to tell | |
| 52 | * before it is proposed. | |
| 53 | */ | |
| 54 | export function needsNotice(r: Facts, now: number): boolean { | |
| 55 | if (!r.made || r.claimed !== true) return false; | |
| 56 | const touched = touchedAt(r); | |
| 57 | if (Number.isNaN(touched) || now - touched < UNTOUCHED_MS) return false; | |
| 58 | const told = ms(r.noticedAt); | |
| 59 | return Number.isNaN(told) || told < touched; | |
| 60 | } |
src/spawn/child_db.ts +9
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | |
| 17 | 17 | import { during } from "../perf/jobs.ts"; |
| 18 | 18 | import type { ChildAsk, ChildJob, ChildReply } from "./child_db_worker.ts"; |
| 19 | import { readingOf, type PersonReading } from "./person_activity.ts"; | |
| 19 | 20 | import type { Presented } from "./vouch_db.ts"; |
| 20 | 21 | |
| 21 | 22 | /** A request's wait for "is this session the child's": past it the request is a stranger's to the wake limits. */ |
| @@ -145,6 +146,14 @@ export async function childCrewAtWork(dir: string, now = Date.now(), ms = SWEEP_ | ||
| 145 | 146 | return (await askChild(dir, { kind: "crewAtWork", now }, ms))?.result === true; |
| 146 | 147 | } |
| 147 | 148 | |
| 149 | /** | |
| 150 | * When a person last did something on the child, and who claimed it (`person_activity.ts`); | |
| 151 | * undefined when it cannot be read in time. | |
| 152 | */ | |
| 153 | export async function childPerson(dir: string, ms = SWEEP_MS): Promise<PersonReading | undefined> { | |
| 154 | return readingOf((await askChild(dir, { kind: "personActivity" }, ms))?.result); | |
| 155 | } | |
| 156 | ||
| 148 | 157 | /** When the child's earliest paused run falls due (`cell.ts` `nextRunDue`), ms; undefined when none or unread. */ |
| 149 | 158 | export async function childNextRunDue(dir: string, ms = SWEEP_MS): Promise<number | undefined> { |
| 150 | 159 | const at = (await askChild(dir, { kind: "nextRunDue" }, ms))?.result; |
src/spawn/child_db_worker.ts +4
| @@ -11,6 +11,7 @@ import { Database } from "bun:sqlite"; | ||
| 11 | 11 | import { existsSync } from "node:fs"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | import { crewAtWork, nextRunDue } from "./child_runs.ts"; |
| 14 | import { ACTIVITY_TABLES, personReading } from "./person_activity.ts"; | |
| 14 | 15 | import { vouchedWith, type Presented } from "./vouch_db.ts"; |
| 15 | 16 | |
| 16 | 17 | declare const self: Worker; |
| @@ -21,6 +22,7 @@ export type ChildAsk = | ||
| 21 | 22 | | { kind: "nextRunDue" } |
| 22 | 23 | | { kind: "publicSlugs" } |
| 23 | 24 | | { kind: "subscription"; name: string } |
| 25 | | { kind: "personActivity" } | |
| 24 | 26 | | { kind: "counts" }; |
| 25 | 27 | |
| 26 | 28 | export type ChildJob = { id: number; dir: string; ask: ChildAsk }; |
| @@ -33,6 +35,7 @@ export const TABLES: Readonly<Record<ChildAsk["kind"], readonly string[]>> = { | ||
| 33 | 35 | nextRunDue: ["run"], |
| 34 | 36 | publicSlugs: ["app"], |
| 35 | 37 | subscription: ["subscription"], |
| 38 | personActivity: ACTIVITY_TABLES, | |
| 36 | 39 | counts: ["user", "repo", "issue"], |
| 37 | 40 | }; |
| 38 | 41 | |
| @@ -57,6 +60,7 @@ export function answerChild(dir: string, ask: ChildAsk): { result?: unknown; ref | ||
| 57 | 60 | const odd = notPlainTables(db, TABLES[ask.kind]); |
| 58 | 61 | if (odd.length > 0) return { refused: `not a plain table: ${odd.join(", ")}` }; |
| 59 | 62 | if (ask.kind === "vouch") return { result: vouchedWith(db, ask.presented, ask.now) }; |
| 63 | if (ask.kind === "personActivity") return { result: personReading(db) }; | |
| 60 | 64 | if (ask.kind === "publicSlugs") |
| 61 | 65 | return { result: db.query<{ slug: unknown }, []>("SELECT slug FROM app WHERE access = 'public' LIMIT 1000").all().map((r) => r.slug).filter((s): s is string => typeof s === "string") }; |
| 62 | 66 | if (ask.kind === "counts") { |
src/spawn/clients.ts +44 −16
| @@ -28,7 +28,11 @@ import { SIZES, type Size } from "./sizes.ts"; | ||
| 28 | 28 | import { firstScreenLine } from "../core/next-steps.ts"; |
| 29 | 29 | import { lapseFor } from "../billing/lapse.ts"; |
| 30 | 30 | import { lastStop, stopSentence } from "./stopped.ts"; |
| 31 | import { forgeUse, type ForgeUse } from "./usage.ts"; | |
| 31 | import { forgeUse, keepBytes, type ForgeUse } from "./usage.ts"; | |
| 32 | import { childPerson } from "./child_db.ts"; | |
| 33 | import { readNotices } from "./archive.ts"; | |
| 34 | import { personReading } from "./person_activity.ts"; | |
| 35 | import { archiveProposal, type ArchiveProposal } from "./archive_proposal.ts"; | |
| 32 | 36 | import { appLinkLines, type AppLinkLine } from "./app_links.ts"; |
| 33 | 37 | |
| 34 | 38 | /** What a spawned forge says about itself to anyone: nothing more. */ |
| @@ -51,7 +55,23 @@ export interface ClientRow { | ||
| 51 | 55 | /** False while the child is still being made (a resumed spawn finishes it). */ |
| 52 | 56 | readonly made: boolean; |
| 53 | 57 | readonly claimed: boolean | null; |
| 58 | /** | |
| 59 | * When a person or an agent last did something there (`person_activity.ts`), read from its own | |
| 60 | * tables: never a heartbeat or telemetry. | |
| 61 | */ | |
| 54 | 62 | readonly lastActivityAt: string | null; |
| 63 | /** Who claimed it and when; null while unclaimed or unread. */ | |
| 64 | readonly claimedBy?: string | null; | |
| 65 | readonly claimedAt?: string | null; | |
| 66 | /** | |
| 67 | * What keeping it costs on disk, bytes (its data directory's last reading); null before the | |
| 68 | * first. | |
| 69 | */ | |
| 70 | readonly keepBytes?: number | null; | |
| 71 | /** Why it is proposed for archive, when it is (`spawn/archive_proposal.ts`). */ | |
| 72 | readonly archive?: ArchiveProposal | null; | |
| 73 | /** When its claimant was told it may be archived, ISO; null when not. */ | |
| 74 | readonly noticedAt?: string | null; | |
| 55 | 75 | /** Whether its process is running now; a sleeping forge costs nothing and wakes on its next request. */ |
| 56 | 76 | readonly awake?: boolean; |
| 57 | 77 | /** Why the host stopped it, while that explains its state (spawn/stopped.ts); a sentence. */ |
| @@ -148,18 +168,8 @@ export async function forgeState( | ||
| 148 | 168 | // Its host has just proven it in a cell of its own and left the mark: the owner is told now, once |
| 149 | 169 | // (`spawn/sandboxed.ts`). |
| 150 | 170 | announceSandboxed(app); |
| 151 | // Anything that happened: an event, a tell or a change touched, a run, | |
| 152 | // an account made (the claim is one). | |
| 153 | const last = app.db | |
| 154 | .query<{ at: string | null }, []>( | |
| 155 | `SELECT MAX(at) AS at FROM ( | |
| 156 | SELECT MAX(created_at) AS at FROM event | |
| 157 | UNION ALL SELECT MAX(updated_at) FROM issue | |
| 158 | UNION ALL SELECT MAX(created_at) FROM run | |
| 159 | UNION ALL SELECT MAX(created_at) FROM user WHERE kind = 'user' AND is_agent = 0 | |
| 160 | )`, | |
| 161 | ) | |
| 162 | .get(); | |
| 171 | // What a person did, never what the forge did by itself (`person_activity.ts`). | |
| 172 | const last = { at: personReading(app.db).lastAt }; | |
| 163 | 173 | return { claimed, lastActivityAt: last?.at ?? null, link: linkOf(app.db) }; |
| 164 | 174 | } |
| 165 | 175 | |
| @@ -254,7 +264,11 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P | ||
| 254 | 264 | }), |
| 255 | 265 | ); |
| 256 | 266 | const beats = await Promise.all(children.map((c) => lastHeard(app, c.name))); |
| 267 | // Who did what there, read from each forge's own tables (asleep or awake, on any code), never | |
| 268 | // from its word. | |
| 269 | const people = await Promise.all(children.map((c) => (c.done ? childPerson(childDirOf(app, c.bodyId)) : undefined))); | |
| 257 | 270 | const links = await Promise.all(children.map((c) => (c.apps.length === 0 ? [] : appLinkLines(app, c)))); |
| 271 | const notices = readNotices(app); | |
| 258 | 272 | // Asked once, and only when a forge here is not in a cell: what its row says depends on whether |
| 259 | 273 | // this host could hold it in one. |
| 260 | 274 | const cells: CellsVerdict = children.some((c) => c.placement !== "cell") ? await cellsHere() : { ok: true }; |
| @@ -271,7 +285,11 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P | ||
| 271 | 285 | sentAt: c.createdAt, |
| 272 | 286 | made: c.done, |
| 273 | 287 | claimed: states[i]?.claimed ?? null, |
| 274 | lastActivityAt: states[i]?.lastActivityAt ?? null, | |
| 288 | lastActivityAt: people[i]?.lastAt ?? null, | |
| 289 | claimedBy: people[i]?.claimedBy ?? null, | |
| 290 | claimedAt: people[i]?.claimedAt ?? null, | |
| 291 | noticedAt: notices[c.name] ?? null, | |
| 292 | keepBytes: keepBytes(childDirOf(app, c.bodyId)) ?? null, | |
| 275 | 293 | awake: childAwake(app, c.name), |
| 276 | 294 | stopped: stoppedWords(app, c), |
| 277 | 295 | running: hostedChildApps(app, c.name) ?? null, |
| @@ -350,6 +368,16 @@ export async function servedRows(app: App): Promise<ClientRow[]> { | ||
| 350 | 368 | return have.rows; |
| 351 | 369 | } |
| 352 | 370 | |
| 371 | /** The rows the last pass read, at once, or none before a pass has; never reads a child. */ | |
| 372 | export function servedNow(app: App): readonly ClientRow[] { | |
| 373 | return served.get(app)?.rows ?? []; | |
| 374 | } | |
| 375 | ||
| 376 | /** The forges proposed for archive, from the last pass: what the needs-you line says (#12262). */ | |
| 377 | export function archiveNeeds(app: App): ClientRow[] { | |
| 378 | return servedNow(app).filter((r) => r.archive !== null && r.archive !== undefined); | |
| 379 | } | |
| 380 | ||
| 353 | 381 | /** A forge was changed here (stopped, removed, a link made): the next list is read afresh. */ |
| 354 | 382 | export function dropServed(app: App): void { |
| 355 | 383 | served.delete(app); |
| @@ -431,10 +459,10 @@ export function stageOf(r: Omit<ClientRow, "attention" | "stage">, now: number): | ||
| 431 | 459 | } |
| 432 | 460 | |
| 433 | 461 | /** Rows that need the owner first, by reason; then by last activity, newest first. */ |
| 434 | export function orderRows(rows: readonly Omit<ClientRow, "attention" | "stage">[], now: number): ClientRow[] { | |
| 462 | export function orderRows(rows: readonly Omit<ClientRow, "attention" | "stage" | "archive">[], now: number): ClientRow[] { | |
| 435 | 463 | const tier = (a: Attention | null) => (a === null ? ATTENTION_ORDER.length : ATTENTION_ORDER.indexOf(a)); |
| 436 | 464 | return rows |
| 437 | .map((r) => ({ ...r, attention: attentionOf(r as ClientRow, now), stage: stageOf(r as ClientRow, now) })) | |
| 465 | .map((r) => ({ ...r, attention: attentionOf(r as ClientRow, now), stage: stageOf(r as ClientRow, now), archive: archiveProposal(r as ClientRow, now) })) | |
| 438 | 466 | .sort( |
| 439 | 467 | (a, b) => |
| 440 | 468 | tier(a.attention) - tier(b.attention) || |
src/spawn/keep_words.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * What a hosted forge's row says about use and cost (#12262), in the same | |
| 3 | * words on /forges, the terminal and the MCP answer: when a person last did | |
| 4 | * something there, who claimed it, and what it costs to keep. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { ClientRow } from "./clients.ts"; | |
| 8 | import { usdWords } from "./credit.ts"; | |
| 9 | import { useWords } from "./usage.ts"; | |
| 10 | ||
| 11 | /** "claimed by maria on 2026-11-02"; null while it is not claimed or unread. */ | |
| 12 | export function claimedWords(r: Pick<ClientRow, "claimedBy" | "claimedAt">): string | null { | |
| 13 | if (r.claimedBy === null || r.claimedBy === undefined) return null; | |
| 14 | return r.claimedAt === null || r.claimedAt === undefined ? `claimed by ${r.claimedBy}` : `claimed by ${r.claimedBy} on ${r.claimedAt.slice(0, 10)}`; | |
| 15 | } | |
| 16 | ||
| 17 | export function bytesWords(n: number): string { | |
| 18 | if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GiB`; | |
| 19 | return `${Math.max(1, Math.round(n / 1_048_576))} MiB`; | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * "keeps 120 MiB · memory 80 MiB of 512 MiB · $3.20 of $20 credit spent"; what each costs the | |
| 24 | * host. | |
| 25 | */ | |
| 26 | export function keepWords(r: Pick<ClientRow, "keepBytes" | "use" | "awake" | "creditSpentUsd" | "creditCapUsd" | "made">): string | null { | |
| 27 | if (!r.made) return null; | |
| 28 | const parts = [ | |
| 29 | r.keepBytes === null || r.keepBytes === undefined ? "disk measuring" : `keeps ${bytesWords(r.keepBytes)}`, | |
| 30 | r.awake === true && r.use.kind === "cell" ? `memory ${useWords(r.use).memory}` : r.awake === true ? "memory shared" : "no memory (asleep)", | |
| 31 | r.creditSpentUsd === null || r.creditSpentUsd === undefined ? "no credit lent" : `${usdWords(r.creditSpentUsd)} credit spent`, | |
| 32 | ]; | |
| 33 | return parts.join(" · "); | |
| 34 | } | |
| 35 | ||
| 36 | export type ForgeSort = "attention" | "quiet" | "recent" | "cost"; | |
| 37 | export const SORT_WORDS: Record<ForgeSort, string> = { | |
| 38 | attention: "Needs you first", | |
| 39 | quiet: "Quietest first", | |
| 40 | recent: "Most recent activity", | |
| 41 | cost: "Costliest to keep", | |
| 42 | }; | |
| 43 | export const isForgeSort = (v: unknown): v is ForgeSort => typeof v === "string" && v in SORT_WORDS; | |
| 44 | ||
| 45 | const when = (r: ClientRow): number => { | |
| 46 | const t = Date.parse(r.lastActivityAt ?? ""); | |
| 47 | return Number.isNaN(t) ? 0 : t; | |
| 48 | }; | |
| 49 | ||
| 50 | /** The rows in the order asked; "attention" keeps the order they came in. */ | |
| 51 | export function sortForges(rows: readonly ClientRow[], by: ForgeSort): ClientRow[] { | |
| 52 | const out = [...rows]; | |
| 53 | if (by === "quiet") out.sort((a, b) => when(a) - when(b) || a.name.localeCompare(b.name)); | |
| 54 | else if (by === "recent") out.sort((a, b) => when(b) - when(a) || a.name.localeCompare(b.name)); | |
| 55 | else if (by === "cost") out.sort((a, b) => (b.keepBytes ?? 0) - (a.keepBytes ?? 0) || (b.creditSpentUsd ?? 0) - (a.creditSpentUsd ?? 0) || a.name.localeCompare(b.name)); | |
| 56 | return out; | |
| 57 | } |
src/spawn/person_activity.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * When a PERSON last did something on a forge, and who claimed it (#12262). | |
| 3 | * What counts: a sign-in, a token used (a push over https), an account made, | |
| 4 | * an issue or pull opened, a comment, a run started by a person. What does | |
| 5 | * not: heartbeats, telemetry, the engine's own ticks and anything the crew | |
| 6 | * (an agent) did, none of which has a person behind it. The placeholder | |
| 7 | * founder a start is born with is no one's doing: an account counts only | |
| 8 | * when it is an admin's, which is what the claim makes. Read from the | |
| 9 | * forge's own tables, so a sleeping forge and one on older code answer the | |
| 10 | * same. Plain data in, plain data out. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | ||
| 15 | export interface PersonReading { | |
| 16 | /** The newest thing a person did, ISO; null when no person ever has. */ | |
| 17 | readonly lastAt: string | null; | |
| 18 | /** The first person who is an admin there: whoever claimed it; null while unclaimed. */ | |
| 19 | readonly claimedBy: string | null; | |
| 20 | /** When that account was made, ISO. */ | |
| 21 | readonly claimedAt: string | null; | |
| 22 | /** Where to tell them, for a notice; never shown. */ | |
| 23 | readonly claimantEmail: string | null; | |
| 24 | } | |
| 25 | ||
| 26 | /** The tables the reading opens: each must be a plain table in the child's database. */ | |
| 27 | export const ACTIVITY_TABLES = ["user", "session", "token", "issue", "comment", "run"] as const; | |
| 28 | ||
| 29 | const PERSON = "u.kind = 'user' AND u.is_agent = 0"; | |
| 30 | ||
| 31 | export function personReading(db: Database): PersonReading { | |
| 32 | const last = db | |
| 33 | .query<{ at: string | null }, []>( | |
| 34 | `SELECT MAX(at) AS at FROM ( | |
| 35 | SELECT MAX(s.created_at) AS at FROM session s JOIN user u ON u.id = s.user_id WHERE ${PERSON} | |
| 36 | UNION ALL SELECT MAX(t.last_used_at) FROM token t JOIN user u ON u.id = t.user_id WHERE ${PERSON} | |
| 37 | UNION ALL SELECT MAX(i.created_at) FROM issue i JOIN user u ON u.id = i.author_id WHERE ${PERSON} | |
| 38 | UNION ALL SELECT MAX(c.created_at) FROM comment c JOIN user u ON u.id = c.author_id WHERE ${PERSON} | |
| 39 | UNION ALL SELECT MAX(r.created_at) FROM run r JOIN user u ON u.id = r.actor_id WHERE ${PERSON} | |
| 40 | UNION ALL SELECT MAX(u.created_at) FROM user u WHERE ${PERSON} AND u.is_admin = 1 | |
| 41 | )`, | |
| 42 | ) | |
| 43 | .get(); | |
| 44 | const who = db | |
| 45 | .query<{ username: string; created_at: string; email: string }, []>( | |
| 46 | `SELECT u.username, u.created_at, u.email FROM user u WHERE ${PERSON} AND u.is_admin = 1 ORDER BY u.created_at, u.id LIMIT 1`, | |
| 47 | ) | |
| 48 | .get(); | |
| 49 | return { | |
| 50 | lastAt: last?.at ?? null, | |
| 51 | claimedBy: who?.username ?? null, | |
| 52 | claimedAt: who?.created_at ?? null, | |
| 53 | claimantEmail: who?.email ?? null, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * A reading from an answer that may be anything (the worker's reply): the fields it names, or | |
| 59 | * undefined. | |
| 60 | */ | |
| 61 | export function readingOf(v: unknown): PersonReading | undefined { | |
| 62 | if (typeof v !== "object" || v === null) return undefined; | |
| 63 | const r = v as Record<string, unknown>; | |
| 64 | const s = (x: unknown): string | null => (typeof x === "string" && x !== "" ? x : null); | |
| 65 | return { lastAt: s(r["lastAt"]), claimedBy: s(r["claimedBy"]), claimedAt: s(r["claimedAt"]), claimantEmail: s(r["claimantEmail"]) }; | |
| 66 | } |
src/spawn/usage.ts +6
| @@ -57,6 +57,12 @@ function diskOf(dir: string, now: number): number | undefined { | ||
| 57 | 57 | return kept?.bytes; |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | /** | |
| 61 | * What a forge's data directory holds now, bytes: the last reading, or undefined before the first | |
| 62 | * (it is taken off the request). | |
| 63 | */ | |
| 64 | export const keepBytes = (dir: string): number | undefined => diskOf(dir, Date.now()); | |
| 65 | ||
| 60 | 66 | /** One child's use; `dir` is where its data lives, `now` the clock. */ |
| 61 | 67 | export function forgeUse(child: Child, dir?: string, now = Date.now()): ForgeUse { |
| 62 | 68 | if (child.placement !== "cell") return { kind: "shared" }; |
src/tui/screens/forges.ts +31 −3
| @@ -14,6 +14,8 @@ import { drawFacts, runAsync } from "./common.ts"; | ||
| 14 | 14 | import type { PaletteEntry } from "./entries.ts"; |
| 15 | 15 | import { clientRows } from "../../spawn/clients.ts"; |
| 16 | 16 | import { diskWords } from "../../spawn/disk_mount.ts"; |
| 17 | import { archiveForge, archivedForges, destinationWords, restoreForge } from "../../spawn/archive.ts"; | |
| 18 | import { claimedWords, keepWords } from "../../spawn/keep_words.ts"; | |
| 17 | 19 | import { changeLink, ownCode, removeForge, stopForge } from "../../spawn/manage.ts"; |
| 18 | 20 | import { moveToCell } from "../../spawn/move_cell.ts"; |
| 19 | 21 | import { readOwnCode } from "../../spawn/own_code.ts"; |
| @@ -28,7 +30,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 28 | 30 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 29 | 31 | return rows.flatMap((r): [string, string][] => [[ |
| 30 | 32 | r.name, |
| 31 | [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.made && r.where.inProcess ? r.where.says : null, r.stopped ?? null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`] | |
| 33 | [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.made && r.where.inProcess ? r.where.says : null, r.stopped ?? null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.made ? (r.lastActivityAt === null ? "no person has done anything here" : `a person last did something ${timeAgo(r.lastActivityAt)}`) : null, claimedWords(r), keepWords(r), r.archive === null || r.archive === undefined ? null : `proposed for archive: ${r.archive.why}`, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`] | |
| 32 | 34 | .filter((m): m is string => m !== null) |
| 33 | 35 | .join(" · "), |
| 34 | 36 | ], ...firstScreenLines(r), ...appLinkLines(r), ...addressLines(r)]); |
| @@ -145,7 +147,7 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[], session?: Ses | ||
| 145 | 147 | }; |
| 146 | 148 | const screen: Screen = { |
| 147 | 149 | title: "Forges you sent", |
| 148 | keys: [binding(["s"], "stop one (sleep now)"), binding(["x"], "remove one"), binding(["l"], "end a link"), binding(["u"], "undo an ended link"), binding(["o"], "own code or this forge's"), binding(["m"], "move into a sandbox"), binding(["esc"], "close")], | |
| 150 | keys: [binding(["s"], "stop one (sleep now)"), binding(["a"], "archive"), binding(["r"], "restore"), binding(["x"], "remove one"), binding(["l"], "end a link"), binding(["u"], "undo an ended link"), binding(["o"], "own code or this forge's"), binding(["m"], "move into a sandbox"), binding(["esc"], "close")], | |
| 149 | 151 | render(area, s) { |
| 150 | 152 | const c = ctx.theme.c; |
| 151 | 153 | const w = Math.min(100, Math.max(30, area.w - 6)); |
| @@ -162,7 +164,33 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[], session?: Ses | ||
| 162 | 164 | else if (e.key === "l") changeOne("revoke"); |
| 163 | 165 | else if (e.key === "u") changeOne("undo"); |
| 164 | 166 | else if (e.key === "o") switchCode(); |
| 165 | else if (e.key === "m") sandboxOne(); | |
| 167 | else if (e.key === "a" && session !== undefined) | |
| 168 | choose("Archive which forge?", (name) => | |
| 169 | ctx.push( | |
| 170 | confirmScreen(ctx, screen, { | |
| 171 | title: `Archive ${name}?`, | |
| 172 | body: [`${name} is stopped, its data and ledger are written as one file to ${destinationWords(session.app)}, then it is removed. Nothing is removed if the copy cannot be verified; restore brings it back under the same name.`], | |
| 173 | actions: [{ label: "Archive it", variant: "danger" }, { label: "Keep it" }], | |
| 174 | onChoose: (i) => { | |
| 175 | if (i === 0) void archiveForge(session.app, name, session.user === undefined ? {} : { actorId: session.user.id }).then((done) => ctx.toast(done.says, done.ok ? "success" : "danger")); | |
| 176 | }, | |
| 177 | }), | |
| 178 | ), | |
| 179 | ); | |
| 180 | else if (e.key === "r" && session !== undefined && session.user?.isAdmin === true) { | |
| 181 | const user = session.user; | |
| 182 | const items = archivedForges(session.app).map((r) => ({ id: r.name, label: r.name, hint: `archived ${r.archivedAt.slice(0, 10)}, ${(r.bytes / 1_048_576).toFixed(1)} MiB at ${r.where}` })); | |
| 183 | if (items.length === 0) { ctx.toast("No forge is archived"); return true; } | |
| 184 | ctx.push( | |
| 185 | pickerScreen(ctx, screen, { | |
| 186 | title: "Restore which forge?", | |
| 187 | items, | |
| 188 | onPick: (ids) => { | |
| 189 | if (ids[0] !== undefined) void restoreForge(session.app, ids[0], { actorId: user.id }).then((done) => ctx.toast(done.says, done.ok ? "success" : "danger")); | |
| 190 | }, | |
| 191 | }), | |
| 192 | ); | |
| 193 | } else if (e.key === "m") sandboxOne(); | |
| 166 | 194 | else if (e.key === "x" && session !== undefined) |
| 167 | 195 | choose("Remove which forge?", (name) => |
| 168 | 196 | ctx.push( |
src/web/pages/forges.ts +104 −5
| @@ -29,6 +29,9 @@ import { claimLinkBody } from "./new-forge.ts"; | ||
| 29 | 29 | import { appLinkSummary, appLinkWords } from "../../spawn/app_links.ts"; |
| 30 | 30 | import { REACH_SETTINGS, reachByForge, type ForgeReach } from "../../actions/child_reach.ts"; |
| 31 | 31 | import { forgeReachRows } from "./hosted-forges.ts"; |
| 32 | import { archiveForge, archivedForges, restoreForge, type ArchiveRecord } from "../../spawn/archive.ts"; | |
| 33 | import { claimedWords, isForgeSort, keepWords, SORT_WORDS, sortForges, type ForgeSort } from "../../spawn/keep_words.ts"; | |
| 34 | import { CHILD_NAME } from "../../spawn/placement.ts"; | |
| 32 | 35 | import { forgesMemory, memoryOverLine } from "../../spawn/memory_fit.ts"; |
| 33 | 36 | |
| 34 | 37 | |
| @@ -70,15 +73,16 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html { | ||
| 70 | 73 | r.stage === null |
| 71 | 74 | ? null |
| 72 | 75 | : html`<span data-stage="${r.stage.key}"${r.stage.resend ? html` data-resend` : null}>${r.stage.words}${r.stage.at === null ? null : html` ${time(r.stage.at)}`}</span>`, |
| 73 | r.lastActivityAt === null | |
| 74 | ? null | |
| 75 | : html`last activity ${time(r.lastActivityAt)}`, | |
| 76 | r.made ? (r.lastActivityAt === null ? html`<span data-activity>no one has done anything here yet</span>` : html`<span data-activity>a person last did something ${time(r.lastActivityAt)}</span>`) : null, | |
| 77 | claimedWords(r) === null ? null : html`<span data-claimed-by>${claimedWords(r)}</span>`, | |
| 78 | keepWords(r) === null ? null : html`<span data-keep>${keepWords(r)}</span>`, | |
| 79 | r.noticedAt === null || r.noticedAt === undefined ? null : html`<span data-noticed>owner told ${time(r.noticedAt)}</span>`, | |
| 76 | 80 | badge(r.nextStep.key === "going" ? html`going: last tell ${time(r.nextStep.lastTellAt!)}` : r.nextStep.words, { |
| 77 | 81 | variant: "outline", |
| 78 | 82 | }), |
| 79 | 83 | ].filter((m): m is Html => m !== null); |
| 80 | 84 | return row({ |
| 81 | title: html`<a href="${r.url}">${r.name}</a>`, | |
| 85 | title: html`${r.made ? html`<input type="checkbox" name="name" value="${r.name}" form="forges-archive-form" aria-label="Select ${r.name} to archive" /> ` : null}<a href="${r.url}">${r.name}</a>`, | |
| 82 | 86 | meta: html`${meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}`, |
| 83 | 87 | // Enter opens the row's first link, the forge's name. |
| 84 | 88 | nav: true, |
| @@ -86,6 +90,7 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html { | ||
| 86 | 90 | id: `forge-${r.name}`, |
| 87 | 91 | data: { forge: r.name, claimed: state }, |
| 88 | 92 | children: [ |
| 93 | ...(r.archive === null || r.archive === undefined ? [] : [row({ title: html`Proposed for archive: ${r.archive.why}. Recommended: ${r.archive.recommended}.`, trail: btn("Archive", { href: `/forges/archive?name=${r.name}`, size: "sm", variant: "primary" }), data: { proposal: "archive" } })]), | |
| 89 | 94 | // What it asked to reach and what it may: answered here, first under its name. |
| 90 | 95 | ...forgeReachRows(reach, `/forges#forge-${r.name}`), |
| 91 | 96 | ...(firstScreenOf(r) === null ? [] : [row({ title: html`${firstScreenOf(r)}`, data: { "first-screen": "" } })]), |
| @@ -128,6 +133,7 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html { | ||
| 128 | 133 | </form>` |
| 129 | 134 | : null}${r.made |
| 130 | 135 | ? html`<form method="post" action="/forges/${r.name}/stop" class="inline">${btn("Stop", { size: "sm", title: "Put it to sleep now; its next visitor wakes it" })}</form> |
| 136 | ${btn("Archive", { href: `/forges/archive?name=${r.name}`, size: "sm", title: "Saves its data and ledger as one file to the backup destination, then removes it; Restore brings it back" })} | |
| 131 | 137 | ${btn("Remove", { href: `/forges/${r.name}/remove`, size: "sm", title: "Asks first, then deletes this forge and its data for good" })}` |
| 132 | 138 | : null}`, |
| 133 | 139 | }); |
| @@ -190,7 +196,10 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 190 | 196 | const user = ctx.auth.user; |
| 191 | 197 | if (!user?.isAdmin) return notFoundPage(ctx); |
| 192 | 198 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 193 | const rows = await timePhaseAsync(ctx.request, "clients", () => servedRows(ctx.app)); | |
| 199 | const asked = ctx.url.searchParams.get("sort"); | |
| 200 | const sort: ForgeSort = isForgeSort(asked) ? asked : "attention"; | |
| 201 | const rows = sortForges(await timePhaseAsync(ctx.request, "clients", () => servedRows(ctx.app)), sort); | |
| 202 | const archived = archivedForges(ctx.app); | |
| 194 | 203 | const full = timePhase(ctx.request, "room", () => noRoom(ctx.app)); |
| 195 | 204 | // Two small tables, by name: the rows themselves come from memory, and this reads no ledger. |
| 196 | 205 | const reachOf = timePhase(ctx.request, "reach", () => reachByForge(ctx.app, user)); |
| @@ -223,11 +232,16 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 223 | 232 | </section> |
| 224 | 233 | <section class="settings-block"> |
| 225 | 234 | <h2>Forges</h2> |
| 235 | <nav class="muted small" aria-label="Sort forges" data-sorts>Sort: ${(Object.keys(SORT_WORDS) as ForgeSort[]).map((k, i) => html`${i > 0 ? " · " : ""}${k === sort ? html`<strong aria-current="true">${SORT_WORDS[k]}</strong>` : html`<a href="/forges?sort=${k}">${SORT_WORDS[k]}</a>`}`)}</nav> | |
| 236 | <form id="forges-archive-form" method="get" action="/forges/archive" class="mt"> | |
| 237 | ${btn("Archive selected", { size: "sm", title: "Pick forges with the boxes; the next page says what happens before anything does" })} | |
| 238 | </form> | |
| 226 | 239 | <ul class="list setting-list"> |
| 227 | 240 | ${rows.map((r) => forgeRow(r, Date.now(), reachOf.get(r.name)))} |
| 228 | 241 | </ul> |
| 229 | 242 | </section>`} |
| 230 | 243 | ${sealedLine(ctx)} |
| 244 | ${archivedSection(ctx, archived)} | |
| 231 | 245 | ${await ownCodeSection(ctx)} |
| 232 | 246 | <p class="mt"><a href="/fleet/graph">Fleet graph</a> <a href="${REACH_SETTINGS}">What hosted forges may reach</a>${refusedLink(ctx)}</p> |
| 233 | 247 | </div>`, |
| @@ -236,6 +250,91 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 236 | 250 | return response; |
| 237 | 251 | } |
| 238 | 252 | |
| 253 | /** One line each for what was archived: where the copy is, and the way back. */ | |
| 254 | function archivedSection(ctx: Ctx, archived: readonly ArchiveRecord[]): Html | null { | |
| 255 | const said = ctx.url.searchParams.get("archive"); | |
| 256 | const note = said === null ? null : html`<p role="status" class="muted" data-receipt>${said.slice(0, 300)}</p>`; | |
| 257 | if (archived.length === 0) return note === null ? null : html`<section class="settings-block" id="archived"><h2>Archived</h2>${note}</section>`; | |
| 258 | return html`<section class="settings-block" id="archived"> | |
| 259 | <h2>Archived</h2> | |
| 260 | ${note} | |
| 261 | <ul class="list setting-list"> | |
| 262 | ${archived.map((a) => | |
| 263 | row({ | |
| 264 | title: html`${a.name}`, | |
| 265 | meta: html`archived ${time(a.archivedAt)} · copy at ${a.where} (${a.key}) · ${a.for === null ? "no one named" : `for ${a.for}`}`, | |
| 266 | id: `archived-${a.name}`, | |
| 267 | data: { archived: a.name }, | |
| 268 | trail: html`<form method="post" action="/forges/${a.name}/restore" class="inline">${btn("Restore", { size: "sm", title: "Brings it back under the same name" })}</form>`, | |
| 269 | }), | |
| 270 | )} | |
| 271 | </ul> | |
| 272 | </section>`; | |
| 273 | } | |
| 274 | ||
| 275 | /** The names a request asked to archive: valid names of forges that exist, at most 50. */ | |
| 276 | async function namedForges(ctx: Ctx, wanted: readonly string[]): Promise<string[]> { | |
| 277 | const have = new Set((await listChildren(ctx.app)).filter((c) => c.done).map((c) => c.name)); | |
| 278 | return [...new Set(wanted)].filter((n) => CHILD_NAME.test(n) && have.has(n)).slice(0, 50); | |
| 279 | } | |
| 280 | ||
| 281 | // GET /forges/archive?name=a&name=b — what archiving does, for one or several, and the one button | |
| 282 | // that does it. | |
| 283 | export async function forgeArchiveConfirm(ctx: Ctx): Promise<Response> { | |
| 284 | const user = ctx.auth.user; | |
| 285 | if (!user?.isAdmin) return notFoundPage(ctx); | |
| 286 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 287 | const names = await namedForges(ctx, ctx.url.searchParams.getAll("name")); | |
| 288 | const rows = (await servedRows(ctx.app)).filter((r) => names.includes(r.name)); | |
| 289 | const rendering = performance.now(); | |
| 290 | const response = page({ | |
| 291 | ctx, | |
| 292 | title: "Archive forges?", | |
| 293 | body: html`<div class="narrow-wide" data-forges> | |
| 294 | ${pageHead("Archive forges?")} | |
| 295 | <section class="settings-block"> | |
| 296 | ${rows.length === 0 | |
| 297 | ? html`<p>Pick at least one forge on ${html`<a href="/forges">your forges</a>`}.</p>` | |
| 298 | : html`<h2>What archiving does</h2> | |
| 299 | <p>Each forge below is stopped, its data directory and ledger are written as one file to this forge's backup destination (and kept here until that copy is read back and matches), and then it is removed. A line stays under Archived with where the copy is, and Restore brings it back under the same name. If the copy cannot be verified, nothing is removed.</p> | |
| 300 | <ul class="list setting-list"> | |
| 301 | ${rows.map((r) => row({ title: html`${r.name}`, meta: html`${[r.lastActivityAt === null ? "no person has done anything" : "last activity " + r.lastActivityAt.slice(0, 10), claimedWords(r), keepWords(r)].filter((x) => x !== null).join(" · ")}` }))} | |
| 302 | </ul> | |
| 303 | <form method="post" action="/forges/archive" class="mt"> | |
| 304 | ${rows.map((r) => html`<input type="hidden" name="name" value="${r.name}" />`)} | |
| 305 | ${btn("Archive", { variant: "primary" })} | |
| 306 | ${btn("Keep them", { href: "/forges" })} | |
| 307 | </form>`} | |
| 308 | </section> | |
| 309 | </div>`, | |
| 310 | }); | |
| 311 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 312 | response.headers.set("cache-control", "no-store"); | |
| 313 | return response; | |
| 314 | } | |
| 315 | ||
| 316 | // POST /forges/archive — archive each named forge, one after another; the receipt says each result. | |
| 317 | export async function forgeArchiveSubmit(ctx: Ctx): Promise<Response> { | |
| 318 | const user = ctx.auth.user; | |
| 319 | if (!user?.isAdmin) return notFoundPage(ctx); | |
| 320 | const form = await readForm(ctx, "admin"); | |
| 321 | if (form instanceof Response) return form; | |
| 322 | const names = await namedForges(ctx, form.getAll("name").filter((v): v is string => typeof v === "string")); | |
| 323 | const says: string[] = []; | |
| 324 | for (const name of names) says.push((await archiveForge(ctx.app, name, { actorId: user.id })).says); | |
| 325 | return redirect(`/forges?archive=${encodeURIComponent(says.length === 0 ? "No forge was named." : says.join(" · "))}#archived`); | |
| 326 | } | |
| 327 | ||
| 328 | // POST /forges/:name/restore — bring an archived forge back under its name. | |
| 329 | export async function forgeRestoreSubmit(ctx: Ctx): Promise<Response> { | |
| 330 | const user = ctx.auth.user; | |
| 331 | if (!user?.isAdmin) return notFoundPage(ctx); | |
| 332 | const form = await readForm(ctx, "admin"); | |
| 333 | if (form instanceof Response) return form; | |
| 334 | const done = await restoreForge(ctx.app, ctx.params["name"] ?? "", { actorId: user.id }); | |
| 335 | return redirect(`/forges?archive=${encodeURIComponent(done.says)}#archived`); | |
| 336 | } | |
| 337 | ||
| 239 | 338 | // POST /forges/:name/claim-link — a fresh link for an unclaimed child; the |
| 240 | 339 | // old one stops working. Shown once, in this answer only. |
| 241 | 340 | export async function forgeLinkSubmit(ctx: Ctx): Promise<Response> { |
src/web/pages/home_flow.ts +21 −2
| @@ -62,6 +62,7 @@ import { requestsCard } from "../requests_card.ts"; | ||
| 62 | 62 | import { askForEveryday } from "../../core/everyday.ts"; |
| 63 | 63 | import type { ReachAsk } from "../../core/child_reach.ts"; |
| 64 | 64 | import { reachNeeds } from "../../spawn/child_reach.ts"; |
| 65 | import { archiveNeeds, type ClientRow } from "../../spawn/clients.ts"; | |
| 65 | 66 | import { reachNeed } from "./hosted-forges.ts"; |
| 66 | 67 | import { inProcessNeed, whereOf } from "../../spawn/where.ts"; |
| 67 | 68 | import { untoldAnswer, markTold } from "../../core/requests.ts"; |
| @@ -258,6 +259,8 @@ interface StripViewer { | ||
| 258 | 259 | readonly everyday?: boolean; |
| 259 | 260 | /** What hosted forges asked to reach and no admin has answered (`spawn/child_reach.ts`); an admin's only. */ |
| 260 | 261 | readonly reach?: readonly ReachAsk[]; |
| 262 | /** Hosted forges proposed for archive (`spawn/archive_proposal.ts`); an admin's only. */ | |
| 263 | readonly archive?: readonly ClientRow[]; | |
| 261 | 264 | /** Forges that run inside this forge's own process and could be in a sandbox; an admin's only. */ |
| 262 | 265 | readonly inProcess?: number; |
| 263 | 266 | } |
| @@ -265,6 +268,20 @@ interface StripViewer { | ||
| 265 | 268 | /** The everyday account's one quiet line: the step is on its own page, where "Keep using admin" is remembered. */ |
| 266 | 269 | const EVERYDAY_NEED: Need = { says: "You are signed in as the admin. Make an everyday account", href: "/settings/everyday", tone: "notice" }; |
| 267 | 270 | |
| 271 | /** | |
| 272 | * One line for the forges proposed for archive; its link opens the confirm page with every one | |
| 273 | * picked. | |
| 274 | */ | |
| 275 | function archiveNeed(rows: readonly ClientRow[]): Need { | |
| 276 | const names = rows.map((r) => r.name); | |
| 277 | const one = names.length === 1; | |
| 278 | return { | |
| 279 | says: `${one ? `${names[0]} is` : `${names.length} hosted forges are`} proposed for archive (${rows[0]!.archive!.why}${one ? "" : ", and more"}): archive is recommended`, | |
| 280 | href: `/forges/archive?${names.map((n) => `name=${encodeURIComponent(n)}`).join("&")}`, | |
| 281 | tone: "notice", | |
| 282 | }; | |
| 283 | } | |
| 284 | ||
| 268 | 285 | function needsOf(picture: LanePicture, viewer: StripViewer, dataDir: string, now: number): Need[] { |
| 269 | 286 | const proof = viewer.isAdmin ? proofNotice(dataDir, running().version, now) : undefined; |
| 270 | 287 | const asks = picture.asks[viewer.id]; |
| @@ -277,6 +294,8 @@ function needsOf(picture: LanePicture, viewer: StripViewer, dataDir: string, now | ||
| 277 | 294 | ...alarms, |
| 278 | 295 | // A person on a hosted forge is stopped until this is answered: first among what can wait. |
| 279 | 296 | ...(viewer.isAdmin ? (viewer.reach ?? []).map((a, n) => ({ ...reachNeed(a, n), tone: "notice" as const })) : []), |
| 297 | // Proposed, never done by itself: one line, the recommended choice one tap away. | |
| 298 | ...(viewer.isAdmin && (viewer.archive ?? []).length > 0 ? [archiveNeed(viewer.archive!)] : []), | |
| 280 | 299 | // Work hidden inside this forge's own process: one line for all of them, and /forges moves |
| 281 | 300 | // each. |
| 282 | 301 | ...(viewer.isAdmin && inProcessNeed(viewer.inProcess ?? 0) !== null ? [{ says: inProcessNeed(viewer.inProcess ?? 0)!, href: "/forges", tone: "notice" as const }] : []), |
| @@ -497,7 +516,7 @@ export async function homeFlowPage(ctx: Ctx, known?: { readonly spawned: Awaited | ||
| 497 | 516 | // A spawned forge's owner is a client: the forge-wide figures are not theirs to see, whole home or lean. |
| 498 | 517 | const spawned = known !== undefined ? known.spawned : await timePhaseAsync(ctx.request, "spawned", () => spawnedForge(ctx.app)); |
| 499 | 518 | const wide = user.isAdmin && spawned === undefined; |
| 500 | const strip = picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now); | |
| 519 | const strip = picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), archive: archiveNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now); | |
| 501 | 520 | // What the old home also carried and is not a lane: the box that tells the crew (with Notes and |
| 502 | 521 | // Talk), the welcome after an account is allowed, a |
| 503 | 522 | // spawned forge's address, and a stranger's request for an account. The everyday-account ask is |
| @@ -627,7 +646,7 @@ export async function liveStripFragment(ctx: Ctx): Promise<Response> { | ||
| 627 | 646 | const picture = await timePhaseAsync(ctx.request, "picture", () => lanePicture(ctx.app.db, optionsOf(ctx, user), "lane", now)); |
| 628 | 647 | const rendering = performance.now(); |
| 629 | 648 | const wide = user.isAdmin && (await spawnedForge(ctx.app)) === undefined; |
| 630 | const res = fragment(picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now), { request: ctx.request }); | |
| 649 | const res = fragment(picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), archive: archiveNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now), { request: ctx.request }); | |
| 631 | 650 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 632 | 651 | return res; |
| 633 | 652 | } |
test/client-list.test.ts +8 −2
| @@ -175,7 +175,12 @@ describe("the client list", () => { | ||
| 175 | 175 | }); |
| 176 | 176 | expect(((await wrong.json()) as Record<string, unknown>)["lastActivityAt"]).toBeNull(); |
| 177 | 177 | // The parent, asking with the secret it kept, does. |
| 178 | const rows = (await (await mcp(adminRw)).json()) as any; | |
| 178 | // What it says is what a person did there (#12262): the forge's own boot and telemetry are | |
| 179 | // not that. | |
| 180 | let rows = (await (await mcp(adminRw)).json()) as any; | |
| 181 | expect(JSON.parse(rows.result.content[0].text).forges.find((f: any) => f.name === "maria").lastActivityAt).toBeNull(); | |
| 182 | child.db.run("INSERT INTO session (token_hash, user_id, expires_at) SELECT 'sign-in-1', id, '2099-01-01T00:00:00.000Z' FROM user WHERE kind = 'user' AND is_agent = 0 LIMIT 1"); | |
| 183 | rows = (await (await mcp(adminRw)).json()) as any; | |
| 179 | 184 | const maria = JSON.parse(rows.result.content[0].text).forges.find((f: any) => f.name === "maria"); |
| 180 | 185 | expect(typeof maria.lastActivityAt).toBe("string"); |
| 181 | 186 | // The secret itself is never in an answer. |
| @@ -357,7 +362,8 @@ describe("the client list", () => { | ||
| 357 | 362 | const dustyApi = body.items.find((r) => r.name === "dusty"); |
| 358 | 363 | expect(dustyApi.attention).toBe("unclaimed"); |
| 359 | 364 | expect(dustyApi.claimed).toBe(false); |
| 360 | expect(dustyApi).toMatchObject(forges.find((r) => r.name === "dusty")); | |
| 365 | // The disk reading is taken off the request, so two answers may differ in it alone. | |
| 366 | expect({ ...dustyApi, keepBytes: 0 }).toMatchObject({ ...forges.find((r) => r.name === "dusty"), keepBytes: 0 }); | |
| 361 | 367 | expect(dustyApi.nextStep.key).toBe("unreported"); |
| 362 | 368 | // Admin only: a non-admin is 403, a signed-out caller 401. |
| 363 | 369 | const bob = await fetch(`${parentServers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${bobRw}` } }); |
test/did-you-mean.test.ts +1 −1
| @@ -15,7 +15,7 @@ let servers: Awaited<ReturnType<typeof serve>>; | ||
| 15 | 15 | /** Statements the page of a found repository makes; a change to it is a change to this number. */ |
| 16 | 16 | // 41 and 18 until 2026-11-13: every page of a repository runs two statements fewer since |
| 17 | 17 | // that day's pushes. |
| 18 | const FOUND_QUERIES = 39; | |
| 18 | const FOUND_QUERIES = 40; // one more: the fork-reach lookup of the repository row | |
| 19 | 19 | const BARE_404_QUERIES = 3; // a 404 with no owner/name to suggest from |
| 20 | 20 | const NEAR_404_QUERIES = 16; // the same 404 for a mistyped repository |
| 21 | 21 | const cookies: Record<string, string> = {}; |
test/forge-archive.test.ts added +353
| @@ -0,0 +1,353 @@ | ||
| 1 | /** | |
| 2 | * #12262: which hosted forges are in use, and archiving one: a person's last | |
| 3 | * activity (never a heartbeat), who claimed it, the proposal after 30 days, | |
| 4 | * and the archive (one file at the backup destination, then removed) with its | |
| 5 | * Restore. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { existsSync, readdirSync } from "node:fs"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { childDirOf, childNamed, listChildren } from "../src/actions/spawn.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { setStrangerLimits, strangerLimits } from "../src/http/strangers.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { archiveForge, archivedForges, noticeOwners, readNotices, restoreForge } from "../src/spawn/archive.ts"; | |
| 19 | import { archiveProposal, needsNotice, NOTICE_MS, UNTOUCHED_MS } from "../src/spawn/archive_proposal.ts"; | |
| 20 | import { childPerson } from "../src/spawn/child_db.ts"; | |
| 21 | import { clientRows, servedRows, useChildFetch } from "../src/spawn/clients.ts"; | |
| 22 | import { useProbe } from "../src/spawn/door.ts"; | |
| 23 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 24 | import { personReading } from "../src/spawn/person_activity.ts"; | |
| 25 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | let dir = ""; | |
| 31 | let store = ""; | |
| 32 | let parent: App; | |
| 33 | let servers: Servers; | |
| 34 | let token = ""; | |
| 35 | let adminId = ""; | |
| 36 | const mails: { to: string; subject: string }[] = []; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dir = await mkdtemp(join(tmpdir(), "forge-archive-")); | |
| 40 | store = await mkdtemp(join(tmpdir(), "forge-archive-store-")); | |
| 41 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0, mailer: async (m) => (mails.push({ to: m.to, subject: m.subject }), true) }); | |
| 42 | useProbe(parent, () => async () => true); | |
| 43 | useChildFetch(parent, async () => new Response("{}", { status: 503 })); | |
| 44 | const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 45 | if (!alice.ok) throw new Error(alice.error); | |
| 46 | adminId = alice.value.id; | |
| 47 | token = createToken(parent.db, adminId, "archive", "admin").token; | |
| 48 | setStrangerLimits(parent, { perAddress: { ...strangerLimits(parent).perAddress, plan: 1000 } }); | |
| 49 | servers = await serve(parent); | |
| 50 | }, 30_000); | |
| 51 | ||
| 52 | afterAll(async () => { | |
| 53 | servers.stop(); | |
| 54 | parent.close(); | |
| 55 | await rm(dir, { recursive: true, force: true }); | |
| 56 | await rm(store, { recursive: true, force: true }); | |
| 57 | }); | |
| 58 | ||
| 59 | const post = (path: string, body: Record<string, string> | [string, string][] = {}) => | |
| 60 | fetch(`${servers.url}${path}`, { method: "POST", redirect: "manual", headers: { origin: "https://forge.test", authorization: `Bearer ${token}` }, body: new URLSearchParams(body) }); | |
| 61 | ||
| 62 | /** A forge the host made, with its own data (an account in it, when `claimed`). */ | |
| 63 | async function makeForge(name: string, claimed: boolean) { | |
| 64 | const res = await fetch(`${servers.url}/forges/start`, { method: "POST", redirect: "manual", headers: { origin: "https://forge.test" }, body: new URLSearchParams({ name }) }); | |
| 65 | await res.body?.cancel(); | |
| 66 | expect(res.status).toBe(200); | |
| 67 | const made = (await childNamed(parent, name))!; | |
| 68 | const childDir = childDirOf(parent, made.bodyId); | |
| 69 | const child = createApp({ dataDir: childDir, port: 0 }); | |
| 70 | await firstBoot(child); | |
| 71 | if (claimed) { | |
| 72 | const u = await createUser(child.db, { origin: "seed", trusted: true, username: "maria", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 73 | if (!u.ok) throw new Error(u.error); | |
| 74 | // The claim makes the claimant an admin; the placeholder founder a start is born with is not | |
| 75 | // one. | |
| 76 | child.db.run("UPDATE user SET is_admin = 1 WHERE username = 'maria'"); | |
| 77 | } | |
| 78 | return { made, childDir, child }; | |
| 79 | } | |
| 80 | ||
| 81 | test("a person's last activity ignores telemetry, heartbeats and the crew, and says who claimed it", async () => { | |
| 82 | const { child } = await makeForge("in-use", true); | |
| 83 | const person = personReading(child.db); | |
| 84 | expect(person.claimedBy).toBe("maria"); | |
| 85 | expect(person.lastAt).not.toBeNull(); | |
| 86 | // Telemetry and an engine's tick, far later than the person: they do not move it. | |
| 87 | child.db.run("INSERT INTO event (id, kind, payload, created_at) VALUES ('e1', 'telemetry.sample', '{}', '2030-01-01T00:00:00.000Z')"); | |
| 88 | expect(personReading(child.db).lastAt).toBe(person.lastAt); | |
| 89 | // A person's issue does. | |
| 90 | const maria = child.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'maria'").get()!; | |
| 91 | child.db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', ?1, 'app')", [maria.id]); | |
| 92 | child.db.run("INSERT INTO issue (id, repo_id, number, title, author_id, created_at) VALUES ('i1', 'r1', 1, 'hello', ?1, '2031-02-03T04:05:06.000Z')", [maria.id]); | |
| 93 | expect(personReading(child.db).lastAt).toBe("2031-02-03T04:05:06.000Z"); | |
| 94 | // The host reads the same from outside, through its worker, with the forge asleep. | |
| 95 | expect((await childPerson(child.config.dataDir))?.lastAt).toBe("2031-02-03T04:05:06.000Z"); | |
| 96 | child.close(); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a forge nobody claimed shows no person, and the row carries what it costs to keep", async () => { | |
| 100 | const { child } = await makeForge("empty-one", false); | |
| 101 | child.close(); | |
| 102 | const row = (await clientRows(parent, { probe: false })).find((r) => r.name === "empty-one")!; | |
| 103 | expect(row.lastActivityAt).toBeNull(); | |
| 104 | expect(row.claimedBy).toBeNull(); | |
| 105 | expect(row.claimed).toBeNull(); // not probed | |
| 106 | expect("keepBytes" in row).toBe(true); | |
| 107 | }); | |
| 108 | ||
| 109 | const day = 86_400_000; | |
| 110 | const facts = (o: Partial<Parameters<typeof archiveProposal>[0]> = {}) => ({ made: true, claimed: false as boolean | null, lastActivityAt: null as string | null, claimedAt: null as string | null, sentAt: "2026-01-01T00:00:00.000Z" as string | null, ...o }); | |
| 111 | ||
| 112 | test("an unclaimed forge untouched for 30 days is proposed with the recommended choice; 29 days is not", () => { | |
| 113 | const sent = Date.parse("2026-01-01T00:00:00.000Z"); | |
| 114 | const p = archiveProposal(facts(), sent + UNTOUCHED_MS); | |
| 115 | expect(p).toMatchObject({ recommended: "Archive", ownerTold: null }); | |
| 116 | expect(p!.why).toContain("unclaimed"); | |
| 117 | expect(archiveProposal(facts(), sent + 29 * day)).toBeNull(); | |
| 118 | // A person's visit resets the clock. | |
| 119 | expect(archiveProposal(facts({ lastActivityAt: "2026-01-20T00:00:00.000Z" }), sent + 40 * day)).toBeNull(); | |
| 120 | // Not asked, still being made: never. | |
| 121 | expect(archiveProposal(facts({ claimed: null }), sent + 90 * day)).toBeNull(); | |
| 122 | expect(archiveProposal(facts({ made: false }), sent + 90 * day)).toBeNull(); | |
| 123 | }); | |
| 124 | ||
| 125 | test("a claimed forge is never proposed before its owner was told, and not if they came back", () => { | |
| 126 | const t0 = Date.parse("2026-01-01T00:00:00.000Z"); | |
| 127 | const claimed = facts({ claimed: true, claimedAt: "2026-01-02T00:00:00.000Z" }); | |
| 128 | const late = t0 + 60 * day; | |
| 129 | expect(archiveProposal(claimed, late)).toBeNull(); | |
| 130 | expect(needsNotice(claimed, late)).toBe(true); | |
| 131 | expect(needsNotice(claimed, t0 + 10 * day)).toBe(false); | |
| 132 | const told = new Date(late).toISOString(); | |
| 133 | expect(needsNotice({ ...claimed, noticedAt: told }, late)).toBe(false); | |
| 134 | expect(archiveProposal({ ...claimed, noticedAt: told }, late + NOTICE_MS - 1)).toBeNull(); | |
| 135 | const p = archiveProposal({ ...claimed, noticedAt: told }, late + NOTICE_MS); | |
| 136 | expect(p?.ownerTold).toBe(told); | |
| 137 | // They came back after being told: not proposed, and told again next time. | |
| 138 | const back = { ...claimed, noticedAt: told, lastActivityAt: new Date(late + 2 * day).toISOString() }; | |
| 139 | expect(archiveProposal(back, late + 20 * day)).toBeNull(); | |
| 140 | }); | |
| 141 | ||
| 142 | test("archive writes one file to the destination, removes the forge, leaves one line; restore brings it back under the same name", async () => { | |
| 143 | const { made, childDir, child } = await makeForge("old-test", false); | |
| 144 | child.db.run("INSERT INTO event (id, kind, payload) VALUES ('keep-me', 'marker', '{}')"); | |
| 145 | child.close(); | |
| 146 | const destination = { source: "dir" as const, store: dirStore(store) }; | |
| 147 | const done = await archiveForge(parent, "old-test", { destination, actorId: adminId }); | |
| 148 | expect(done.ok).toBe(true); | |
| 149 | // Gone from the host: the ledger, the data, the name. | |
| 150 | expect(await childNamed(parent, "old-test")).toBeUndefined(); | |
| 151 | expect(existsSync(childDir)).toBe(false); | |
| 152 | expect(existsSync(join(dir, "spawn", "old-test.json"))).toBe(false); | |
| 153 | // One file at the destination, and one line left. | |
| 154 | const keys = await destination.store.list("forge-archives/old-test/"); | |
| 155 | expect(keys.length).toBe(1); | |
| 156 | const [record] = archivedForges(parent); | |
| 157 | expect(record).toMatchObject({ name: "old-test", where: store, key: keys[0] }); | |
| 158 | // The local copy is not kept once the upload is verified. | |
| 159 | expect(readdirSync(join(dir, "archive", "files"))).toEqual([]); | |
| 160 | // The page names where the copy is, with a Restore. | |
| 161 | const page = await (await fetch(`${servers.url}/forges`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 162 | expect(page).toContain('id="archived"'); | |
| 163 | expect(page).toContain(`/forges/old-test/restore`); | |
| 164 | // Restore: same name, same data, the line gone. | |
| 165 | const back = await restoreForge(parent, "old-test", { destination }); | |
| 166 | expect(back.ok).toBe(true); | |
| 167 | expect((await childNamed(parent, "old-test"))?.bodyId).toBe(made.bodyId); | |
| 168 | const again = createApp({ dataDir: childDir, port: 0 }); | |
| 169 | expect(again.db.query("SELECT id FROM event WHERE id = 'keep-me'").get()).not.toBeNull(); | |
| 170 | again.close(); | |
| 171 | expect(archivedForges(parent)).toEqual([]); | |
| 172 | await archiveForge(parent, "old-test", { destination }); // tidy | |
| 173 | expect((await restoreForge(parent, "missing", { destination })).ok).toBe(false); | |
| 174 | }); | |
| 175 | ||
| 176 | test("a copy that does not read back the same removes nothing", async () => { | |
| 177 | const { childDir, child } = await makeForge("kept-safe", false); | |
| 178 | child.close(); | |
| 179 | const real = dirStore(store); | |
| 180 | const liar = { ...real, async get(key: string) { const b = await real.get(key); return b === undefined ? b : b.slice(0, b.length - 1); } }; | |
| 181 | const done = await archiveForge(parent, "kept-safe", { destination: { source: "dir", store: liar } }); | |
| 182 | expect(done.ok).toBe(false); | |
| 183 | expect(done.says).toContain("did not read back the same"); | |
| 184 | expect(await childNamed(parent, "kept-safe")).toBeDefined(); | |
| 185 | expect(existsSync(childDir)).toBe(true); | |
| 186 | // An upload that throws is the same. | |
| 187 | const down = { ...real, async put() { throw new Error("bucket down"); } }; | |
| 188 | const failed = await archiveForge(parent, "kept-safe", { destination: { source: "dir", store: down } }); | |
| 189 | expect(failed.ok).toBe(false); | |
| 190 | expect(await childNamed(parent, "kept-safe")).toBeDefined(); | |
| 191 | expect(archivedForges(parent).some((a) => a.name === "kept-safe")).toBe(false); | |
| 192 | }); | |
| 193 | ||
| 194 | test("with no backup destination nothing is archived", async () => { | |
| 195 | const saved = process.env["FORGE_SNAPSHOT_DIR"]; | |
| 196 | delete process.env["FORGE_SNAPSHOT_DIR"]; | |
| 197 | try { | |
| 198 | const done = await archiveForge(parent, "kept-safe"); | |
| 199 | expect(done.ok).toBe(false); | |
| 200 | expect(done.says).toContain("no backup destination"); | |
| 201 | expect(await childNamed(parent, "kept-safe")).toBeDefined(); | |
| 202 | } finally { | |
| 203 | if (saved !== undefined) process.env["FORGE_SNAPSHOT_DIR"] = saved; | |
| 204 | } | |
| 205 | }); | |
| 206 | ||
| 207 | test("the page asks first, archives several at once, and a stranger or a bad name gets nothing", async () => { | |
| 208 | process.env["FORGE_SNAPSHOT_DIR"] = store; | |
| 209 | try { | |
| 210 | for (const n of ["bulk-one", "bulk-two"]) (await makeForge(n, false)).child.close(); | |
| 211 | const confirm = await fetch(`${servers.url}/forges/archive?name=bulk-one&name=bulk-two&name=../etc&name=nope`, { headers: { authorization: `Bearer ${token}` } }); | |
| 212 | const text = await confirm.text(); | |
| 213 | expect(confirm.status).toBe(200); | |
| 214 | expect(text).toContain("bulk-one"); | |
| 215 | expect(text).toContain("bulk-two"); | |
| 216 | expect(text).not.toContain("etc"); | |
| 217 | expect((await fetch(`${servers.url}/forges/archive?name=bulk-one`)).status).toBe(404); | |
| 218 | const out = await post("/forges/archive", [["name", "bulk-one"], ["name", "bulk-two"], ["name", "../x"]]); | |
| 219 | expect(out.status).toBe(303); | |
| 220 | expect(await childNamed(parent, "bulk-one")).toBeUndefined(); | |
| 221 | expect(await childNamed(parent, "bulk-two")).toBeUndefined(); | |
| 222 | expect(archivedForges(parent).map((a) => a.name).sort()).toEqual(expect.arrayContaining(["bulk-one", "bulk-two"])); | |
| 223 | const restored = await post("/forges/bulk-one/restore"); | |
| 224 | expect(restored.status).toBe(303); | |
| 225 | expect(await childNamed(parent, "bulk-one")).toBeDefined(); | |
| 226 | // Without the admin's own origin the post is refused. | |
| 227 | const cross = await fetch(`${servers.url}/forges/archive`, { method: "POST", headers: { origin: "https://evil.test", authorization: `Bearer ${token}` }, body: new URLSearchParams({ name: "bulk-one" }) }); | |
| 228 | expect(cross.status).not.toBe(303); | |
| 229 | expect(await childNamed(parent, "bulk-one")).toBeDefined(); | |
| 230 | } finally { | |
| 231 | delete process.env["FORGE_SNAPSHOT_DIR"]; | |
| 232 | } | |
| 233 | }); | |
| 234 | ||
| 235 | test("the sort offers the quietest first, and a proposal reaches the needs-you line", async () => { | |
| 236 | const { child } = await makeForge("old-unclaimed", false); | |
| 237 | child.close(); | |
| 238 | const file = join(dir, "spawn", "old-unclaimed.json"); | |
| 239 | const ledger = JSON.parse(await Bun.file(file).text()); | |
| 240 | ledger.createdAt = new Date(Date.now() - 45 * day).toISOString(); | |
| 241 | await Bun.write(file, JSON.stringify(ledger)); | |
| 242 | const rows = await servedRows(parent); | |
| 243 | const row = rows.find((r) => r.name === "old-unclaimed")!; | |
| 244 | // Not asked: claimed is unknown, so not proposed on a guess. | |
| 245 | expect(row.archive).toBeNull(); | |
| 246 | const quiet = await (await fetch(`${servers.url}/forges?sort=quiet`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 247 | expect(quiet).toContain('aria-current="true">Quietest first'); | |
| 248 | }); | |
| 249 | ||
| 250 | test("a claimed forge's owner is mailed before it can be proposed, and only a sent mail counts", async () => { | |
| 251 | const { child, childDir } = await makeForge("claimed-idle", true); | |
| 252 | child.db.run("UPDATE user SET created_at = '2026-01-02T00:00:00.000Z'"); | |
| 253 | child.close(); | |
| 254 | const file = join(dir, "spawn", "claimed-idle.json"); | |
| 255 | const ledger = JSON.parse(await Bun.file(file).text()); | |
| 256 | ledger.createdAt = "2026-01-01T00:00:00.000Z"; | |
| 257 | await Bun.write(file, JSON.stringify(ledger)); | |
| 258 | useChildFetch(parent, async () => Response.json({ claimed: true })); | |
| 259 | const rows = await clientRows(parent); | |
| 260 | const row = rows.find((r) => r.name === "claimed-idle")!; | |
| 261 | expect(row.claimed).toBe(true); | |
| 262 | expect(row.claimedBy).toBe("maria"); | |
| 263 | expect(row.archive).toBeNull(); | |
| 264 | const told = await noticeOwners(parent, [row], Date.now()); | |
| 265 | expect(told).toEqual(["claimed-idle"]); | |
| 266 | expect(mails.at(-1)).toMatchObject({ to: "[email protected]" }); | |
| 267 | expect(readNotices(parent)["claimed-idle"]).toBeDefined(); | |
| 268 | // Told once: not again. | |
| 269 | expect(await noticeOwners(parent, [{ ...row, noticedAt: readNotices(parent)["claimed-idle"]! }], Date.now())).toEqual([]); | |
| 270 | expect(childDir.length).toBeGreaterThan(0); | |
| 271 | expect((await listChildren(parent)).some((c) => c.name === "claimed-idle")).toBe(true); | |
| 272 | }); | |
| 273 | ||
| 274 | test("an unclaimed forge untouched for 30 days is proposed in the needs-you line, never archived by itself", async () => { | |
| 275 | const { child } = await makeForge("long-quiet", false); | |
| 276 | child.close(); | |
| 277 | const file = join(dir, "spawn", "long-quiet.json"); | |
| 278 | const ledger = JSON.parse(await Bun.file(file).text()); | |
| 279 | ledger.createdAt = new Date(Date.now() - 45 * day).toISOString(); | |
| 280 | await Bun.write(file, JSON.stringify(ledger)); | |
| 281 | useChildFetch(parent, async () => Response.json({ claimed: false })); | |
| 282 | const rows = await servedRows(parent); | |
| 283 | expect(rows.find((r) => r.name === "long-quiet")?.archive).toMatchObject({ recommended: "Archive", ownerTold: null }); | |
| 284 | const home = await fetch(`${servers.url}/`, { headers: { authorization: `Bearer ${token}` } }); | |
| 285 | const text = await home.text(); | |
| 286 | expect(text).toContain("long-quiet"); | |
| 287 | expect(text).toContain("proposed for archive"); | |
| 288 | expect(text).toContain("/forges/archive?name=long-quiet"); | |
| 289 | // Proposed only: it is still here. | |
| 290 | expect(await childNamed(parent, "long-quiet")).toBeDefined(); | |
| 291 | }); | |
| 292 | ||
| 293 | // The review of #12266: a copy comes from a store less trusted than the host, two asks for one | |
| 294 | // forge do not race, a forge is not stopped for a copy that cannot be kept, and a copy is bounded. | |
| 295 | test("a restore refuses a copy that holds a link, however its names look", async () => { | |
| 296 | const work = await mkdtemp(join(tmpdir(), "forge-archive-evil-")); | |
| 297 | try { | |
| 298 | const bodyId = "evilbody1"; | |
| 299 | await Bun.write(join(work, "evil-copy.json"), "{}"); | |
| 300 | await Bun.write(join(work, bodyId, "real.txt"), "x"); | |
| 301 | await Bun.spawn(["ln", "-s", "/etc", join(work, bodyId, "x")]).exited; | |
| 302 | const made = Bun.spawn(["tar", "-czf", join(work, "c.tar.gz"), "-C", work, "evil-copy.json", bodyId]); | |
| 303 | expect(await made.exited).toBe(0); | |
| 304 | const body = new Uint8Array(await Bun.file(join(work, "c.tar.gz")).arrayBuffer()); | |
| 305 | const real = dirStore(store); | |
| 306 | await real.put("forge-archives/evil-copy/1.tar.gz", body); | |
| 307 | const { sha256 } = await import("../src/kernel/hash.ts"); | |
| 308 | await Bun.write(join(dir, "archive", "evil-copy.json"), JSON.stringify({ name: "evil-copy", bodyId, archivedAt: new Date().toISOString(), where: store, key: "forge-archives/evil-copy/1.tar.gz", bytes: body.length, sha256: sha256(body), for: null, apps: [] })); | |
| 309 | const done = await restoreForge(parent, "evil-copy", { destination: { source: "dir", store: real } }); | |
| 310 | expect(done.ok).toBe(false); | |
| 311 | expect(done.says).toContain("not a forge's archive"); | |
| 312 | expect(existsSync(childDirOf(parent, bodyId))).toBe(false); | |
| 313 | expect(readdirSync(join(dir, "archive")).some((f) => f.startsWith("restoring-"))).toBe(false); | |
| 314 | } finally { | |
| 315 | await rm(join(dir, "archive", "evil-copy.json"), { force: true }); | |
| 316 | await rm(work, { recursive: true, force: true }); | |
| 317 | } | |
| 318 | }); | |
| 319 | ||
| 320 | test("a second archive of the same forge while one runs is refused", async () => { | |
| 321 | const { child } = await makeForge("twice-asked", false); | |
| 322 | child.close(); | |
| 323 | const real = dirStore(store); | |
| 324 | let release = (): void => {}; | |
| 325 | const gate = new Promise<void>((r) => (release = r)); | |
| 326 | const slow = { ...real, async put(key: string, body: Uint8Array | string) { await gate; return real.put(key, body); } }; | |
| 327 | const first = archiveForge(parent, "twice-asked", { destination: { source: "dir", store: slow } }); | |
| 328 | await Bun.sleep(20); // real-timer: lets the first archive reach its held put before the second is asked | |
| 329 | const second = await archiveForge(parent, "twice-asked", { destination: { source: "dir", store: real } }); | |
| 330 | expect(second.ok).toBe(false); | |
| 331 | expect(second.says).toContain("already"); | |
| 332 | release(); | |
| 333 | expect((await first).ok).toBe(true); | |
| 334 | expect((await restoreForge(parent, "twice-asked", { destination: { source: "dir", store: real } })).ok).toBe(true); | |
| 335 | }); | |
| 336 | ||
| 337 | test("a destination that does not answer is found before the forge is stopped; a copy over the cap is refused and says it wakes", async () => { | |
| 338 | const { childDir, child } = await makeForge("not-stopped", false); | |
| 339 | child.close(); | |
| 340 | const real = dirStore(store); | |
| 341 | const down = { ...real, async put() { throw new Error("bucket down"); } }; | |
| 342 | const refused = await archiveForge(parent, "not-stopped", { destination: { source: "dir", store: down } }); | |
| 343 | expect(refused.ok).toBe(false); | |
| 344 | expect(refused.says).toContain("running as before"); | |
| 345 | // A copy over the cap: refused with a sentence that says the forge wakes on its next request. | |
| 346 | const big = await archiveForge(parent, "not-stopped", { destination: { source: "dir", store: real }, maxBytes: 100 }); | |
| 347 | expect(big.ok).toBe(false); | |
| 348 | expect(big.says).toContain("one archive can hold"); | |
| 349 | expect(big.says).toContain("wakes on its next request"); | |
| 350 | expect(await childNamed(parent, "not-stopped")).toBeDefined(); | |
| 351 | expect(existsSync(childDir)).toBe(true); | |
| 352 | expect(readdirSync(join(dir, "archive", "files")).filter((f) => f.startsWith("not-stopped"))).toEqual([]); | |
| 353 | }); |
test/spawn-pipes.test.ts +1
| @@ -19,6 +19,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = { | ||
| 19 | 19 | "src/apps/backup_door.ts": { sites: ["stdout+stderr"], reason: "both read to their end alongside the exit: stdout's last line is the proof, stderr the refusal" }, |
| 20 | 20 | "src/apps/contain.ts": { sites: ["stderr"], reason: "stderr awaited with the exit; stdout ignored" }, |
| 21 | 21 | "src/apps/process.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "pump() reads stdout and stderr of the app until exit" }, |
| 22 | "src/spawn/archive.ts": { sites: ["stdout+stderr"], reason: "tar() reads the listing from stdout and the failure text from stderr, then waits for exit" }, | |
| 22 | 23 | "src/spawn/cell.ts": { sites: ["stdout+stderr"], reason: "drain() reads the child forge's stdout and stderr to its log until exit" }, |
| 23 | 24 | "src/chaos/faults.ts": { sites: ["stdout"], reason: "stdout is read until the kill closes it, then cancelled; stderr is ignored" }, |
| 24 | 25 | "src/crew/claude.ts": { sites: ["stdout+stderr", "stdout"], reason: "session pumps stdout and stderr; --version awaits stdout, stderr ignored" }, |
test/spawn-sites.test.ts +2
| @@ -79,6 +79,8 @@ const SITES: Record<string, string> = { | ||
| 79 | 79 | "bun (process.execPath) on the release entry file", |
| 80 | 80 | "src/snapshot/boot.ts": |
| 81 | 81 | "bun (process.execPath) on src/main.ts in the snapshot tree", |
| 82 | "src/spawn/archive.ts": | |
| 83 | "tar with fixed flags on the forge's own data directory and an archive path it derived from a validated name", | |
| 82 | 84 | "src/spawn/cell.ts": |
| 83 | 85 | "a child forge launched from a launch spec the parent built, wrapped by its cell", |
| 84 | 86 | "src/tui/screens/external.ts": |
test/tui-forges.test.ts +3 −3
| @@ -188,16 +188,16 @@ describe("forges you sent in the terminal", () => { | ||
| 188 | 188 | const up = { total: 3, running: 3, notRunning: [] }; |
| 189 | 189 | const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] }; |
| 190 | 190 | expect(forgeLines([row({ running: up })])).toEqual([ |
| 191 | ["maria", "asleep · for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet · small · shared · shared disk · no heartbeat yet"], | |
| 191 | ["maria", "asleep · for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet · small · shared · shared disk · no person has done anything here · disk measuring · no memory (asleep) · no credit lent · no heartbeat yet"], | |
| 192 | 192 | ["", ` ${firstScreenLine([])}`], |
| 193 | 193 | ]); |
| 194 | 194 | expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([ |
| 195 | ["maria", "asleep · Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · shared disk · no heartbeat yet"], | |
| 195 | ["maria", "asleep · Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · shared disk · no person has done anything here · disk measuring · no memory (asleep) · no credit lent · no heartbeat yet"], | |
| 196 | 196 | ]); |
| 197 | 197 | expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown"); |
| 198 | 198 | expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made"); |
| 199 | 199 | expect(forgeLines([row({ attention: "down", claimed: true, for: null })])).toEqual([ |
| 200 | ["maria", "asleep · App down · Claimed · has not reported yet · small · shared · shared disk · no heartbeat yet"], | |
| 200 | ["maria", "asleep · App down · Claimed · has not reported yet · small · shared · shared disk · no person has done anything here · disk measuring · no memory (asleep) · no credit lent · no heartbeat yet"], | |
| 201 | 201 | ]); |
| 202 | 202 | expect(forgeLines([row({})])[0]![1]).not.toMatch(/Not reporting|App down|Unclaimed/); |
| 203 | 203 | expect(forgeLines([row({ nextStep: { key: "tell", words: "no tell yet", lastTellAt: null } })])[0]![1]).toContain("no tell yet"); |
new above
offline