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

terminal has no create app #15

Merged opened this in answer to

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

Merged

scout/forge:scout/3-terminal-has-no-create-app → main 1 commit fork

  • Merged. scout/3-terminal-has-no-create-app was merged into main as f5fe31b.

Review

Sign in to review this change.
5 files changed +135 −29
src/actions/crew.ts +45
@@ -15,6 +15,7 @@ import {
1515 getAgentByName,
1616 listAgents,
1717 listCrew as queryCrew,
18 listCrewRoles,
1819 removeCrew as deleteCrew,
1920 updateAgent as patchAgent,
2021 type Agent,
@@ -23,10 +24,14 @@ import {
2324 BUDGET_MAX_USD,
2425 MODEL_MAX,
2526 } from "../core/agents.ts";
27 import type { Issue, TitleError } from "../core/issues.ts";
28 import { listRuns } from "../core/runs.ts";
2629 import type { Repo } from "../core/repos.ts";
2730 import { createUser, type CreateUserError, type User } from "../core/users.ts";
31 import { routeIssue, type Routed } from "../crew/route.ts";
2832 import { err, ok, type Result } from "../kernel/result.ts";
2933 import { requireReader, requireRepoAdmin, requireUser } from "./access.ts";
34 import { assignIssue, openIssue, type MirrorError } from "./issues.ts";
3035
3136 export interface CreateAgentInput {
3237 username: string;
@@ -225,4 +230,44 @@ export function removeFromCrew(
225230 return ok();
226231 }
227232
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
228273 export { getAgent };
src/tui/screens/app.ts +35
@@ -20,6 +20,7 @@ import {
2020 stopEnv,
2121 type AppView,
2222 } from "../../actions/apps.ts";
23 import { tellCrew } from "../../actions/crew.ts";
2324 import type { EnvStatus } from "../../apps/runtime.ts";
2425 import { envPull } from "../../apps/data.ts";
2526 import { getIssuesByRef } from "../../core/issues.ts";
@@ -42,6 +43,7 @@ import {
4243 live,
4344 metaOf,
4445 openLink,
46 promptScreen,
4547 runAsync,
4648 type ListRow,
4749 } from "./common.ts";
@@ -183,6 +185,34 @@ export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
183185 );
184186 };
185187
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
186216 const screen: Screen = {
187217 title: `${repo.ownerName}/${repo.name} · app`,
188218 get keys() {
@@ -191,6 +221,7 @@ export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
191221 binding(["j", "k"], "move"),
192222 binding(["o"], "open"),
193223 binding(["t"], "terminal"),
224 binding(["T"], "tell the crew", { disabled: !writer }),
194225 binding(["enter"], "log"),
195226 binding(["r"], "redeploy", { disabled: !writer }),
196227 binding(["s"], "stop", { disabled: !writer }),
@@ -314,6 +345,10 @@ export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
314345 if (env) void openAppTerminal(f, ctx, repo, env.env);
315346 return true;
316347 }
348 if (e.key === "T" && mayWrite()) {
349 tell();
350 return true;
351 }
317352 if (e.key === "r" && mayWrite()) {
318353 redeploy(selected());
319354 return true;
src/tui/screens/dashboard.ts +13 −2
@@ -65,7 +65,13 @@ import { openAppTerminal } from "./apptty.ts";
6565 import { describeEvent, type FeedLine } from "./activity.ts";
6666 import { accountScreen } from "./account.ts";
6767 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";
6975 import { exploreScreen } from "./explore.ts";
7076 import { issueScreen } from "./issue.ts";
7177 import { newRepoScreen } from "./newrepo.ts";
@@ -319,6 +325,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
319325 disabled: focus !== "machine" || pins.length === 0,
320326 }),
321327 binding(["n"], "new repo", { disabled: !f.signedIn }),
328 binding(["N"], "new app", { disabled: !f.signedIn }),
322329 binding(["A"], "account", { disabled: !f.signedIn }),
323330 binding(["s"], "sign in", { disabled: f.signedIn }),
324331 binding(["r"], "refresh"),
@@ -427,6 +434,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
427434 ctx.push(newRepoScreen(f, ctx));
428435 return true;
429436 }
437 if (e.key === "N" && f.signedIn) {
438 newAppFlow(f, ctx, screen);
439 return true;
440 }
430441 if (e.key === "A" && f.user) {
431442 ctx.push(accountScreen(f, ctx));
432443 return true;
@@ -523,7 +534,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
523534 emptyState(s, r, c, {
524535 title: f.signedIn ? "No repositories yet" : "Nothing public yet",
525536 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."
527538 : "Press s to sign in, or e to explore.",
528539 });
529540 return;
src/web/pages/studio.ts +6 −26
@@ -11,8 +11,9 @@
1111 * pull the crew stopped on, or a green one waiting for a look.
1212 */
1313
14 import { addComment, openIssue, assignIssue } from "../../actions/issues.ts";
14 import { addComment } from "../../actions/issues.ts";
1515 import { mergePull } from "../../actions/pulls.ts";
16 import { tellCrew } from "../../actions/crew.ts";
1617 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
1718 import { listCrewRoles, type Agent } from "../../core/agents.ts";
1819 import { appsForRepos, getAppByRepo, type AppRecord } from "../../core/apps.ts";
@@ -42,7 +43,6 @@ import {
4243 } from "../../core/runs.ts";
4344 import { getUserById, getUsersByIds, type User } from "../../core/users.ts";
4445 import { autonomyStatus } from "../../crew/autonomy.ts";
45 import { routeIssue } from "../../crew/route.ts";
4646 import { certain, decideOr } from "../../crew/decide.ts";
4747 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
4848 import type { Ctx } from "../../http/context.ts";
@@ -928,40 +928,20 @@ export async function tellSubmit(ctx: Ctx): Promise<Response> {
928928 tone: "danger",
929929 message: "Say something first.",
930930 });
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)
939933 return flashTo(ctx, base, {
940934 tone: "danger",
941935 message: "That could not be told.",
942936 });
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;
949938 if (routed === undefined)
950939 return flashTo(ctx, base, {
951940 message: `Told. Nobody is on the crew to take it yet — add someone under Settings → Crew.`,
952941 });
953942 const builder = routed.builder;
954 const assigned = assignIssue(
955 ctx.app,
956 ctx.auth.user,
957 repo,
958 opened.value,
959 builder.user,
960 );
961943 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.`,
965945 });
966946 }
967947
test/tui-apps.test.ts +36 −1
@@ -27,7 +27,7 @@ import { createApp, type App as Forge } from "../src/app.ts";
2727 import { AppsRuntime } from "../src/apps/runtime.ts";
2828 import { getAgentByName, type Agent } from "../src/core/agents.ts";
2929 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";
3131 import { getRepo, getRepoById, type Repo } from "../src/core/repos.ts";
3232 import {
3333 claimRun,
@@ -591,6 +591,41 @@ describe("terminal: apps, runs and the crew", () => {
591591 }
592592 }, 30_000);
593593
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
594629 // Last on purpose: it starts a real deploy of the scaffold in the
595630 // background, and nothing after it should have to share the machine.
596631 test("new app, the golden path: a name, a sentence, who sees it, who works on it", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs