Public: anyone can read this; the crew can change it.
terminal has no create app #15
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3-terminal-has-no-create-app → main 1 commit fork
5 files
changed +135
−29
src/actions/crew.ts +45
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | getAgentByName, |
| 16 | 16 | listAgents, |
| 17 | 17 | listCrew as queryCrew, |
| 18 | listCrewRoles, | |
| 18 | 19 | removeCrew as deleteCrew, |
| 19 | 20 | updateAgent as patchAgent, |
| 20 | 21 | type Agent, |
| @@ -23,10 +24,14 @@ import { | ||
| 23 | 24 | BUDGET_MAX_USD, |
| 24 | 25 | MODEL_MAX, |
| 25 | 26 | } from "../core/agents.ts"; |
| 27 | import type { Issue, TitleError } from "../core/issues.ts"; | |
| 28 | import { listRuns } from "../core/runs.ts"; | |
| 26 | 29 | import type { Repo } from "../core/repos.ts"; |
| 27 | 30 | import { createUser, type CreateUserError, type User } from "../core/users.ts"; |
| 31 | import { routeIssue, type Routed } from "../crew/route.ts"; | |
| 28 | 32 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 29 | 33 | import { requireReader, requireRepoAdmin, requireUser } from "./access.ts"; |
| 34 | import { assignIssue, openIssue, type MirrorError } from "./issues.ts"; | |
| 30 | 35 | |
| 31 | 36 | export interface CreateAgentInput { |
| 32 | 37 | username: string; |
| @@ -225,4 +230,44 @@ export function removeFromCrew( | ||
| 225 | 230 | return ok(); |
| 226 | 231 | } |
| 227 | 232 | |
| 233 | export interface TellCrewResult { | |
| 234 | readonly issue: Issue; | |
| 235 | /** Who took it, when somebody on the crew builds here. */ | |
| 236 | readonly routed: Routed | undefined; | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * A sentence becomes work: open it as an issue, and hand it to whichever | |
| 241 | * builder on the crew `routeIssue` picks. The one call behind the web's | |
| 242 | * "Tell the crew" box and the terminal's — a repository with nobody on its | |
| 243 | * crew still opens the issue, just with nobody to route it to. | |
| 244 | */ | |
| 245 | export async function tellCrew( | |
| 246 | app: App, | |
| 247 | actor: User | undefined, | |
| 248 | repo: Repo, | |
| 249 | text: string, | |
| 250 | ): Promise< | |
| 251 | Result<TellCrewResult, "not_found" | "unauthorized" | TitleError | "body_too_long" | MirrorError> | |
| 252 | > { | |
| 253 | const trimmed = text.trim(); | |
| 254 | const firstLine = trimmed.split(/\n/)[0]!.trim(); | |
| 255 | const title = | |
| 256 | firstLine.length <= 80 ? firstLine : `${firstLine.slice(0, 77).trimEnd()}…`; | |
| 257 | const opened = openIssue(app, actor, repo, { | |
| 258 | title, | |
| 259 | body: trimmed === firstLine ? "" : trimmed, | |
| 260 | }); | |
| 261 | if (!opened.ok) return opened; | |
| 262 | const crew = listCrewRoles(app.db, repo.id); | |
| 263 | const inFlight = new Map<string, number>(); | |
| 264 | for (const r of listRuns(app.db, repo.id, { kind: "agent", limit: 50 })) | |
| 265 | if ((r.state === "running" || r.state === "queued") && r.actorId !== null) | |
| 266 | inFlight.set(r.actorId, (inFlight.get(r.actorId) ?? 0) + 1); | |
| 267 | const routed = await routeIssue(app, repo, opened.value, crew, inFlight); | |
| 268 | if (routed === undefined) return ok({ issue: opened.value, routed: undefined }); | |
| 269 | assignIssue(app, actor, repo, opened.value, routed.builder.user); | |
| 270 | return ok({ issue: opened.value, routed }); | |
| 271 | } | |
| 272 | ||
| 228 | 273 | export { getAgent }; |
src/tui/screens/app.ts +35
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | stopEnv, |
| 21 | 21 | type AppView, |
| 22 | 22 | } from "../../actions/apps.ts"; |
| 23 | import { tellCrew } from "../../actions/crew.ts"; | |
| 23 | 24 | import type { EnvStatus } from "../../apps/runtime.ts"; |
| 24 | 25 | import { envPull } from "../../apps/data.ts"; |
| 25 | 26 | import { getIssuesByRef } from "../../core/issues.ts"; |
| @@ -42,6 +43,7 @@ import { | ||
| 42 | 43 | live, |
| 43 | 44 | metaOf, |
| 44 | 45 | openLink, |
| 46 | promptScreen, | |
| 45 | 47 | runAsync, |
| 46 | 48 | type ListRow, |
| 47 | 49 | } from "./common.ts"; |
| @@ -183,6 +185,34 @@ export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 183 | 185 | ); |
| 184 | 186 | }; |
| 185 | 187 | |
| 188 | /** `T`: a sentence becomes an issue, handed to whichever builder is free. */ | |
| 189 | const tell = (): void => { | |
| 190 | ctx.push( | |
| 191 | promptScreen(ctx, screen, { | |
| 192 | title: "Tell the crew", | |
| 193 | label: "What should change?", | |
| 194 | hint: "Becomes an issue, and starts whichever builder on the crew is free.", | |
| 195 | onSubmit: (text) => { | |
| 196 | if (text === "") return "say something first"; | |
| 197 | return (async () => { | |
| 198 | const told = await tellCrew(f.app, f.user, repo, text); | |
| 199 | if (!told.ok) { | |
| 200 | ctx.toast(errorText(told.error), "danger"); | |
| 201 | return; | |
| 202 | } | |
| 203 | const { routed } = told.value; | |
| 204 | ctx.toast( | |
| 205 | routed === undefined | |
| 206 | ? "told, but nobody is on the crew to take it yet" | |
| 207 | : `${routed.builder.user.username} is on it`, | |
| 208 | "success", | |
| 209 | ); | |
| 210 | })(); | |
| 211 | }, | |
| 212 | }), | |
| 213 | ); | |
| 214 | }; | |
| 215 | ||
| 186 | 216 | const screen: Screen = { |
| 187 | 217 | title: `${repo.ownerName}/${repo.name} · app`, |
| 188 | 218 | get keys() { |
| @@ -191,6 +221,7 @@ export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 191 | 221 | binding(["j", "k"], "move"), |
| 192 | 222 | binding(["o"], "open"), |
| 193 | 223 | binding(["t"], "terminal"), |
| 224 | binding(["T"], "tell the crew", { disabled: !writer }), | |
| 194 | 225 | binding(["enter"], "log"), |
| 195 | 226 | binding(["r"], "redeploy", { disabled: !writer }), |
| 196 | 227 | binding(["s"], "stop", { disabled: !writer }), |
| @@ -314,6 +345,10 @@ export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 314 | 345 | if (env) void openAppTerminal(f, ctx, repo, env.env); |
| 315 | 346 | return true; |
| 316 | 347 | } |
| 348 | if (e.key === "T" && mayWrite()) { | |
| 349 | tell(); | |
| 350 | return true; | |
| 351 | } | |
| 317 | 352 | if (e.key === "r" && mayWrite()) { |
| 318 | 353 | redeploy(selected()); |
| 319 | 354 | return true; |
src/tui/screens/dashboard.ts +13 −2
| @@ -65,7 +65,13 @@ import { openAppTerminal } from "./apptty.ts"; | ||
| 65 | 65 | import { describeEvent, type FeedLine } from "./activity.ts"; |
| 66 | 66 | import { accountScreen } from "./account.ts"; |
| 67 | 67 | import { appScreen } from "./app.ts"; |
| 68 | import { appRow, appsScreen, listVisibleApps, type AppItem } from "./apps.ts"; | |
| 68 | import { | |
| 69 | appRow, | |
| 70 | appsScreen, | |
| 71 | listVisibleApps, | |
| 72 | newAppFlow, | |
| 73 | type AppItem, | |
| 74 | } from "./apps.ts"; | |
| 69 | 75 | import { exploreScreen } from "./explore.ts"; |
| 70 | 76 | import { issueScreen } from "./issue.ts"; |
| 71 | 77 | import { newRepoScreen } from "./newrepo.ts"; |
| @@ -319,6 +325,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 319 | 325 | disabled: focus !== "machine" || pins.length === 0, |
| 320 | 326 | }), |
| 321 | 327 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 328 | binding(["N"], "new app", { disabled: !f.signedIn }), | |
| 322 | 329 | binding(["A"], "account", { disabled: !f.signedIn }), |
| 323 | 330 | binding(["s"], "sign in", { disabled: f.signedIn }), |
| 324 | 331 | binding(["r"], "refresh"), |
| @@ -427,6 +434,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 427 | 434 | ctx.push(newRepoScreen(f, ctx)); |
| 428 | 435 | return true; |
| 429 | 436 | } |
| 437 | if (e.key === "N" && f.signedIn) { | |
| 438 | newAppFlow(f, ctx, screen); | |
| 439 | return true; | |
| 440 | } | |
| 430 | 441 | if (e.key === "A" && f.user) { |
| 431 | 442 | ctx.push(accountScreen(f, ctx)); |
| 432 | 443 | return true; |
| @@ -523,7 +534,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 523 | 534 | emptyState(s, r, c, { |
| 524 | 535 | title: f.signedIn ? "No repositories yet" : "Nothing public yet", |
| 525 | 536 | body: f.signedIn |
| 526 | ? "Press n to create one, or e to explore." | |
| 537 | ? "Press N for a new app, n for a plain repository, or e to explore." | |
| 527 | 538 | : "Press s to sign in, or e to explore.", |
| 528 | 539 | }); |
| 529 | 540 | return; |
src/web/pages/studio.ts +6 −26
| @@ -11,8 +11,9 @@ | ||
| 11 | 11 | * pull the crew stopped on, or a green one waiting for a look. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { addComment, openIssue, assignIssue } from "../../actions/issues.ts"; | |
| 14 | import { addComment } from "../../actions/issues.ts"; | |
| 15 | 15 | import { mergePull } from "../../actions/pulls.ts"; |
| 16 | import { tellCrew } from "../../actions/crew.ts"; | |
| 16 | 17 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; |
| 17 | 18 | import { listCrewRoles, type Agent } from "../../core/agents.ts"; |
| 18 | 19 | import { appsForRepos, getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| @@ -42,7 +43,6 @@ import { | ||
| 42 | 43 | } from "../../core/runs.ts"; |
| 43 | 44 | import { getUserById, getUsersByIds, type User } from "../../core/users.ts"; |
| 44 | 45 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 45 | import { routeIssue } from "../../crew/route.ts"; | |
| 46 | 46 | import { certain, decideOr } from "../../crew/decide.ts"; |
| 47 | 47 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 48 | 48 | import type { Ctx } from "../../http/context.ts"; |
| @@ -928,40 +928,20 @@ export async function tellSubmit(ctx: Ctx): Promise<Response> { | ||
| 928 | 928 | tone: "danger", |
| 929 | 929 | message: "Say something first.", |
| 930 | 930 | }); |
| 931 | const firstLine = text.split(/\n/)[0]!.trim(); | |
| 932 | const title = | |
| 933 | firstLine.length <= 80 ? firstLine : `${firstLine.slice(0, 77).trimEnd()}…`; | |
| 934 | const opened = openIssue(ctx.app, ctx.auth.user, repo, { | |
| 935 | title, | |
| 936 | body: text === firstLine ? "" : text, | |
| 937 | }); | |
| 938 | if (!opened.ok) | |
| 931 | const told = await tellCrew(ctx.app, ctx.auth.user, repo, text); | |
| 932 | if (!told.ok) | |
| 939 | 933 | return flashTo(ctx, base, { |
| 940 | 934 | tone: "danger", |
| 941 | 935 | message: "That could not be told.", |
| 942 | 936 | }); |
| 943 | const crew = listCrewRoles(ctx.app.db, repo.id); | |
| 944 | const inFlight = new Map<string, number>(); | |
| 945 | for (const r of listRuns(ctx.app.db, repo.id, { kind: "agent", limit: 50 })) | |
| 946 | if ((r.state === "running" || r.state === "queued") && r.actorId !== null) | |
| 947 | inFlight.set(r.actorId, (inFlight.get(r.actorId) ?? 0) + 1); | |
| 948 | const routed = await routeIssue(ctx.app, repo, opened.value, crew, inFlight); | |
| 937 | const { routed } = told.value; | |
| 949 | 938 | if (routed === undefined) |
| 950 | 939 | return flashTo(ctx, base, { |
| 951 | 940 | message: `Told. Nobody is on the crew to take it yet — add someone under Settings → Crew.`, |
| 952 | 941 | }); |
| 953 | 942 | const builder = routed.builder; |
| 954 | const assigned = assignIssue( | |
| 955 | ctx.app, | |
| 956 | ctx.auth.user, | |
| 957 | repo, | |
| 958 | opened.value, | |
| 959 | builder.user, | |
| 960 | ); | |
| 961 | 943 | return flashTo(ctx, base, { |
| 962 | message: assigned.ok | |
| 963 | ? `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}. Watch it work below.` | |
| 964 | : `Told as #${opened.value.number}, but ${builder.user.username} could not take it.`, | |
| 944 | message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}. Watch it work below.`, | |
| 965 | 945 | }); |
| 966 | 946 | } |
| 967 | 947 |
test/tui-apps.test.ts +36 −1
| @@ -27,7 +27,7 @@ import { createApp, type App as Forge } from "../src/app.ts"; | ||
| 27 | 27 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 28 | 28 | import { getAgentByName, type Agent } from "../src/core/agents.ts"; |
| 29 | 29 | import { getAppByRepo } from "../src/core/apps.ts"; |
| 30 | import { getIssue, type Issue } from "../src/core/issues.ts"; | |
| 30 | import { getIssue, listIssues, type Issue } from "../src/core/issues.ts"; | |
| 31 | 31 | import { getRepo, getRepoById, type Repo } from "../src/core/repos.ts"; |
| 32 | 32 | import { |
| 33 | 33 | claimRun, |
| @@ -591,6 +591,41 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 591 | 591 | } |
| 592 | 592 | }, 30_000); |
| 593 | 593 | |
| 594 | test("T on the app screen: tell the crew, and the free builder takes it", async () => { | |
| 595 | const h = boot(forge, identityFor(alice)); | |
| 596 | try { | |
| 597 | await h.press("a"); | |
| 598 | await h.until("alice/site"); | |
| 599 | await h.press("enter"); | |
| 600 | await h.until("slug site"); | |
| 601 | await h.press("T"); | |
| 602 | await h.until("Tell the crew"); | |
| 603 | await h.type("Make the header pop"); | |
| 604 | await h.press("enter"); | |
| 605 | await h.until("robo is on it"); | |
| 606 | const told = listIssues(forge.db, site.id, { state: "all" }).find( | |
| 607 | (i) => i.title === "Make the header pop", | |
| 608 | ); | |
| 609 | expect(told?.assigneeId).toBe(robo.user.id); | |
| 610 | } finally { | |
| 611 | h.stop(); | |
| 612 | } | |
| 613 | }, 30_000); | |
| 614 | ||
| 615 | test("N on the dashboard reaches New app directly, with no detour through Apps", async () => { | |
| 616 | const h = boot(forge, identityFor(alice)); | |
| 617 | try { | |
| 618 | await h.until("Apps"); | |
| 619 | await h.press("N"); | |
| 620 | await h.until("New app"); | |
| 621 | await h.press("esc"); | |
| 622 | // Esc discarded the flow: the dashboard is back underneath it. | |
| 623 | await h.until("Apps"); | |
| 624 | } finally { | |
| 625 | h.stop(); | |
| 626 | } | |
| 627 | }, 30_000); | |
| 628 | ||
| 594 | 629 | // Last on purpose: it starts a real deploy of the scaffold in the |
| 595 | 630 | // background, and nothing after it should have to share the machine. |
| 596 | 631 | test("new app, the golden path: a name, a sentence, who sees it, who works on it", async () => { |
new above
offline