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

Talk starts an app from one prompt, and each turn is a version you can preview, diff, restore and publish #11014

Merged opened this in answer to

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

Merged

mason/forge:mason/11010-talk-starts-an-app-from-one-prompt-and-e → main 6 commits fork 1 thread · 1 unresolved

  • Merged. mason/11010-talk-starts-an-app-from-one-prompt-and-e was merged into main as eafca22.

Review

Sign in to review this change.
28 files changed +1358 −9
docs/design/areas/talk-build.md added +7
@@ -0,0 +1,7 @@
1 # Talk build (#11010)
2
3 - `.talk-build`: the panel beside a building conversation: repository, preview, versions, Publish.
4 - `.talk-build-box`: the empty Talk's one box, "What do you want to build?", with its starters.
5 - `.talk-build-versions`: the numbered list of versions, newest first, the chosen one in bold.
6 - `.talk-build-starters`: the templates to start from, a plain link list under the box.
7 - `.talk-build-files`: the files a chosen version changed.
docs/features/talk.md +11
@@ -112,3 +112,14 @@ Finds a conversation by a word in its title or in what was said, among the perso
112112 - **Terminal:** in Conversations, `/` asks for words and the results replace the list (archived ones marked); Enter opens the chat on the page around the match, that turn marked `▶`; `r` goes to the newest turns; esc leaves the results.
113113 - **How:** an FTS5 table, `conversation_find` (title and standing turns, kept in step by triggers; a turn set aside is left out), with the owner as an indexed column that the query names in its MATCH, so the engine's 200-row limit counts only the person's rows. `findConversations` and `pageAround` in `src/core/conversations.ts` are the one door for both screens.
114114 - **Left out:** the API and MCP do not search yet; no ranking beyond FTS5's, no highlighting of the words inside a turn.
115
116 ## Build an app from one prompt (#11010)

Both doors: this feature has no terminal screen. Add a src/tui/ screen for start, versions, diff, Restore and Publish, and say the terminal how-to here. README stays untouched, which is right.

117
118 An empty `/talk` asks "What do you want to build?" with starters from the marked app templates (`/talk?template=owner/name` chooses one). Send makes the repository from the template (`newAppFromSentence`), starts a conversation in it, and sends the prompt; the agent is told with every message to work on the branch `talk-<conversation id>` and never on main (`buildLead`, `src/core/chat_build.ts`).
119
120 - **Versions** are the branch's commits past main, numbered v1, v2 from the oldest (`src/actions/build.ts`); nothing about them is stored twice. The first one opens the branch's pull, which is what gives it a preview: it opens when a turn ends, or on Restore or Publish; a read never writes. `?v=N` shows that version's files changed; **Restore** makes a new version holding exactly its tree, history kept.
121 - **Publish** merges the pull to the default branch, which deploys production; the production URL then shows as a link. Only a person publishes.
122 - **API:** `POST /api/v1/builds {prompt, template?}`, `GET /api/v1/conversations/:id/build`, `GET …/build/versions/:n/diff`, `POST …/build/restore {version}`, `POST …/build/publish`. Web forms: `POST /talk/build`, `/talk/:id/build/restore`, `/talk/:id/build/publish`.
123 - **Limits:** five builds an hour and ten unpublished at once, per person (`BUILDS_PER_HOUR`, `UNPUBLISHED_MAX`); a template must be one on offer to that person, on the API as on the page; a start that fails takes back the repository and conversation it made.
124 - **Terminal:** `:` then **Build an app**. `n` says what to build (`t` chooses the template), Enter opens a build and lists its versions newest first, Enter again shows the files that version changed, `R` restores the chosen version, `P` publishes, `r` reloads. The screen calls the same `actions/build.ts` functions as the page, so it is gated the same way (your own conversation, a writer of the repository) and what it does is what the page shows on its next read.
125 - Proof: `test/talk-build.test.ts` runs prompt, repository, v1, v2, restore v1 and publish against a scratch forge; `test/tui-talk-build.test.ts` does the same from the terminal.
src/actions/build.ts added +222
@@ -0,0 +1,222 @@
1 /**
2 * Talk builds an app (#11010). One prompt makes the repository from a
3 * template and starts a conversation in it; each turn that changes code is a
4 * commit on the conversation's branch, numbered v1, v2, …; Restore makes a
5 * new version from an old one; Publish merges the branch to the default
6 * branch, which deploys production. Versions are the branch's commits, read
7 * from git: nothing about them is stored twice.
8 */
9
10 import type { App } from "../app.ts";
11 import { appsRuntime } from "../apps/runtime.ts";
12 import { previewEnv } from "../apps/data.ts";
13 import { offeredTemplates } from "../core/app_templates.ts";
14 import { type ChatBuild, branchFor, chatBuildOf, setBuildPublished, startChatBuild, unpublishedBuilds } from "../core/chat_build.ts";
15 import { RateLimiter } from "../core/ratelimit.ts";
16 import { commitIdentity } from "../core/commit_identity.ts";
17 import { getAppByRepo } from "../core/apps.ts";
18 import { createConversation, dropConversation, getConversation, renameConversation, TITLE_MAX } from "../core/conversations.ts";
19 import { getPull } from "../core/pulls.ts";
20 import { getRepoById, type Repo } from "../core/repos.ts";
21 import type { User } from "../core/users.ts";
22 import { diffCommit, type Diff } from "../git/diff.ts";
23 import { runGit } from "../git/env.ts";
24 import { moveRefLeased } from "../git/branches.ts";
25 import { resolveOid, treeOf } from "../git/merge.ts";
26 import { repoDir } from "../git/repos.ts";
27 import { err, ok, type Result } from "../kernel/result.ts";
28 import { requireUser, requireWriter } from "./access.ts";
29 import { newAppFromSentence } from "./apps.ts";
30 import { createBranch } from "./branches.ts";
31 import { ensureBuildPull, type Version, versionsIn } from "./build_pull.ts";
32 import { mergePull } from "./pulls.ts";
33 import { deleteRepo } from "./repos.ts";
34 import { talk, type TalkRefusal } from "./talk.ts";
35
36 export { VERSIONS_MAX, type Version } from "./build_pull.ts";
37
38 export interface BuildState {
39 readonly build: ChatBuild;
40 readonly repoName: string;
41 readonly versions: readonly Version[];
42 /** The preview of the newest version, once there is a version and its environment exists. */
43 readonly previewUrl: string | null;
44 readonly previewState: string | null;
45 /** Production's address, shown once published. */
46 readonly productionUrl: string | null;
47 readonly published: boolean;
48 }
49
50 /** Builds a person may start in an hour, and may hold unpublished at once. */
51 export const BUILDS_PER_HOUR = 5;
52 export const UNPUBLISHED_MAX = 10;
53
54 const limiters = new WeakMap<App, RateLimiter>();
55
56 /** Throttle by user id (never a header): false past `BUILDS_PER_HOUR` an hour. */
57 function takeBuild(app: App, user: User): boolean {
58 let limiter = limiters.get(app);
59 if (limiter === undefined) {
60 limiter = new RateLimiter({ perMinute: BUILDS_PER_HOUR, adminPerMinute: BUILDS_PER_HOUR, enabled: true, windowMs: 3_600_000 });
61 limiters.set(app, limiter);
62 }
63 return limiter.take(`build:${user.id}`, "rw").allowed;
64 }
65
66 export type BuildError = "unauthorized" | "not_found" | "no_name" | "no_version" | "nothing_to_publish" | "moved" | "bad_template" | "rate_limited" | "too_many_builds" | "failed";
67
68 export const BUILD_WORDS: Readonly<Record<string, string>> = {
69 unauthorized: "Sign in to build.",
70 not_found: "No such build.",
71 no_name: "Say what you want to build in a few words.",
72 no_version: "There is no such version.",
73 nothing_to_publish: "Nothing to publish yet: no version has changed the app.",
74 moved: "The build changed while you did that. Try again.",
75 bad_template: "That template is not on offer to you.",
76 rate_limited: `At most ${BUILDS_PER_HOUR} builds an hour; try again later.`,
77 too_many_builds: `You hold ${UNPUBLISHED_MAX} builds that are not published; publish one first.`,
78 failed: "That did not go through.",
79 };
80
81 /** A prompt as a one-line title. */
82 const titleOf = (prompt: string): string => prompt.replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
83
84 /**
85 * Make the repository from the template, start the chat on its branch and
86 * send the prompt. `said` is the refusal when the agent could not take the
87 * words (no account, resting): the build still exists and the person can send again.
88 */
89 export async function startBuild(
90 app: App,
91 actor: User | undefined,
92 input: { readonly prompt: string; readonly template?: string },
93 ): Promise<Result<{ build: ChatBuild; repo: Repo; said: TalkRefusal | undefined }, BuildError>> {
94 const user = requireUser(actor);
95 if (!user.ok) return user;
96 const template = input.template === undefined || input.template === "" ? undefined : input.template;
97 // The API reaches this too: a template is only ever one the person can see.
98 if (template !== undefined && !offeredTemplates(app.db, user.value).includes(template)) return err("bad_template");
99 if (unpublishedBuilds(app.db, user.value.id) >= UNPUBLISHED_MAX) return err("too_many_builds");
100 if (!takeBuild(app, user.value)) return err("rate_limited");
101 // Cheapest step first, so a refusal there leaves nothing; later failures take back what was made.
102 const conversation = createConversation(app.db, user.value.id);
103 if (!conversation.ok) return err("failed");
104 const made = await newAppFromSentence(app, user.value, { sentence: input.prompt, ...(template === undefined ? {} : { template }) });
105 if (!made.ok) {
106 dropConversation(app.db, user.value.id, conversation.value.id);
107 // Words that name nothing cost nothing, so they do not use up the hour.
108 if (made.code === "no_name") limiters.get(app)?.refund(`build:${user.value.id}`);
109 return err(made.code === "no_name" ? "no_name" : "failed");
110 }
111 const branch = await createBranch(app, user.value, made.repo, { name: branchFor(conversation.value.id) });
112 if (!branch.ok) {
113 await deleteRepo(app, user.value, made.repo);
114 dropConversation(app.db, user.value.id, conversation.value.id);
115 return err("failed");
116 }
117 const build = startChatBuild(app.db, conversation.value.id, made.repo.id);
118 renameConversation(app.db, user.value.id, conversation.value.id, titleOf(input.prompt));
119 const said = talk(app, user.value, input.prompt, [], "queue", conversation.value.id);
120 return ok({ build, repo: made.repo, said: said.ok ? undefined : said.error });
121 }
122
123 /** The build of one of the person's own conversations, with the repository it is in. */
124 function buildOf(app: App, user: User, conversationId: string): { build: ChatBuild; repo: Repo } | undefined {
125 if (getConversation(app.db, user.id, conversationId) === undefined) return undefined;
126 const build = chatBuildOf(app.db, conversationId);
127 const repo = build === undefined ? undefined : getRepoById(app.db, build.repoId);
128 return build === undefined || repo === undefined ? undefined : { build, repo };
129 }
130
131 /**
132 * Where the build stands. Pure: a read never opens the pull (a turn's end,
133 * Restore and Publish do, `build_pull.ts`).
134 */
135 export async function buildState(app: App, actor: User | undefined, conversationId: string): Promise<Result<BuildState, BuildError>> {
136 const user = requireUser(actor);
137 if (!user.ok) return user;
138 const found = buildOf(app, user.value, conversationId);
139 if (found === undefined) return err("not_found");
140 const { repo } = found;
141 const { build } = found;
142 const versions = build.publishedAt === null ? await versionsIn(app, repo, build) : [];
143 const record = getAppByRepo(app.db, repo.id);
144 const runtime = appsRuntime(app);
145 const env = build.pullNumber === null ? undefined : previewEnv(build.pullNumber);
146 const status = record === undefined || runtime === undefined || env === undefined ? undefined : runtime.envStatus(record, env);
147 return ok({
148 build,
149 repoName: `${repo.ownerName}/${repo.name}`,
150 versions,
151 previewUrl: record === undefined || runtime === undefined || env === undefined || build.publishedAt !== null ? null : runtime.urlFor(record, env),
152 previewState: build.publishedAt !== null ? null : (status?.state ?? null),
153 productionUrl: build.publishedAt === null || record === undefined || runtime === undefined ? null : runtime.urlFor(record, "production"),
154 published: build.publishedAt !== null,
155 });
156 }
157
158 /** The diff one version made, against the version before it. */
159 export async function versionDiff(app: App, actor: User | undefined, conversationId: string, number: number): Promise<Result<Diff, BuildError>> {
160 const user = requireUser(actor);
161 if (!user.ok) return user;
162 const found = buildOf(app, user.value, conversationId);
163 if (found === undefined) return err("not_found");
164 const version = (await versionsIn(app, found.repo, found.build)).find((v) => v.number === number);
165 if (version === undefined) return err("no_version");
166 const diff = await diffCommit(repoDir(app.reposRoot, found.repo.ownerName, found.repo.name), version.oid);
167 return diff.ok ? ok(diff.value) : err("failed");
168 }
169
170 /** A new version holding exactly what an earlier one held; history is kept, nothing is rewritten. */
171 export async function restoreVersion(app: App, actor: User | undefined, conversationId: string, number: number): Promise<Result<Version, BuildError>> {
172 const user = requireUser(actor);
173 if (!user.ok) return user;
174 const found = buildOf(app, user.value, conversationId);
175 if (found === undefined) return err("not_found");
176 const { repo, build } = found;
177 if (build.publishedAt !== null) return err("not_found");
178 const writer = requireWriter(app, user.value, repo);
179 if (!writer.ok) return err("not_found");
180 const versions = await versionsIn(app, repo, build);
181 const from = versions.find((v) => v.number === number);
182 if (from === undefined) return err("no_version");
183 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
184 const tip = await resolveOid(dir, `refs/heads/${build.branch}`);
185 const tree = await treeOf(dir, from.oid);
186 if (tip === undefined || tree === undefined) return err("failed");
187 const who = commitIdentity(app.config, writer.value);
188 const made = await runGit(["commit-tree", tree, "-p", tip, "-m", `Restore v${number}\n\nThe app as it was at v${number}: ${from.subject}`], {
189 cwd: dir,
190 env: { GIT_AUTHOR_NAME: who.name, GIT_AUTHOR_EMAIL: who.email, GIT_COMMITTER_NAME: who.name, GIT_COMMITTER_EMAIL: who.email },
191 });
192 if (!made.ok) return err("failed");
193 const oid = made.value.trim();
194 const moved = await moveRefLeased(dir, `refs/heads/${build.branch}`, oid, tip);
195 if (!moved.ok) return err(moved.error === "ref_moved" ? "moved" : "failed");
196 // `update-ref` runs no hooks: announce the push as a git push would be, so the preview deploys.
197 app.bus.append({ kind: "push", repoId: repo.id, actorId: writer.value.id, payload: { updates: [{ ref: `refs/heads/${build.branch}`, oldOid: tip, newOid: oid }], viaApi: true } });
198 await ensureBuildPull(app, writer.value, build, repo);
199 return ok({ number: versions.length + 1, oid, subject: `Restore v${number}`, date: new Date().toISOString() });
200 }
201
202 /** Merge the branch to the default branch, which deploys production. */
203 export async function publishBuild(app: App, actor: User | undefined, conversationId: string): Promise<Result<{ productionUrl: string | null }, BuildError>> {
204 const user = requireUser(actor);
205 if (!user.ok) return user;
206 const state = await buildState(app, user.value, conversationId);
207 if (!state.ok) return state;
208 const repo = getRepoById(app.db, state.value.build.repoId);
209 if (repo === undefined) return err("not_found");
210 if (state.value.published) return ok({ productionUrl: state.value.productionUrl });
211 const writer = requireWriter(app, user.value, repo);
212 if (!writer.ok) return err("not_found");
213 const build = await ensureBuildPull(app, writer.value, state.value.build, repo);
214 if (build.pullNumber === null || state.value.versions.length === 0) return err("nothing_to_publish");
215 const pull = getPull(app.db, repo.id, build.pullNumber);
216 if (pull === undefined) return err("not_found");
217 const merged = await mergePull(app, user.value, repo, pull, "merge");
218 if (!merged.ok) return err("failed");
219 setBuildPublished(app.db, conversationId);
220 const after = await buildState(app, user.value, conversationId);
221 return ok({ productionUrl: after.ok ? after.value.productionUrl : null });
222 }
src/actions/build_pull.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The pull that gives a build's branch its preview (#11010), and the
3 * versions the branch holds. Opening the pull is a write: it happens when a
4 * turn ends (`crew/conversation.ts`) and on Restore and Publish, never on a read.
5 */
6
7 import type { App } from "../app.ts";
8 import { type ChatBuild, chatBuildOf, setBuildPull } from "../core/chat_build.ts";
9 import { getRepoById, type Repo } from "../core/repos.ts";
10 import { getUserById, type User } from "../core/users.ts";
11 import { commitLog, type Commit } from "../git/read.ts";
12 import { repoDir } from "../git/repos.ts";
13 import { openPull } from "./pulls.ts";
14
15 /** Most versions listed for one build. */
16 export const VERSIONS_MAX = 200;
17
18 export interface Version {
19 readonly number: number;
20 readonly oid: string;
21 readonly subject: string;
22 readonly date: string;
23 }
24
25 /** The branch's commits past the default branch, oldest first, numbered from 1. */
26 export async function versionsIn(app: App, repo: Repo, build: ChatBuild): Promise<Version[]> {
27 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
28 const log = await commitLog(dir, `refs/heads/${repo.defaultBranch}..refs/heads/${build.branch}`, { limit: VERSIONS_MAX });
29 if (!log.ok) return [];
30 return log.value.reverse().map((c: Commit, i) => ({ number: i + 1, oid: c.oid, subject: c.subject, date: c.date }));
31 }
32
33 /** Open the build's pull once a version exists; the build as it stands after. */
34 export async function ensureBuildPull(app: App, user: User, build: ChatBuild, repo: Repo): Promise<ChatBuild> {
35 if (build.pullNumber !== null || build.publishedAt !== null) return build;
36 if ((await versionsIn(app, repo, build)).length === 0) return build;
37 const opened = await openPull(app, user, repo, { title: `Build: ${repo.name}`, body: "Made by Talk.", baseBranch: repo.defaultBranch, headBranch: build.branch });
38 if (!opened.ok) return build;
39 setBuildPull(app.db, build.conversationId, opened.value.number);
40 return { ...build, pullNumber: opened.value.number };
41 }
42
43 /** A turn ended in this conversation: if it is a build and the turn made its first version, open the pull. */
44 export async function buildTurnEnded(app: App, userId: string, conversationId: string): Promise<void> {
45 const build = chatBuildOf(app.db, conversationId);
46 const repo = build === undefined ? undefined : getRepoById(app.db, build.repoId);
47 const user = getUserById(app.db, userId);
48 if (build === undefined || repo === undefined || user === undefined) return;
49 await ensureBuildPull(app, user, build, repo);
50 }
src/contract/openapi/routes/getApiV1ConversationsIdBuild.ts added +15
@@ -0,0 +1,15 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/conversations/{id}/build",
6 summary: "The app a conversation builds: its repository, branch, versions (the branch's commits past the default branch, numbered from v1), the preview address once there is a version, and whether it is published",
7 auth: true,
8 role: "member",
9 reply: { repo: "string", versions: "array", published: "boolean" },
10 refusals: {
11 not_found: "no such build of yours (404)",
12 unauthorized: "sign in (401)",
13 forbidden: "the request is cross-origin, or the token may only read",
14 },
15 };
src/contract/openapi/routes/getApiV1ConversationsIdBuildVersionsNDiff.ts added +15
@@ -0,0 +1,15 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/conversations/{id}/build/versions/{n}/diff",
6 summary: "The files and lines one version changed against the version before it; an unknown n is 404",
7 auth: true,
8 role: "member",
9 reply: { files: "array" },
10 refusals: {
11 not_found: "no such build of yours (404)",
12 unauthorized: "sign in (401)",
13 forbidden: "the request is cross-origin, or the token may only read",
14 },
15 };
src/contract/openapi/routes/index.ts +5
@@ -70,6 +70,8 @@ export { route as getApiV1Contribute } from "./getApiV1Contribute.ts";
7070 export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts";
7171 export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts";
7272 export { route as getApiV1Conversations } from "./getApiV1Conversations.ts";
73 export { route as getApiV1ConversationsIdBuild } from "./getApiV1ConversationsIdBuild.ts";
74 export { route as getApiV1ConversationsIdBuildVersionsNDiff } from "./getApiV1ConversationsIdBuildVersionsNDiff.ts";
7375 export { route as getApiV1Credit } from "./getApiV1Credit.ts";
7476 export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts";
7577 export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts";
@@ -255,12 +257,15 @@ export { route as postApiV1AsksGiveBack } from "./postApiV1AsksGiveBack.ts";
255257 export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts";
256258 export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
257259 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
260 export { route as postApiV1Builds } from "./postApiV1Builds.ts";
258261 export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts";
259262 export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFiles.ts";
260263 export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts";
261264 export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts";
262265 export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts";
263266 export { route as postApiV1Conversations } from "./postApiV1Conversations.ts";
267 export { route as postApiV1ConversationsIdBuildPublish } from "./postApiV1ConversationsIdBuildPublish.ts";
268 export { route as postApiV1ConversationsIdBuildRestore } from "./postApiV1ConversationsIdBuildRestore.ts";
264269 export { route as postApiV1DevicesIdPrint } from "./postApiV1DevicesIdPrint.ts";
265270 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
266271 export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts";
src/contract/openapi/routes/postApiV1Builds.ts added +20
@@ -0,0 +1,20 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/builds",
6 summary: "Build an app from one prompt (and optionally an offered template, owner/name): makes the repository, starts a conversation in it on its own branch and sends the prompt; the agent's turns that change code become versions v1, v2, …",
7 auth: true,
8 role: "member",
9 status: 201,
10 body: { prompt: "string", template: "string" },
11 reply: { conversation: "string", repo: "string", branch: "string" },
12 refusals: {
13 invalid_template: "the template is not owner/name (422)",
14 bad_template: "the template is not one on offer to you (422)",
15 rate_limited: "at most 5 builds an hour (429)",
16 too_many_builds: "you hold 10 builds that are not published (429)",
17 unauthorized: "sign in (401)",
18 forbidden: "the request is cross-origin, or the token may only read",
19 },
20 };
src/contract/openapi/routes/postApiV1ConversationsIdBuildPublish.ts added +15
@@ -0,0 +1,15 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/conversations/{id}/build/publish",
6 summary: "Publish: merge the build's branch to the default branch, which deploys production; answers its address. 409 when no version has changed the app",
7 auth: true,
8 role: "member",
9 reply: { published: "boolean" },
10 refusals: {
11 not_found: "no such build of yours (404)",
12 unauthorized: "sign in (401)",
13 forbidden: "the request is cross-origin, or the token may only read",
14 },
15 };
src/contract/openapi/routes/postApiV1ConversationsIdBuildRestore.ts added +17
@@ -0,0 +1,17 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/conversations/{id}/build/restore",
6 summary: "Restore a version: make a new version holding exactly what version n held; history is kept",
7 auth: true,
8 role: "member",
9 status: 201,
10 body: { version: "number" },
11 reply: { version: "object" },
12 refusals: {
13 not_found: "no such build of yours (404)",
14 unauthorized: "sign in (401)",
15 forbidden: "the request is cross-origin, or the token may only read",
16 },
17 };
src/core/chat_build.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * The app a conversation is building (#11010): which repository, which
3 * branch its turns commit to, and the pull that gives the branch a preview.
4 * Plain rows in, plain rows out; the git side lives in `actions/build.ts`.
5 */
6 import type { Database } from "bun:sqlite";
7
8 export interface ChatBuild {
9 readonly conversationId: string;
10 readonly repoId: string;
11 readonly branch: string;
12 /** The pull that previews the branch; set when the first version exists. */
13 readonly pullNumber: number | null;
14 readonly publishedAt: string | null;
15 }
16
17 interface Row {
18 conversation_id: string;
19 repo_id: string;
20 branch: string;
21 pull_number: number | null;
22 published_at: string | null;
23 }
24
25 const build = (r: Row): ChatBuild => ({ conversationId: r.conversation_id, repoId: r.repo_id, branch: r.branch, pullNumber: r.pull_number, publishedAt: r.published_at });
26
27 /** The branch a conversation's turns commit to: server-made, never taken from a request. */
28 export const branchFor = (conversationId: string): string => `talk-${conversationId}`;
29
30 export function startChatBuild(db: Database, conversationId: string, repoId: string): ChatBuild {
31 const branch = branchFor(conversationId);
32 db.query("INSERT INTO chat_build (conversation_id, repo_id, branch, created_at) VALUES (?1,?2,?3,?4)").run(conversationId, repoId, branch, new Date().toISOString());
33 return { conversationId, repoId, branch, pullNumber: null, publishedAt: null };
34 }
35
36 export function chatBuildOf(db: Database, conversationId: string): ChatBuild | undefined {
37 const row = db.query("SELECT * FROM chat_build WHERE conversation_id = ?1").get(conversationId) as Row | null;
38 return row === null ? undefined : build(row);
39 }
40
41 export function setBuildPull(db: Database, conversationId: string, pullNumber: number): void {
42 db.query("UPDATE chat_build SET pull_number = ?2 WHERE conversation_id = ?1").run(conversationId, pullNumber);
43 }
44
45 export function setBuildPublished(db: Database, conversationId: string): void {
46 db.query("UPDATE chat_build SET published_at = ?2 WHERE conversation_id = ?1").run(conversationId, new Date().toISOString());
47 }
48
49 /**
50 * What the agent is told before every message of a building conversation:
51 * where to work. Server text, so it is not fenced; the names in it are the
52 * server's own (an owner and repository name already validated at creation).
53 */
54 export function buildLead(repoName: string, branch: string, defaultBranch: string): string {
55 return `You are building the app ${repoName} for this person. Make every change on the branch ${branch}, one commit for each request that changes code, with a short message saying what changed. Never commit to ${defaultBranch}: the person publishes from the page.\n\n`;
56 }
57
58 /** How many builds a person holds that are not yet published. */
59 export function unpublishedBuilds(db: Database, userId: string): number {
60 const row = db
61 .query("SELECT COUNT(*) AS n FROM chat_build b JOIN conversation c ON c.id = b.conversation_id WHERE c.user_id = ?1 AND b.published_at IS NULL")
62 .get(userId) as { n: number };
63 return row.n;
64 }
65
66 export interface BuildRow {
67 readonly conversationId: string;
68 readonly title: string;
69 readonly repoId: string;
70 readonly published: boolean;
71 readonly updatedAt: string;
72 }
73
74 /** A person's builds, most recently active first. */
75 export function buildsOf(db: Database, userId: string): BuildRow[] {
76 const rows = db
77 .query("SELECT b.conversation_id, b.repo_id, b.published_at, c.title, c.updated_at FROM chat_build b JOIN conversation c ON c.id = b.conversation_id WHERE c.user_id = ?1 ORDER BY c.updated_at DESC LIMIT 100")
78 .all(userId) as { conversation_id: string; repo_id: string; published_at: string | null; title: string; updated_at: string }[];
79 return rows.map((r) => ({ conversationId: r.conversation_id, title: r.title, repoId: r.repo_id, published: r.published_at !== null, updatedAt: r.updated_at }));
80 }
src/core/conversations.ts +5
@@ -100,6 +100,11 @@ export function createConversation(db: Database, userId: string): Result<Convers
100100 }).immediate();
101101 }
102102
103 /** Take back a conversation that was made and never used (a failed start); its turns and build go with it. */
104 export function dropConversation(db: Database, userId: string, conversationId: string): void {
105 db.query("DELETE FROM conversation WHERE id = ?1 AND user_id = ?2").run(conversationId, userId);
106 }
107
103108 /** Start a new, empty conversation. */
104109 export function newConversation(db: Database, userId: string): Conversation {
105110 const now = new Date().toISOString();
src/core/migration-pins/20261110170000-chat-build.pin added +1
@@ -0,0 +1,1 @@
1 0e181bbd9f756ae2a707dcbecef9fada14d157871d5f2b30050f37bc83665f71
src/core/migrations/20261110170000-chat-build.ts added +13
@@ -0,0 +1,13 @@
1 // A conversation that builds an app (#11010): the repository it builds, the
2 // branch each turn commits to, and the pull that gives that branch a preview.
3 // Versions are the branch's commits, so none are stored here.
4 export default `
5 CREATE TABLE chat_build (
6 conversation_id TEXT PRIMARY KEY REFERENCES conversation(id) ON DELETE CASCADE,
7 repo_id TEXT NOT NULL,
8 branch TEXT NOT NULL,
9 pull_number INTEGER,
10 published_at TEXT,
11 created_at TEXT NOT NULL
12 );
13 `;
src/crew/conversation.ts +13 −2
@@ -20,6 +20,9 @@ import { join } from "node:path";
2020 import type { App } from "../app.ts";
2121 import { dailyLeft } from "../core/account_ceiling.ts";
2222 import { aboutContext } from "../core/talk_about.ts";
23 import { buildTurnEnded } from "../actions/build_pull.ts";
24 import { buildLead, chatBuildOf } from "../core/chat_build.ts";
25 import { getRepoById } from "../core/repos.ts";
2326 import { appendTurn, getConversation, lastExchange, listTurns, openConversation, removeTurn, restoreTurns, setAsideFrom, setClaudeSession, type Conversation } from "../core/conversations.ts";
2427 import { attach, deliver, fileOwnedBy, NOTHING, release, type ConversationFile, type Delivery } from "../core/conversation_files.ts";
2528 import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts";
@@ -314,7 +317,11 @@ export function say(
314317 }
315318 // The subject rides with a new conversation's first message only (#10496), as fenced data the agent reads before the words.
316319 const first = listTurns(app.db, userId, conversation.id, { limit: 1 });
317 const lead = about === undefined || about === null || !first.ok || first.value.length > 0 ? "" : aboutContext(app.db, about, user);
320 const subject = about === undefined || about === null || !first.ok || first.value.length > 0 ? "" : aboutContext(app.db, about, user);
321 // A conversation that builds an app tells the agent where to work with every message (#11010).
322 const building = chatBuildOf(app.db, conversation.id);
323 const repo = building === undefined ? undefined : getRepoById(app.db, building.repoId);
324 const lead = (repo === undefined || building === undefined ? "" : buildLead(`${repo.ownerName}/${repo.name}`, building.branch, repo.defaultBranch)) + subject;
318325 const stored = appendTurn(app.db, userId, conversation.id, { role: "person", text });
319326 if (!stored.ok) return err("too_long");
320327 attach(app.db, files, stored.value.id);
@@ -605,7 +612,11 @@ function begin(
605612 if (stoppers.get(conversation.id)?.runId === runId) stoppers.delete(conversation.id);
606613 // A restart leaves the next message unsent (its turn would be cut again); anything else sends it.
607614 if (options.signal.aborted) queues.delete(conversation.id);
608 else drain(app, options, conversation);
615 else {
616 // A build's first version gives its branch the pull that previews it (#11010).
617 await buildTurnEnded(app, userId, conversation.id).catch((error: unknown) => app.log.warn("build pull failed", { error: String(error) }));
618 drain(app, options, conversation);
619 }
609620 }
610621 })();
611622 return ok(turn);
src/http/api.ts +2
@@ -387,6 +387,7 @@ import { rollbackRoutes } from "./api/rollback.ts";
387387 import { takeBackRoutes } from "./api/take_back.ts";
388388 import { conversationConfirmRoutes } from "./api/conversation_confirms.ts";
389389 import { conversationTalkRoutes } from "./api/conversation_talk.ts";
390 import { conversationBuildRoutes } from "./api/conversation_build.ts";
390391 import { orgRoutes } from "./api/orgs.ts";
391392 import { promptRoutes } from "./api/prompts.ts";
392393 import { cloudRoutes } from "./api/cloud.ts";
@@ -2147,6 +2148,7 @@ export function apiRoutes(router: Router<Handler>): void {
21472148 takeBackRoutes(router);
21482149 conversationConfirmRoutes(router);
21492150 conversationTalkRoutes(router);
2151 conversationBuildRoutes(router);
21502152
21512153 // Which stage of the one flow each open issue is in, and what went live in
21522154 // the last day (src/flow).
src/http/api/conversation_build.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * Talk builds an app over the API (#11010): `POST /api/v1/builds` makes the
3 * repository from a template and starts the chat in it; a build's state,
4 * each version's diff, Restore and Publish hang under
5 * `/api/v1/conversations/:id/build`. Same-origin and rw only, as every
6 * conversation change is; every answer is for the person's own conversation.
7 */
8
9 import { BUILD_WORDS, buildState, publishBuild, restoreVersion, startBuild, versionDiff, type BuildError } from "../../actions/build.ts";
10 import { TALK_WORDS } from "../../actions/talk.ts";
11 import { WORDS_MAX } from "../../core/fence.ts";
12 import { apiError, boundedBody, json, tryAgain, unauthorized } from "../respond.ts";
13 import type { Router } from "../router.ts";
14 import type { Handler } from "../context.ts";
15 import { mayChange } from "./conversation_talk.ts";
16
17 const ID = /^[0-9a-z]{1,40}$/;
18 const TEMPLATE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
19 const STATUS: Readonly<Record<BuildError, number>> = { unauthorized: 401, not_found: 404, no_name: 422, no_version: 404, nothing_to_publish: 409, moved: 409, bad_template: 422, rate_limited: 422, too_many_builds: 422, failed: 422 };
20
21 /** Both build limits are per hour: say so with the 429 (#10762). */
22 const BUILD_WAIT = 3600;
23 const fail = (error: BuildError) =>
24 error === "rate_limited" || error === "too_many_builds"
25 ? tryAgain(error, 429, BUILD_WORDS[error] ?? error, BUILD_WAIT)
26 : apiError(error, STATUS[error], BUILD_WORDS[error] ?? error);
27
28 export function conversationBuildRoutes(router: Router<Handler>): void {
29 router.add("POST", "/api/v1/builds", async (ctx) => {
30 const user = mayChange(ctx);
31 if (user instanceof Response) return user;
32 const input = await boundedBody(ctx.request, 20_000);
33 if (input === "too_large") return apiError("too_large", 413, "at most 20000 bytes");
34 const prompt = input["prompt"];
35 if (typeof prompt !== "string" || prompt.trim() === "" || prompt.length > WORDS_MAX) return apiError("invalid_prompt", 422, `prompt is a string of 1 to ${WORDS_MAX} characters`);
36 const template = input["template"];
37 if (template !== undefined && (typeof template !== "string" || !TEMPLATE.test(template))) return apiError("invalid_template", 422, "template is owner/name");
38 const started = await startBuild(ctx.app, user, { prompt, ...(template === undefined ? {} : { template }) });
39 if (!started.ok) return fail(started.error);
40 const { build, repo, said } = started.value;
41 return json({ conversation: build.conversationId, repo: `${repo.ownerName}/${repo.name}`, branch: build.branch, ...(said === undefined ? {} : { said, message: TALK_WORDS[said][1] }) }, 201);
42 });
43
44 const named = (id: string | undefined): string | undefined => (id !== undefined && ID.test(id) ? id : undefined);
45
46 router.add("GET", "/api/v1/conversations/:id/build", async (ctx) => {
47 if (ctx.auth.user === undefined) return unauthorized();
48 const id = named(ctx.params["id"]);
49 if (id === undefined) return fail("not_found");
50 const state = await buildState(ctx.app, ctx.auth.user, id);
51 return state.ok ? json({ repo: state.value.repoName, branch: state.value.build.branch, versions: state.value.versions, previewUrl: state.value.previewUrl, previewState: state.value.previewState, published: state.value.published, productionUrl: state.value.productionUrl }) : fail(state.error);
52 });
53
54 router.add("GET", "/api/v1/conversations/:id/build/versions/:n/diff", async (ctx) => {
55 if (ctx.auth.user === undefined) return unauthorized();
56 const id = named(ctx.params["id"]);
57 const n = Number(ctx.params["n"]);
58 if (id === undefined || !Number.isInteger(n) || n < 1) return fail("no_version");
59 const diff = await versionDiff(ctx.app, ctx.auth.user, id, n);
60 return diff.ok ? json(diff.value) : fail(diff.error);
61 });
62
63 router.add("POST", "/api/v1/conversations/:id/build/restore", async (ctx) => {
64 const user = mayChange(ctx);
65 if (user instanceof Response) return user;
66 const id = named(ctx.params["id"]);
67 const input = await boundedBody(ctx.request, 1_000);
68 if (input === "too_large") return apiError("too_large", 413, "at most 1000 bytes");
69 const n = input["version"];
70 if (id === undefined) return fail("not_found");
71 if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return apiError("invalid_version", 422, "version is the number of a version, from 1");
72 const restored = await restoreVersion(ctx.app, user, id, n);
73 return restored.ok ? json({ version: restored.value }, 201) : fail(restored.error);
74 });
75
76 router.add("POST", "/api/v1/conversations/:id/build/publish", async (ctx) => {
77 const user = mayChange(ctx);
78 if (user instanceof Response) return user;
79 const id = named(ctx.params["id"]);
80 if (id === undefined) return fail("not_found");
81 const published = await publishBuild(ctx.app, user, id);
82 return published.ok ? json({ published: true, productionUrl: published.value.productionUrl }) : fail(published.error);
83 });
84 }
src/http/api/conversation_talk.ts +1 −1
@@ -242,7 +242,7 @@ const ID = /^[0-9a-z]{1,40}$/;
242242 const TITLE_WORDS = `title is text of at most ${TITLE_MAX} characters, on one line`;
243243
244244 /** The person a change is for: signed in, same-origin, and not on a read-only token. */
245 function mayChange(ctx: Parameters<Handler>[0]): User | Response {
245 export function mayChange(ctx: Parameters<Handler>[0]): User | Response {
246246 const user = ctx.auth.user;
247247 if (user === undefined) return unauthorized();
248248 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
src/http/server.ts +4
@@ -146,6 +146,7 @@ import {
146146 verifyPage,
147147 themeSubmit,
148148 } from "../web/pages/site.ts";
149 import { talkBuild, talkPublish, talkRestore } from "../web/talk_build.ts";
149150 import { talkAgainSend, talkArchive, talkConfirm, talkNew, talkQueueCancel, talkRename, talkSend, talkStream, talkView } from "../web/pages/talk.ts";
150151 import {
151152 recoverFinish,
@@ -1011,10 +1012,13 @@ export function buildRouter(): Router<Handler> {
10111012 router.add("POST", "/talk/queue/:id/cancel", talkQueueCancel);
10121013 router.add("POST", "/talk/confirm/:id", talkConfirm);
10131014 router.add("POST", "/talk/new", talkNew);
1015 router.add("POST", "/talk/build", talkBuild);
10141016 router.add("GET", "/talk/:id", talkView);
10151017 router.add("POST", "/talk/:id", talkSend);
10161018 router.add("POST", "/talk/:id/rename", talkRename);
10171019 router.add("POST", "/talk/:id/archive", talkArchive);
1020 router.add("POST", "/talk/:id/build/restore", talkRestore);
1021 router.add("POST", "/talk/:id/build/publish", talkPublish);
10181022 router.add("POST", "/talk/:id/again/:how", talkAgainSend);
10191023 router.add("POST", "/signin/passkey/options", signinOptions);
10201024 router.add("POST", "/signin/passkey", signinPasskey);
src/tui/forge.ts +9
@@ -59,6 +59,7 @@ import { usageScreen } from "./screens/usage.ts";
5959 import { canScreen } from "./screens/can.ts";
6060 import { modelScreen } from "./screens/model.ts";
6161 import { notifyScreen } from "./screens/notify.ts";
62 import { talkBuildScreen } from "./screens/talk_build.ts";
6263 import { talkChatsScreen } from "./screens/talk_chats.ts";
6364 import { sshKeysScreen } from "./screens/sshkeys.ts";
6465 import { passkeysScreen } from "./screens/passkeys.ts";
@@ -405,6 +406,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
405406 hint: "your talks with the forge's agent; n starts one, r renames, x archives",
406407 run: () => ctx.push(talkChatsScreen(session, ctx)),
407408 });
409 // One prompt builds an app; each turn is a version to diff, restore and publish (#11010).
410 if (session.signedIn && session.user?.kind !== "agent")
411 commands.push({
412 id: "build-app",
413 title: "Build an app",
414 hint: "say what to build; versions, diff, restore and publish. n starts one",
415 run: () => ctx.push(talkBuildScreen(session, ctx)),
416 });
408417 if (session.signedIn)
409418 commands.push({
410419 id: "ssh-keys",
src/tui/screens/talk_build.ts added +213
@@ -0,0 +1,213 @@
1 /**
2 * Talk builds an app, in the terminal (#11010): the same one prompt, versions,
3 * diff, Restore and Publish the page has, through the same `actions/build.ts`
4 * calls, so each is gated as the page is (the person's own conversation, a
5 * writer of the repository) and a change here is what a web viewer's next
6 * read shows: Restore announces its push and Publish merges the pull.
7 */
8
9 import { buildState, BUILD_WORDS, publishBuild, restoreVersion, startBuild, versionDiff, type BuildState } from "../../actions/build.ts";
10 import { TALK_WORDS } from "../../actions/talk.ts";
11 import { offeredTemplates } from "../../core/app_templates.ts";
12 import { buildsOf } from "../../core/chat_build.ts";
13 import { getRepoById } from "../../core/repos.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import { style } from "../term/style.ts";
17 import { truncate } from "../term/width.ts";
18 import type { Session } from "../session.ts";
19 import { fill, len, split } from "../ui/layout.ts";
20 import { ListState, Scroll, ago, confirmScreen, drawLines, drawList, promptScreen, runAsync, type ListRow } from "./common.ts";
21
22 const words = (error: string): string => BUILD_WORDS[error] ?? "That did not go through.";
23
24 /** What one version changed, file by file. */
25 function diffScreen(f: Session, ctx: Ctx, conversationId: string, number: number, subject: string): Screen {
26 const scroll = new Scroll();
27 let lines: { text: string; style: ReturnType<typeof style> }[][] | undefined;
28 runAsync(ctx, async () => {
29 const diff = await versionDiff(f.app, f.user, conversationId, number);
30 const c = ctx.theme.c;
31 lines = !diff.ok
32 ? [[{ text: words(diff.error), style: style(c.mutedFg, c.bg) }]]
33 : diff.value.files.length === 0
34 ? [[{ text: "This version changed no files.", style: style(c.mutedFg, c.bg) }]]
35 : diff.value.files.map((file) => [
36 { text: `${file.path} `, style: style(c.fg, c.bg) },
37 { text: `${file.status}, +${file.additions} -${file.deletions}`, style: style(c.mutedFg, c.bg) },
38 ]);
39 ctx.invalidate();
40 });
41 return {
42 title: `v${number}: ${subject}`,
43 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
44 render(area, s) {
45 drawLines(s, area, ctx.theme.c, scroll, lines ?? []);
46 },
47 key(e) {
48 if (scroll.key(e, lines?.length ?? 0)) return true;
49 if (e.key === "esc" || e.key === "q") {
50 ctx.pop();
51 return true;
52 }
53 return false;
54 },
55 };
56 }
57
58 /** One build: its versions, newest first; Enter shows a version's files, R restores it, P publishes. */
59 function buildScreen(f: Session, ctx: Ctx, conversationId: string, title: string): Screen {
60 const state = new ListState();
61 let current: BuildState | undefined;
62 let note = "Loading…";
63 const load = (): void => {
64 runAsync(ctx, async () => {
65 const got = await buildState(f.app, f.user, conversationId);
66 if (got.ok) {
67 current = got.value;
68 note = "";
69 } else note = words(got.error);
70 ctx.invalidate();
71 });
72 };
73 load();
74 const versions = () => [...(current?.versions ?? [])].reverse();
75 const screen: Screen = {
76 title,
77 keys: [binding(["j", "k"], "move"), binding(["enter"], "files changed"), binding(["R"], "restore"), binding(["P"], "publish"), binding(["r"], "reload"), binding(["esc"], "back")],
78 render(area, s) {
79 const c = ctx.theme.c;
80 const [head, body] = split(area, "v", [len(2), fill(1)]);
81 const where = current === undefined ? "" : current.published ? `${current.repoName} · published ${current.productionUrl ?? ""}` : `${current.repoName} · preview ${current.previewUrl ?? "not up yet"}${current.previewState === null ? "" : ` (${current.previewState})`}`;
82 s.text(head!.x, head!.y, truncate(where, head!.w), style(c.fg, c.bg), head!.x + head!.w);
83 s.text(head!.x, head!.y + 1, truncate(note !== "" ? note : current?.versions.length === 0 ? "No version yet: the first change makes v1." : "R restores the chosen version as a new one; P publishes to production.", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
84 const rows: ListRow[] = versions().map((v) => ({ title: `v${v.number} ${v.subject}`, meta: ago(v.date) }));
85 state.clamp(rows.length);
86 drawList(s, body!, c, state, rows);
87 },
88 key(e) {
89 if (state.key(e, versions().length)) return true;
90 if (e.ctrl || e.alt) return false;
91 if (e.key === "esc" || e.key === "q") {
92 ctx.pop();
93 return true;
94 }
95 if (e.key === "r") {
96 load();
97 return true;
98 }
99 const at = versions()[state.index];
100 if (e.key === "enter" && at !== undefined) {
101 ctx.push(diffScreen(f, ctx, conversationId, at.number, at.subject));
102 return true;
103 }
104 if (e.key === "R" && at !== undefined && current?.published === false) {
105 ctx.push(
106 confirmScreen(ctx, screen, {
107 title: "Restore",
108 body: [`Make a new version holding exactly what v${at.number} held. History is kept.`],
109 actions: [{ label: "Restore" }, { label: "Cancel" }],
110 onChoose: (i) => {
111 if (i !== 0) return;
112 runAsync(ctx, async () => {
113 const done = await restoreVersion(f.app, f.user, conversationId, at.number);
114 ctx.toast(done.ok ? `Restored v${at.number} as a new version.` : words(done.error), done.ok ? "success" : "danger");
115 load();
116 });
117 },
118 }),
119 );
120 return true;
121 }
122 if (e.key === "P" && current !== undefined && !current.published) {
123 ctx.push(
124 confirmScreen(ctx, screen, {
125 title: "Publish",
126 body: ["Merge this build to the default branch and deploy production."],
127 actions: [{ label: "Publish" }, { label: "Cancel" }],
128 onChoose: (i) => {
129 if (i !== 0) return;
130 runAsync(ctx, async () => {
131 const done = await publishBuild(f.app, f.user, conversationId);
132 ctx.toast(done.ok ? "Published. Production is deploying." : words(done.error), done.ok ? "success" : "danger");
133 load();
134 });
135 },
136 }),
137 );
138 return true;
139 }
140 return false;
141 },
142 };
143 return screen;
144 }
145
146 /** The person's builds; `n` starts one from a prompt, `t` chooses the template it starts from. */
147 export function talkBuildScreen(f: Session, ctx: Ctx): Screen {
148 const state = new ListState();
149 let note = "";
150 // "" is the forge's default template; the rest are those on offer to this person.
151 let template = "";
152 const items = () => (f.user === undefined ? [] : buildsOf(f.app.db, f.user.id));
153 const screen: Screen = {
154 title: "Build an app",
155 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["n"], "new build"), binding(["t"], "template"), binding(["esc"], "back")],
156 render(area, s) {
157 const c = ctx.theme.c;
158 const [head, body] = split(area, "v", [len(1), fill(1)]);
159 const text = note !== "" ? note : `Starting from ${template === "" ? "the default template" : template}; n says what to build, t changes the template.`;
160 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
161 const rows: ListRow[] = items().map((b) => ({
162 title: b.title === "" ? "Untitled build" : b.title,
163 secondary: (() => {
164 const repo = getRepoById(f.app.db, b.repoId);
165 return repo === undefined ? "" : `${repo.ownerName}/${repo.name}`;
166 })(),
167 meta: `${b.published ? "published · " : ""}${ago(b.updatedAt)}`,
168 }));
169 state.clamp(rows.length);
170 drawList(s, body!, c, state, rows);
171 },
172 key(e) {
173 if (state.key(e, items().length)) return true;
174 const user = f.user;
175 if (user === undefined || e.ctrl || e.alt) return false;
176 if (e.key === "esc" || e.key === "q") {
177 ctx.pop();
178 return true;
179 }
180 if (e.key === "t") {
181 const on = ["", ...offeredTemplates(f.app.db, user)];
182 template = on[(on.indexOf(template) + 1) % on.length] ?? "";
183 ctx.invalidate();
184 return true;
185 }
186 if (e.key === "n") {
187 ctx.push(
188 promptScreen(ctx, screen, {
189 title: "Build an app",
190 label: "What do you want to build?",
191 onSubmit: (prompt) => {
192 if (prompt.trim() === "") return "Say what to build.";
193 runAsync(ctx, async () => {
194 const started = await startBuild(f.app, user, { prompt, ...(template === "" ? {} : { template }) });
195 note = started.ok ? (started.value.said === undefined ? "Started; Enter opens it." : `The app is made, but ${TALK_WORDS[started.value.said][1]}.`) : words(started.error);
196 ctx.invalidate();
197 });
198 return undefined;
199 },
200 }),
201 );
202 return true;
203 }
204 const at = items()[state.index];
205 if (e.key === "enter" && at !== undefined) {
206 ctx.push(buildScreen(f, ctx, at.conversationId, at.title === "" ? "Build" : at.title));
207 return true;
208 }
209 return false;
210 },
211 };
212 return screen;
213 }
src/web/pages/talk.ts +11 −3
@@ -54,6 +54,8 @@ import { html, raw, type Html } from "../html.ts";
5454 import { flashTo, page, redirect } from "../layout.ts";
5555 import { renderMarkdown } from "../markdown.ts";
5656 import { badge, btn, row, time } from "../ui.ts";
57 import { buildBox, buildCss, chosenTemplate, panelFor } from "../talk_build.ts";
58 import { chatBuildOf } from "../../core/chat_build.ts";
5759 import { stepOf, repoOf, type Step } from "../../core/talk_steps.ts";
5860 import { VIEWS, talkPane, type View } from "./talk_preview.ts";
5961 import { talkCss, talkJs } from "./talk_client.ts";
@@ -285,7 +287,7 @@ function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean, type
285287 </div>`;
286288 }
287289
288 function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefined, prefill: string, latest: boolean, edit: boolean): Response {
290 async function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefined, prefill: string, latest: boolean, edit: boolean): Promise<Response> {
289291 const user = ctx.auth.user!;
290292 let before = before0;
291293 const here = latest ? HERE : urlOf(conversation);
@@ -313,11 +315,16 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi
313315 const acts = !paged ? exchangeActs(ctx.app.db, user.id, conversation.id, running, here) : new Map<string, Html>();
314316 // Edit shows the composer holding the last message, posting to Edit instead of Send.
315317 const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined;
318 // A conversation that builds an app shows its versions and preview; an empty one offers the one box (#11010).
319 const building = chatBuildOf(ctx.app.db, conversation.id) !== undefined;
320 const panel = building ? await panelFor(ctx, conversation.id, here) : null;
321 const box = !building && !paged && turns.length === 0 ? buildBox(ctx.app.db, user, chosenTemplate(ctx.app.db, user, ctx.url.searchParams.get("template"))) : null;
316322 const asked = ctx.url.searchParams.get("pane");
317323 const view: View | undefined = VIEWS.find((p) => p === asked);
318324 const shown = view ?? "chat";
319325 const pane = talkPane(ctx, user, repoOf(turns), works, view);
320326 const body = html`<h1 class="sr-only">Talk</h1>
327 ${box}
321328 <div class="talk-shell" data-shell data-pane="${shown}">
322329 <div class="talk-side">${chats(ctx, conversation, showArchived, typed)}</div>
323330 <nav class="seg talk-tabs" aria-label="Chat or preview"><a href="?pane=chat"${shown === "chat" ? raw(' aria-current="page"') : null}>Chat</a><a href="?pane=preview"${shown !== "chat" ? raw(' aria-current="page"') : null}>Preview</a></nav>
@@ -356,8 +363,9 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi
356363 </form>
357364 </div>
358365 ${pane.html}
366 ${panel}
359367 </div>`;
360 return page({ ctx, title: "Talk", body, styleCss: talkCss, scriptJs: talkJs, description: "Talk to the forge.", ...(pane.frame === undefined ? {} : { frameSrc: pane.frame }) });
368 return page({ ctx, title: "Talk", body, styleCss: panel === null && box === null ? talkCss : talkCss + buildCss, scriptJs: talkJs, description: "Talk to the forge.", ...(pane.frame === undefined ? {} : { frameSrc: pane.frame }) });
361369 }
362370
363371 /** The conversation a request names (`:id`), or the person's latest when it names none; undefined when it is not theirs. */
@@ -368,7 +376,7 @@ const conversationOf = (ctx: Ctx, user: { readonly id: string }): Conversation |
368376 };
369377
370378 // GET /talk and GET /talk/:id
371 export function talkView(ctx: Ctx): Response {
379 export function talkView(ctx: Ctx): Response | Promise<Response> {
372380 if (ctx.auth.user === undefined) return signInFirst(ctx);
373381 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
374382 const before = ctx.url.searchParams.get("before");
src/web/talk_build.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * Talk builds an app, on the page (#11010): the one box on an empty Talk
3 * ("What do you want to build?", with starters from the marked templates),
4 * and beside a building conversation its versions, the chosen version's diff
5 * with Restore, the preview, and Publish. All plain forms and links: it
6 * ships no script.
7 */
8
9 import { buildState, BUILD_WORDS, publishBuild, restoreVersion, startBuild, versionDiff, type BuildState } from "../actions/build.ts";
10 import { TALK_WORDS } from "../actions/talk.ts";
11 import { offeredTemplates } from "../core/app_templates.ts";
12 import { WORDS_MAX } from "../core/fence.ts";
13 import { getConversation } from "../core/conversations.ts";
14 import type { Database } from "bun:sqlite";
15 import type { Diff } from "../git/diff.ts";
16 import type { Ctx } from "../http/context.ts";
17 import { signInFirst, notFoundPage } from "./chrome.ts";
18 import { readForm } from "./form.ts";
19 import { html, type Html } from "./html.ts";
20 import { flashTo, redirect } from "./layout.ts";
21 import { btn, time } from "./ui.ts";
22 import type { User } from "../core/users.ts";
23
24 const TEMPLATE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
25 const ID = /^[0-9a-z]{1,40}$/;
26 /** Starters offered under the box. */
27 const STARTERS_MAX = 4;
28
29 /** The template the person chose with a starter link, if it is one on offer to them. */
30 export function chosenTemplate(db: Database, user: User, asked: string | null): string | undefined {
31 return asked !== null && TEMPLATE.test(asked) && offeredTemplates(db, user).includes(asked) ? asked : undefined;
32 }
33
34 /** The empty Talk's one box: what to build, starters that choose a template, one Send. */
35 export function buildBox(db: Database, user: User, chosen: string | undefined): Html {
36 const starters = offeredTemplates(db, user).slice(0, STARTERS_MAX);
37 return html`<section class="talk-build-box" aria-labelledby="build-q">
38 <h2 id="build-q">What do you want to build?</h2>
39 <form method="post" action="/talk/build">
40 ${chosen === undefined ? null : html`<input type="hidden" name="template" value="${chosen}" />`}
41 <textarea class="input" name="prompt" rows="3" maxlength="${WORDS_MAX}" required aria-labelledby="build-q" placeholder="A tip calculator for friends"></textarea>
42 <div class="bar">
43 ${chosen === undefined ? null : html`<span class="small muted">Starting from ${chosen}</span>`}
44 <span class="spacer"></span>
45 ${btn("Build it", { variant: "primary" })}
46 </div>
47 </form>
48 ${starters.length === 0
49 ? null
50 : html`<ul class="talk-build-starters small" aria-label="Start from">${starters.map((t) => html`<li><a href="/talk?template=${encodeURIComponent(t)}" ${t === chosen ? html`aria-current="true"` : null}>${t}</a></li>`)}</ul>`}
51 </section>`;
52 }
53
54 /** What a diff changed, file by file; the lines themselves are one step deeper, on the pull. */
55 function diffHtml(diff: Diff): Html {
56 return diff.files.length === 0
57 ? html`<p class="small muted">This version changed no files.</p>`
58 : html`<ul class="talk-build-files small">${diff.files.map((f) => html`<li><code>${f.path}</code> <span class="muted">${f.status}, +${f.additions} −${f.deletions}</span></li>`)}</ul>`;
59 }
60
61 /** The versions, the chosen one's diff and Restore, the preview, Publish. */
62 export function buildPanel(state: BuildState, base: string, chosen: number | undefined, diff: Diff | undefined): Html {
63 const newest = state.versions.at(-1);
64 return html`<aside class="talk-build" aria-label="The app being built" data-build>
65 <p class="bar"><strong>${state.repoName}</strong>
66 ${state.published
67 ? html`<span class="badge">published</span>`
68 : state.previewUrl === null
69 ? html`<span class="small muted">${newest === undefined ? "No version yet: the first change makes v1." : "Preview is on its way."}</span>`
70 : html`<a href="${state.previewUrl}" target="_blank" rel="noopener noreferrer">Preview${newest === undefined ? "" : ` v${newest.number}`}${state.previewState === null ? "" : ` (${state.previewState})`}</a>`}</p>
71 ${state.productionUrl === null
72 ? null
73 : html`<p class="small"><a href="${state.productionUrl}" rel="noopener noreferrer">${state.productionUrl}</a></p>`}
74 ${state.versions.length === 0
75 ? null
76 : html`<ol class="talk-build-versions" reversed aria-label="Versions">${[...state.versions].reverse().map(
77 (v) => html`<li ${v.number === chosen ? html`aria-current="true"` : null}><a href="${base}?v=${v.number}">v${v.number}</a> ${v.subject} <span class="small muted">${time(v.date)}</span></li>`,
78 )}</ol>`}
79 ${chosen === undefined || diff === undefined
80 ? null
81 : html`<div class="talk-build-chosen"><div class="bar"><strong>v${chosen}</strong>
82 <span class="spacer"></span>
83 <form method="post" action="${base}/build/restore"><input type="hidden" name="version" value="${chosen}" />${btn("Restore", { label: `Make a new version from v${chosen}` })}</form></div>
84 ${diffHtml(diff)}</div>`}
85 ${state.published || state.versions.length === 0
86 ? null
87 : html`<form method="post" action="${base}/build/publish">${btn("Publish", { label: "Publish: merge to main and deploy production" })}</form>`}
88 </aside>`;
89 }
90
91 /** The panel for a conversation that builds, or null when it does not (or is not the person's). */
92 export async function panelFor(ctx: Ctx, conversationId: string, base: string): Promise<Html | null> {
93 const state = await buildState(ctx.app, ctx.auth.user, conversationId);
94 if (!state.ok) return null;
95 const v = Number(ctx.url.searchParams.get("v"));
96 const chosen = Number.isInteger(v) && v >= 1 && state.value.versions.some((x) => x.number === v) ? v : undefined;
97 const diff = chosen === undefined ? undefined : await versionDiff(ctx.app, ctx.auth.user, conversationId, chosen);
98 return buildPanel(state.value, base, chosen, diff?.ok ? diff.value : undefined);
99 }
100
101 // POST /talk/build — one prompt makes the app and opens its chat.
102 export async function talkBuild(ctx: Ctx): Promise<Response> {
103 const user = ctx.auth.user;
104 if (user === undefined) return signInFirst(ctx);
105 const form = await readForm(ctx, "rw");
106 if (form instanceof Response) return form;
107 const prompt = form.get("prompt");
108 const template = form.get("template");
109 if (typeof prompt !== "string" || prompt.trim() === "" || prompt.length > WORDS_MAX)
110 return flashTo(ctx, "/talk", { tone: "danger", message: `Say what to build, up to ${WORDS_MAX} characters.` });
111 const from = typeof template === "string" ? chosenTemplate(ctx.app.db, user, template) : undefined;
112 const started = await startBuild(ctx.app, user, { prompt, ...(from === undefined ? {} : { template: from }) });
113 if (!started.ok) return flashTo(ctx, "/talk", { tone: "danger", message: BUILD_WORDS[started.error] ?? "Not started." });
114 const to = `/talk/${started.value.build.conversationId}`;
115 return started.value.said === undefined ? redirect(to) : flashTo(ctx, to, { tone: "danger", message: `The app is made, but ${TALK_WORDS[started.value.said][1]}.` });
116 }
117
118 /** The conversation a build request names, when it is the person's. */
119 function mine(ctx: Ctx): string | undefined {
120 const id = ctx.params["id"];
121 return ctx.auth.user !== undefined && id !== undefined && ID.test(id) && getConversation(ctx.app.db, ctx.auth.user.id, id) !== undefined ? id : undefined;
122 }
123
124 // POST /talk/:id/build/restore
125 export async function talkRestore(ctx: Ctx): Promise<Response> {
126 const user = ctx.auth.user;
127 if (user === undefined) return signInFirst(ctx);
128 const form = await readForm(ctx, "rw");
129 if (form instanceof Response) return form;
130 const id = mine(ctx);
131 if (id === undefined) return notFoundPage(ctx);
132 const n = Number(form.get("version"));
133 const restored = Number.isInteger(n) && n >= 1 ? await restoreVersion(ctx.app, user, id, n) : undefined;
134 return restored?.ok === true
135 ? flashTo(ctx, `/talk/${id}`, { tone: "success", message: `Restored v${n} as a new version.` })
136 : flashTo(ctx, `/talk/${id}`, { tone: "danger", message: BUILD_WORDS[restored?.ok === false ? restored.error : "no_version"] ?? "Not restored." });
137 }
138
139 // POST /talk/:id/build/publish
140 export async function talkPublish(ctx: Ctx): Promise<Response> {
141 const user = ctx.auth.user;
142 if (user === undefined) return signInFirst(ctx);
143 const form = await readForm(ctx, "rw");
144 if (form instanceof Response) return form;
145 const id = mine(ctx);
146 if (id === undefined) return notFoundPage(ctx);
147 const published = await publishBuild(ctx.app, user, id);
148 return published.ok
149 ? flashTo(ctx, `/talk/${id}`, { tone: "success", message: "Published. Production is deploying." })
150 : flashTo(ctx, `/talk/${id}`, { tone: "danger", message: BUILD_WORDS[published.error] ?? "Not published." });
151 }
152
153 /** Styles for the box and the panel; added to Talk's own only on a page that draws one. Spacing is the 4px scale. */
154 export const buildCss = /* css */ `
155 .talk-build-box, .talk-build { max-width: min(48rem, 100%); margin: 0 auto 16px; display: grid; gap: 8px; }
156 .talk-build-box textarea { width: 100%; }
157 .talk-build-starters, .talk-build-versions, .talk-build-files { margin: 0; padding-left: 24px; }
158 .talk-build-starters { display: flex; flex-wrap: wrap; gap: 4px 16px; padding-left: 0; list-style: none; }
159 .talk-build-versions [aria-current] { font-weight: 600; }
160 `;
test/pages-budget.test.ts +10
@@ -16,6 +16,8 @@ import { tmpdir } from "node:os";
1616 import { join } from "node:path";
1717 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1818 import { createApp, type App } from "../src/app.ts";
19 import { startChatBuild } from "../src/core/chat_build.ts";
20 import { createConversation } from "../src/core/conversations.ts";
1921 import { createIssue } from "../src/core/issues.ts";
2022 import { pin } from "../src/core/pins.ts";
2123 import { getRepo } from "../src/core/repos.ts";
@@ -133,6 +135,14 @@ beforeAll(async () => {
133135 const pull = createIssue(app.db, { repoId: forge.id, authorId: bob.id, title: "A pull", body: "body", kind: "pull" });
134136 if (!pull.ok) throw new Error(pull.error);
135137 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'topic', '', 'h')", [pull.value.id]);
138 // A conversation that builds, with a version on its branch, for the two build reads.
139 const talk = createConversation(app.db, alice.id);
140 if (!talk.ok) throw new Error(talk.error);
141 startChatBuild(app.db, talk.value.id, forge.id);
142 const version = await commitToBranch(app, alice, forge, { branch: `talk-${talk.value.id}`, message: "A version", files: [{ path: "page.txt", content: "v1\n" }] } as never);
143 if (!version.ok) throw new Error(JSON.stringify(version.error));
144 FIXTURE["/api/v1/conversations/:id/build"] = `/api/v1/conversations/${talk.value.id}/build`;
145 FIXTURE["/api/v1/conversations/:id/build/versions/:n/diff"] = `/api/v1/conversations/${talk.value.id}/build/versions/1/diff`;
136146 // An agent's start the claim page can show (`/claim/start/:token`): bob's, so no extra account joins the walk.
137147 recordStart(app.db, bob.id, "claim_fixture", "127.0.0.1");
138148 servers = await serve(app);
test/palette-roster.test.ts +2
@@ -89,6 +89,7 @@ test("a site-admin person is offered the whole roster", () => {
8989 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
9090 ["trusted", "Trusted sources", "who folds in without review; a trusts, x removes, s stops all, v reverts"],
9191 ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"],
92 ["build-app", "Build an app", "say what to build; versions, diff, restore and publish. n starts one"],
9293 ["ssh-keys", "Computer keys", "list, add and remove the keys that sign in as you"],
9394 ["passkeys", "Passkeys", "the passkeys that sign in as you"],
9495 ["account", "Account settings", undefined],
@@ -300,6 +301,7 @@ test("a member person is offered the signed-in roster without the admin entries"
300301 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
301302 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
302303 ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"],
304 ["build-app", "Build an app", "say what to build; versions, diff, restore and publish. n starts one"],
303305 ["ssh-keys", "Computer keys", "list, add and remove the keys that sign in as you"],
304306 ["passkeys", "Passkeys", "the passkeys that sign in as you"],
305307 ["account", "Account settings", undefined],
test/security-floor-routes.test.ts +11 −3
@@ -28,6 +28,8 @@ import { createRun } from "../src/core/runs.ts";
2828 import { createSession } from "../src/core/sessions.ts";
2929 import { repoDir } from "../src/git/repos.ts";
3030 import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts";
31 import { startChatBuild } from "../src/core/chat_build.ts";
32 import { createConversation } from "../src/core/conversations.ts";
3133 import { seedAdmin } from "./helpers/admin.ts";
3234 import { quietLogs } from "./helpers/globals.ts";
3335 import { pool } from "./helpers/sweep-clock.ts";
@@ -35,7 +37,7 @@ import { pool } from "./helpers/sweep-clock.ts";
3537 quietLogs();
3638
3739 /** Parameters the seed supplies; any other parameter makes a pattern unseeded. */
38 const SEEDED = new Set(["owner", "name", "username", "number", "ref", "oid", "path", "env", "slug", "spec", "machine"]);
40 const SEEDED = new Set(["owner", "name", "username", "number", "ref", "oid", "path", "env", "slug", "spec", "machine", "n"]);
3941
4042 const ORIGIN = "needs a connected origin account and a mirror job; the seed connects none";
4143 const MIGRATE = "needs a migration job, which only an import creates";
@@ -174,6 +176,11 @@ beforeAll(async () => {
174176 path: "a.txt", env: "prod", slug: "notes", spec: "192", machine: "standard",
175177 issue: String(issue.value.number), pull: String(pull.value.number), run: run.id,
176178 });
179 // A conversation that builds, with the version its branch holds, for the build reads.
180 const talk = createConversation(app.db, ada.id);
181 if (!talk.ok) throw new Error(talk.error);
182 startChatBuild(app.db, talk.value.id, repo.id);
183 fill.conversation = talk.value.id;
177184 cookie = `forge_session=${createSession(app.db, ada.id).token}`;
178185 }, 30_000);
179186
@@ -191,7 +198,8 @@ const getPatterns = (): string[] =>
191198
192199 function pathFor(pattern: string): string {
193200 const number = pattern.includes("/pulls/") ? fill.pull : fill.issue;
194 const runId = pattern.includes("/runs/:id") ? fill.run : "x";
201 const runId = pattern.includes("/runs/:id") ? fill.run : pattern.includes("/conversations/:id/build") ? fill.conversation : "x";
202 if (pattern.includes("/build/versions/:n/")) fill.n = "1";
195203 return pattern
196204 .replace(/[:*]([a-z_]+)/g, (_, key: string) => {
197205 if (key === "number") return number!;
@@ -204,7 +212,7 @@ function pathFor(pattern: string): string {
204212
205213 const unseeded = (pattern: string): boolean =>
206214 paramsOf(pattern).some(
207 (key) => !SEEDED.has(key) && !(key === "id" && pattern.includes("/runs/:id")) && !(key === "file" && pattern.includes("/data/:env/:file/")),
215 (key) => !SEEDED.has(key) && !(key === "id" && (pattern.includes("/runs/:id") || pattern.includes("/conversations/:id/build"))) && !(key === "file" && pattern.includes("/data/:env/:file/")),
208216 );
209217
210218 /**
test/talk-build.test.ts added +280
@@ -0,0 +1,280 @@
1 /**
2 * Talk builds an app (#11010): prompt, repository from a template, v1, v2,
3 * restore v1, publish: the whole path through the API against a scratch forge.
4 * The agent is a stand-in that answers; its code changes are commits the test
5 * makes on the branch the conversation names, as the real agent would.
6 */
7
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { chmodSync, readFileSync, writeFileSync } from "node:fs";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { setRole } from "../src/actions/roles.ts";
14 import { setTemplateMark } from "../src/core/app_templates.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { createApp, type App } from "../src/app.ts";
17 import { BUILDS_PER_HOUR, UNPUBLISHED_MAX } from "../src/actions/build.ts";
18 import { chatBuildOf } from "../src/core/chat_build.ts";
19 import { getRepo } from "../src/core/repos.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { configureConversation } from "../src/crew/conversation.ts";
23 import { readerFor } from "../src/git/read.ts";
24 import { repoDir } from "../src/git/repos.ts";
25 import { serve, type Servers } from "../src/http/server.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27
28 quietLogs();
29
30 let dir: string;
31 let app: App;
32 let servers: Servers;
33 let token: string;
34 let alice: User;
35
36 const headers = (t: string) => ({ "content-type": "application/json", authorization: `Bearer ${t}` });
37 const call = async (method: string, path: string, body?: unknown, t: string | null = token) => {
38 const res = await fetch(`${servers.url}${path}`, { method, headers: t === null ? { "content-type": "application/json" } : headers(t), ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
39 return { status: res.status, body: (await res.json()) as Record<string, any> };
40 };
41 const settle = async (conversation: string) => {
42 for (let i = 0; i < 200; i++) {
43 const res = await fetch(`${servers.url}/api/v1/conversation/turns?conversation=${conversation}`, { headers: headers(token) });
44 if (!((await res.json()) as { running: boolean }).running) return;
45 await Bun.sleep(25);
46 }
47 throw new Error("the turn never finished");
48 };
49 /** What the agent does in a turn that changes code: one commit on the chat's branch. */
50 const agentCommits = async (repo: string, branch: string, message: string, content: string) => {
51 const [owner, name] = repo.split("/") as [string, string];
52 const r = getRepo(app.db, owner, name)!;
53 const done = await commitToBranch(app, alice, r, { branch, message, files: [{ path: "page.txt", content }] });
54 if (!done.ok) throw new Error(JSON.stringify(done.error));
55 };
56 const read = async (repo: string, ref: string, path: string) => {
57 const [owner, name] = repo.split("/") as [string, string];
58 const o = await readerFor(repoDir(app.reposRoot, owner, name)).object(`${ref}:${path}`);
59 return o.ok ? new TextDecoder().decode(o.value.body) : undefined;
60 };
61
62 beforeAll(async () => {
63 dir = await mkdtemp(join(tmpdir(), "forge-talk-build-"));
64 app = createApp({ dataDir: dir, port: 0, host: "forge.test" });
65 servers = await serve(app);
66 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
67 if (!made.ok) throw new Error(made.error);
68 alice = made.value;
69 token = createToken(app.db, alice.id, "personal", "rw").token;
70 const bin = join(dir, "claude");
71 writeFileSync(
72 bin,
73 `#!/bin/sh
74 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
75 IFS= read -r first
76 printf '%s' "$first" > "${dir}/first.json"
77 echo '{"type":"system","subtype":"init","session_id":"s1","model":"fake"}'
78 echo '{"type":"result","subtype":"success","is_error":false,"result":"Built.","total_cost_usd":0.01,"num_turns":1,"session_id":"s1","terminal_reason":"completed","usage":{"input_tokens":1,"output_tokens":1}}'
79 `,
80 );
81 chmodSync(bin, 0o755);
82 configureConversation(app, { binary: bin, localUrl: servers.url, budgetUsd: 1, timeoutMs: 30_000, signal: new AbortController().signal });
83 });
84 afterAll(async () => {
85 await servers.stop();
86 await rm(dir, { recursive: true, force: true });
87 });
88
89 test("prompt, repo, v1, v2, restore v1, publish", async () => {
90 // A template to build from.
91 const tpl = await createRepo(app, alice, { name: "kit", visibility: "public", description: "" });
92 if (!tpl.ok) throw new Error(tpl.error);
93 const seeded = await commitToBranch(app, alice, tpl.value, { message: "t", files: [{ path: "README.md", content: "# kit\n" }] });
94 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
95
96 setTemplateMark(app.db, tpl.value, true, alice.id);
97
98 // The prompt makes the repository from the template and starts the chat in it.
99 const started = await call("POST", "/api/v1/builds", { prompt: "a tip calculator for friends", template: "alice/kit" });
100 expect(started.status).toBe(201);
101 const { conversation, repo, branch } = started.body as { conversation: string; repo: string; branch: string };
102 expect(repo).toBe("alice/tip-calculator");
103 expect(branch).toBe(`talk-${conversation}`);
104 expect(await read(repo, "refs/heads/main", "README.md")).toBe("# kit\n");
105 expect(chatBuildOf(app.db, conversation)?.repoId).toBe(getRepo(app.db, "alice", "tip-calculator")!.id);
106 await settle(conversation);
107
108 // The agent is told where to work, with the person's words after it.
109 const first = readFileSync(join(dir, "first.json"), "utf8");
110 expect(first).toContain(`on the branch ${branch}`);
111 expect(first).toContain("a tip calculator for friends");
112
113 // No code changed yet: no version.
114 expect((await call("GET", `/api/v1/conversations/${conversation}/build`)).body["versions"]).toEqual([]);
115
116 // v1 and v2.
117 await agentCommits(repo, branch, "Add the tip form", "one\n");
118 await agentCommits(repo, branch, "Round tips up", "two\n");
119 const two = (await call("GET", `/api/v1/conversations/${conversation}/build`)).body;
120 expect(two["versions"].map((v: { number: number; subject: string }) => `v${v.number} ${v.subject}`)).toEqual(["v1 Add the tip form", "v2 Round tips up"]);
121 // A read changes nothing: the pull that previews the branch is not opened by looking.
122 expect(chatBuildOf(app.db, conversation)?.pullNumber).toBeNull();
123
124 // A version's diff is what that turn changed.
125 const diff = (await call("GET", `/api/v1/conversations/${conversation}/build/versions/2/diff`)).body;
126 expect(JSON.stringify(diff)).toContain("two");
127 expect((await call("GET", `/api/v1/conversations/${conversation}/build/versions/9/diff`)).status).toBe(404);
128
129 // Restore v1 makes v3 holding v1's files; v1 and v2 stay.
130 const restored = await call("POST", `/api/v1/conversations/${conversation}/build/restore`, { version: 1 });
131 expect(restored.status).toBe(201);
132 expect(await read(repo, `refs/heads/${branch}`, "page.txt")).toBe("one\n");
133 // Restore is a write by a person who may write: it gives the branch its pull.
134 expect(chatBuildOf(app.db, conversation)?.pullNumber).toBeGreaterThan(0);
135 const three = (await call("GET", `/api/v1/conversations/${conversation}/build`)).body;
136 expect(three["versions"].map((v: { subject: string }) => v.subject)).toEqual(["Add the tip form", "Round tips up", "Restore v1"]);
137 expect((await call("POST", `/api/v1/conversations/${conversation}/build/restore`, { version: 7 })).status).toBe(404);
138
139 // Main has not moved until the person publishes.
140 expect(await read(repo, "refs/heads/main", "page.txt")).toBeUndefined();
141 const published = await call("POST", `/api/v1/conversations/${conversation}/build/publish`);
142 expect(published.status).toBe(200);
143 expect(await read(repo, "refs/heads/main", "page.txt")).toBe("one\n");
144 expect((await call("GET", `/api/v1/conversations/${conversation}/build`)).body["published"]).toBe(true);
145 });
146
147 test("a build is its owner's alone, and every change is refused without sign-in or a same-origin browser", async () => {
148 const mine = await call("POST", "/api/v1/builds", { prompt: "a private notebook" });
149 const id = (mine.body as { conversation: string }).conversation;
150 expect((await call("GET", `/api/v1/conversations/${id}/build`, undefined, null)).status).toBe(401);
151 expect((await call("POST", "/api/v1/builds", { prompt: "x" }, null)).status).toBe(401);
152 const other = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
153 if (!other.ok) throw new Error(other.error);
154 const bob = createToken(app.db, other.value.id, "personal", "rw").token;
155 expect((await call("GET", `/api/v1/conversations/${id}/build`, undefined, bob)).status).toBe(404);
156 expect((await call("POST", `/api/v1/conversations/${id}/build/publish`, undefined, bob)).status).toBe(404);
157 expect((await call("POST", `/api/v1/conversations/${id}/build/publish`)).status).toBe(409);
158 expect((await call("POST", "/api/v1/builds", { prompt: "" })).status).toBe(422);
159 expect((await call("POST", "/api/v1/builds", { prompt: "x", template: "../a" })).status).toBe(422);
160 const cross = await fetch(`${servers.url}/api/v1/builds`, { method: "POST", headers: { ...headers(token), origin: "https://evil.test" }, body: JSON.stringify({ prompt: "a thing" }) });
161 expect(cross.status).toBe(403);
162 });
163
164 test("the page: one box on an empty Talk, then the versions, Restore and Publish as plain forms", async () => {
165 const { createSession } = await import("../src/core/sessions.ts");
166 const cookie = `forge_session=${createSession(app.db, alice.id).token}`;
167 const get = (path: string) => fetch(`${servers.url}${path}`, { redirect: "manual", headers: { cookie } });
168 const post = (path: string, form: Record<string, string>) =>
169 fetch(`${servers.url}${path}`, { method: "POST", redirect: "manual", headers: { origin: servers.url, cookie }, body: new URLSearchParams(form) });
170
171 const empty = await (await get("/talk/new-never")).status;
172 expect(empty).toBe(404);
173 const fresh = await call("POST", "/api/v1/conversations", {});
174 const home = await (await get(`/talk/${fresh.body["conversation"].id}`)).text();
175 expect(home).toContain("What do you want to build?");
176 expect(home).toContain('action="/talk/build"');
177
178 const sent = await post("/talk/build", { prompt: "a recipe box" });
179 expect(sent.status).toBe(303);
180 const to = sent.headers.get("location")!;
181 expect(to).toMatch(/^\/talk\/[0-9a-z]+$/);
182 const id = to.slice("/talk/".length);
183 await settle(id);
184 const none = await (await get(to)).text();
185 expect(none).toContain("alice/recipe-box");
186 expect(none).not.toContain("What do you want to build?");
187 expect(none).not.toContain("/build/publish");
188
189 const { branch, repo } = (await call("GET", `/api/v1/conversations/${id}/build`)).body as { branch: string; repo: string };
190 await agentCommits(repo, branch, "Add recipe list", "one\n");
191 const one = await (await get(`${to}?v=1`)).text();
192 expect(one).toContain("v1");
193 expect(one).toContain("page.txt");
194 expect(one).toContain(`action="${to}/build/restore"`);
195 expect(one).toContain(`action="${to}/build/publish"`);
196
197 expect((await post(`${to}/build/restore`, { version: "1" })).status).toBe(303);
198 expect((await call("GET", `/api/v1/conversations/${id}/build`)).body["versions"].length).toBe(2);
199 expect((await post(`${to}/build/publish`, {})).status).toBe(303);
200 const done = await (await get(to)).text();
201 expect(done).toContain("published");
202 // Another person's build is not found, and a cross-origin form is refused.
203 expect((await post("/talk/nope/build/publish", {})).status).toBe(404);
204 const cross = await fetch(`${servers.url}${to}/build/publish`, { method: "POST", redirect: "manual", headers: { origin: "https://evil.test", cookie }, body: new URLSearchParams({}) });
205 expect(cross.status).toBe(403);
206 });
207
208 const makeUser = async (username: string) => {
209 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" });
210 if (!made.ok) throw new Error(made.error);
211 setRole(app, alice, username, "member");
212 return { user: made.value, token: createToken(app.db, made.value.id, "personal", "rw").token };
213 };
214
215 test("a read never writes: looking at a build with a version, even on a read-only token, opens no pull", async () => {
216 const { user, token: rw } = await makeUser("reader");
217 const ro = createToken(app.db, user.id, "personal", "ro").token;
218 const started = await call("POST", "/api/v1/builds", { prompt: "a reading list" }, rw);
219 const { conversation, repo, branch } = started.body as { conversation: string; repo: string; branch: string };
220 const [owner, name] = repo.split("/") as [string, string];
221 const done = await commitToBranch(app, user, getRepo(app.db, owner, name)!, { branch, message: "Add list", files: [{ path: "page.txt", content: "x\n" }] });
222 if (!done.ok) throw new Error(JSON.stringify(done.error));
223 const before = JSON.stringify(chatBuildOf(app.db, conversation));
224 for (const t of [ro, rw]) {
225 const state = await call("GET", `/api/v1/conversations/${conversation}/build`, undefined, t);
226 expect(state.body["versions"].length).toBe(1);
227 expect((await call("GET", `/api/v1/conversations/${conversation}/build/versions/1/diff`, undefined, t)).status).toBe(200);
228 }
229 expect(JSON.stringify(chatBuildOf(app.db, conversation))).toBe(before);
230 expect(chatBuildOf(app.db, conversation)?.pullNumber).toBeNull();
231 // The read-only token cannot Restore or Publish either.
232 expect((await call("POST", `/api/v1/conversations/${conversation}/build/publish`, undefined, ro)).status).toBe(403);
233 });
234
235 test("a template that is not on offer to the person is refused, so the API cannot read a private repository as one", async () => {
236 const owner = await makeUser("vault");
237 const secret = await createRepo(app, owner.user, { name: "secret-kit", visibility: "private", description: "" });
238 if (!secret.ok) throw new Error(secret.error);
239 setTemplateMark(app.db, secret.value, true, owner.user.id);
240 const open = await createRepo(app, owner.user, { name: "plain-kit", visibility: "public", description: "" });
241 if (!open.ok) throw new Error(open.error);
242 const asker = await makeUser("asker");
243 const private_ = await call("POST", "/api/v1/builds", { prompt: "a copy", template: "vault/secret-kit" }, asker.token);
244 expect(private_.status).toBe(422);
245 expect(private_.body["error"]).toBe("bad_template");
246 // A readable repository nobody marked as a template is not on offer either.
247 expect((await call("POST", "/api/v1/builds", { prompt: "a copy", template: "vault/plain-kit" }, asker.token)).status).toBe(422);
248 expect(getRepo(app.db, "asker", "copy")).toBeUndefined();
249 });
250
251 test("builds are limited per person, and a refused start leaves no repository or conversation behind", async () => {
252 const { listConversations } = await import("../src/core/conversations.ts");
253 const person = await makeUser("hasty");
254 const held = () => listConversations(app.db, person.user.id).length;
255 // A prompt with no name to derive is refused, and takes back what it made.
256 expect((await call("POST", "/api/v1/builds", { prompt: "!!!" }, person.token)).status).toBe(422);
257 expect(held()).toBe(0);
258 for (let i = 0; i < BUILDS_PER_HOUR; i++) expect((await call("POST", "/api/v1/builds", { prompt: `quick idea number ${"x".repeat(i + 1)}` }, person.token)).status).toBe(201);
259 const refused = await call("POST", "/api/v1/builds", { prompt: "one idea too many" }, person.token);
260 expect(refused.status).toBe(429);
261 expect(held()).toBe(BUILDS_PER_HOUR);
262 // Another person's budget is their own.
263 const other = await makeUser("calm");
264 expect((await call("POST", "/api/v1/builds", { prompt: "a calm idea" }, other.token)).status).toBe(201);
265 });
266
267 test("a person may hold only so many builds that are not published", async () => {
268 const { createConversation } = await import("../src/core/conversations.ts");
269 const { startChatBuild } = await import("../src/core/chat_build.ts");
270 const person = await makeUser("hoarder");
271 const anyRepo = getRepo(app.db, "alice", "kit")!;
272 for (let i = 0; i < UNPUBLISHED_MAX; i++) {
273 const c = createConversation(app.db, person.user.id);
274 if (!c.ok) throw new Error("conversation");
275 startChatBuild(app.db, c.value.id, anyRepo.id);
276 }
277 const refused = await call("POST", "/api/v1/builds", { prompt: "an eleventh idea" }, person.token);
278 expect(refused.status).toBe(429);
279 expect(refused.body["error"]).toBe("too_many_builds");
280 });
test/tui-talk-build.test.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * The terminal's Build an app screen (#11010): one prompt, the versions, a
3 * version's files, Restore and Publish, through the calls the page uses, so
4 * what is done here is what the page reads next.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { versionsIn } from "../src/actions/build_pull.ts";
11 import { commitToBranch } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { buildsOf, chatBuildOf } from "../src/core/chat_build.ts";
14 import { getRepo, getRepoById } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { readerFor } from "../src/git/read.ts";
17 import { repoDir } from "../src/git/repos.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { settleApp } from "./helpers/tui.ts";
21 import { proveAll } from "./helpers/verified.ts";
22
23 let dir: string;
24 let app: App;
25 let alice: User;
26 const open = [":", "Build an app", "\r"];
27
28 async function drive(user: User, keys: string[]): Promise<string> {
29 const terminal = headlessTerminal(120, 30);
30 const { app: tui } = createForgeApp(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, { terminal, exitHooks: false });
31 tui.start();
32 await settleApp(tui);
33 for (const k of keys) {
34 terminal.feed(k);
35 if (k === "\x1b") await Bun.sleep(150);
36 await settleApp(tui);
37 await Bun.sleep(30);
38 }
39 const text = tui.screen.toText();
40 tui.stop?.();
41 return text;
42 }
43
44 beforeAll(async () => {
45 dir = await mkdtemp(join(tmpdir(), "forge-tui-build-"));
46 app = createApp({ dataDir: dir, port: 0 });
47 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
48 if (!r.ok) throw new Error(r.error);
49 alice = r.value;
50 proveAll(app);
51 });
52 afterAll(async () => {
53 app.db.close();
54 await rm(dir, { recursive: true, force: true });
55 });
56
57 test("n starts a build, Enter opens it, Enter shows a version's files, R restores it and P publishes", async () => {
58 const listed = await drive(alice, [...open, "n", "a recipe box", "\r"]);
59 const [mine] = buildsOf(app.db, alice.id);
60 expect(mine?.title).toBe("a recipe box");
61 expect(listed).toContain("alice/recipe-box");
62
63 const build = chatBuildOf(app.db, mine!.conversationId)!;
64 const repo = getRepoById(app.db, build.repoId)!;
65 const made = await commitToBranch(app, alice, repo, { branch: build.branch, message: "Add recipes", files: [{ path: "page.txt", content: "one\n" }] } as never);
66 if (!made.ok) throw new Error(JSON.stringify(made.error));
67
68 const versions = await drive(alice, [...open, "\r"]);
69 expect(versions).toContain("v1 Add recipes");
70 expect(versions).toContain("alice/recipe-box");
71 expect(await drive(alice, [...open, "\r", "\r"])).toContain("page.txt");
72
73 // Restore makes a new version from the chosen one; the branch keeps both.
74 await drive(alice, [...open, "\r", "R", "\r"]);
75 expect((await versionsIn(app, repo, build)).map((v) => v.subject)).toEqual(["Add recipes", "Restore v1"]);
76
77 // Publish merges to main, where the page reads it.
78 await drive(alice, [...open, "\r", "P", "\r"]);
79 expect(chatBuildOf(app.db, mine!.conversationId)?.publishedAt).not.toBeNull();
80 const main = await readerFor(repoDir(app.reposRoot, "alice", getRepo(app.db, "alice", "recipe-box")!.name)).object("refs/heads/main:page.txt");
81 expect(main.ok && new TextDecoder().decode(main.value.body)).toBe("one\n");
82 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs