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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11010-talk-starts-an-app-from-one-prompt-and-e → main 6 commits fork 1 thread · 1 unresolved
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 | ||
| 112 | 112 | - **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. |
| 113 | 113 | - **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. |
| 114 | 114 | - **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) | |
| 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"; | ||
| 70 | 70 | export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts"; |
| 71 | 71 | export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts"; |
| 72 | 72 | export { route as getApiV1Conversations } from "./getApiV1Conversations.ts"; |
| 73 | export { route as getApiV1ConversationsIdBuild } from "./getApiV1ConversationsIdBuild.ts"; | |
| 74 | export { route as getApiV1ConversationsIdBuildVersionsNDiff } from "./getApiV1ConversationsIdBuildVersionsNDiff.ts"; | |
| 73 | 75 | export { route as getApiV1Credit } from "./getApiV1Credit.ts"; |
| 74 | 76 | export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts"; |
| 75 | 77 | export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts"; |
| @@ -255,12 +257,15 @@ export { route as postApiV1AsksGiveBack } from "./postApiV1AsksGiveBack.ts"; | ||
| 255 | 257 | export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts"; |
| 256 | 258 | export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts"; |
| 257 | 259 | export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts"; |
| 260 | export { route as postApiV1Builds } from "./postApiV1Builds.ts"; | |
| 258 | 261 | export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts"; |
| 259 | 262 | export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFiles.ts"; |
| 260 | 263 | export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts"; |
| 261 | 264 | export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts"; |
| 262 | 265 | export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts"; |
| 263 | 266 | export { route as postApiV1Conversations } from "./postApiV1Conversations.ts"; |
| 267 | export { route as postApiV1ConversationsIdBuildPublish } from "./postApiV1ConversationsIdBuildPublish.ts"; | |
| 268 | export { route as postApiV1ConversationsIdBuildRestore } from "./postApiV1ConversationsIdBuildRestore.ts"; | |
| 264 | 269 | export { route as postApiV1DevicesIdPrint } from "./postApiV1DevicesIdPrint.ts"; |
| 265 | 270 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 266 | 271 | 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 | ||
| 100 | 100 | }).immediate(); |
| 101 | 101 | } |
| 102 | 102 | |
| 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 | ||
| 103 | 108 | /** Start a new, empty conversation. */ |
| 104 | 109 | export function newConversation(db: Database, userId: string): Conversation { |
| 105 | 110 | 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"; | ||
| 20 | 20 | import type { App } from "../app.ts"; |
| 21 | 21 | import { dailyLeft } from "../core/account_ceiling.ts"; |
| 22 | 22 | 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"; | |
| 23 | 26 | import { appendTurn, getConversation, lastExchange, listTurns, openConversation, removeTurn, restoreTurns, setAsideFrom, setClaudeSession, type Conversation } from "../core/conversations.ts"; |
| 24 | 27 | import { attach, deliver, fileOwnedBy, NOTHING, release, type ConversationFile, type Delivery } from "../core/conversation_files.ts"; |
| 25 | 28 | import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts"; |
| @@ -314,7 +317,11 @@ export function say( | ||
| 314 | 317 | } |
| 315 | 318 | // The subject rides with a new conversation's first message only (#10496), as fenced data the agent reads before the words. |
| 316 | 319 | 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; | |
| 318 | 325 | const stored = appendTurn(app.db, userId, conversation.id, { role: "person", text }); |
| 319 | 326 | if (!stored.ok) return err("too_long"); |
| 320 | 327 | attach(app.db, files, stored.value.id); |
| @@ -605,7 +612,11 @@ function begin( | ||
| 605 | 612 | if (stoppers.get(conversation.id)?.runId === runId) stoppers.delete(conversation.id); |
| 606 | 613 | // A restart leaves the next message unsent (its turn would be cut again); anything else sends it. |
| 607 | 614 | 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 | } | |
| 609 | 620 | } |
| 610 | 621 | })(); |
| 611 | 622 | return ok(turn); |
src/http/api.ts +2
| @@ -387,6 +387,7 @@ import { rollbackRoutes } from "./api/rollback.ts"; | ||
| 387 | 387 | import { takeBackRoutes } from "./api/take_back.ts"; |
| 388 | 388 | import { conversationConfirmRoutes } from "./api/conversation_confirms.ts"; |
| 389 | 389 | import { conversationTalkRoutes } from "./api/conversation_talk.ts"; |
| 390 | import { conversationBuildRoutes } from "./api/conversation_build.ts"; | |
| 390 | 391 | import { orgRoutes } from "./api/orgs.ts"; |
| 391 | 392 | import { promptRoutes } from "./api/prompts.ts"; |
| 392 | 393 | import { cloudRoutes } from "./api/cloud.ts"; |
| @@ -2147,6 +2148,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2147 | 2148 | takeBackRoutes(router); |
| 2148 | 2149 | conversationConfirmRoutes(router); |
| 2149 | 2150 | conversationTalkRoutes(router); |
| 2151 | conversationBuildRoutes(router); | |
| 2150 | 2152 | |
| 2151 | 2153 | // Which stage of the one flow each open issue is in, and what went live in |
| 2152 | 2154 | // 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}$/; | ||
| 242 | 242 | const TITLE_WORDS = `title is text of at most ${TITLE_MAX} characters, on one line`; |
| 243 | 243 | |
| 244 | 244 | /** 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 { | |
| 246 | 246 | const user = ctx.auth.user; |
| 247 | 247 | if (user === undefined) return unauthorized(); |
| 248 | 248 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); |
src/http/server.ts +4
| @@ -146,6 +146,7 @@ import { | ||
| 146 | 146 | verifyPage, |
| 147 | 147 | themeSubmit, |
| 148 | 148 | } from "../web/pages/site.ts"; |
| 149 | import { talkBuild, talkPublish, talkRestore } from "../web/talk_build.ts"; | |
| 149 | 150 | import { talkAgainSend, talkArchive, talkConfirm, talkNew, talkQueueCancel, talkRename, talkSend, talkStream, talkView } from "../web/pages/talk.ts"; |
| 150 | 151 | import { |
| 151 | 152 | recoverFinish, |
| @@ -1011,10 +1012,13 @@ export function buildRouter(): Router<Handler> { | ||
| 1011 | 1012 | router.add("POST", "/talk/queue/:id/cancel", talkQueueCancel); |
| 1012 | 1013 | router.add("POST", "/talk/confirm/:id", talkConfirm); |
| 1013 | 1014 | router.add("POST", "/talk/new", talkNew); |
| 1015 | router.add("POST", "/talk/build", talkBuild); | |
| 1014 | 1016 | router.add("GET", "/talk/:id", talkView); |
| 1015 | 1017 | router.add("POST", "/talk/:id", talkSend); |
| 1016 | 1018 | router.add("POST", "/talk/:id/rename", talkRename); |
| 1017 | 1019 | 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); | |
| 1018 | 1022 | router.add("POST", "/talk/:id/again/:how", talkAgainSend); |
| 1019 | 1023 | router.add("POST", "/signin/passkey/options", signinOptions); |
| 1020 | 1024 | router.add("POST", "/signin/passkey", signinPasskey); |
src/tui/forge.ts +9
| @@ -59,6 +59,7 @@ import { usageScreen } from "./screens/usage.ts"; | ||
| 59 | 59 | import { canScreen } from "./screens/can.ts"; |
| 60 | 60 | import { modelScreen } from "./screens/model.ts"; |
| 61 | 61 | import { notifyScreen } from "./screens/notify.ts"; |
| 62 | import { talkBuildScreen } from "./screens/talk_build.ts"; | |
| 62 | 63 | import { talkChatsScreen } from "./screens/talk_chats.ts"; |
| 63 | 64 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 64 | 65 | import { passkeysScreen } from "./screens/passkeys.ts"; |
| @@ -405,6 +406,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 405 | 406 | hint: "your talks with the forge's agent; n starts one, r renames, x archives", |
| 406 | 407 | run: () => ctx.push(talkChatsScreen(session, ctx)), |
| 407 | 408 | }); |
| 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 | }); | |
| 408 | 417 | if (session.signedIn) |
| 409 | 418 | commands.push({ |
| 410 | 419 | 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"; | ||
| 54 | 54 | import { flashTo, page, redirect } from "../layout.ts"; |
| 55 | 55 | import { renderMarkdown } from "../markdown.ts"; |
| 56 | 56 | 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"; | |
| 57 | 59 | import { stepOf, repoOf, type Step } from "../../core/talk_steps.ts"; |
| 58 | 60 | import { VIEWS, talkPane, type View } from "./talk_preview.ts"; |
| 59 | 61 | import { talkCss, talkJs } from "./talk_client.ts"; |
| @@ -285,7 +287,7 @@ function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean, type | ||
| 285 | 287 | </div>`; |
| 286 | 288 | } |
| 287 | 289 | |
| 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> { | |
| 289 | 291 | const user = ctx.auth.user!; |
| 290 | 292 | let before = before0; |
| 291 | 293 | const here = latest ? HERE : urlOf(conversation); |
| @@ -313,11 +315,16 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi | ||
| 313 | 315 | const acts = !paged ? exchangeActs(ctx.app.db, user.id, conversation.id, running, here) : new Map<string, Html>(); |
| 314 | 316 | // Edit shows the composer holding the last message, posting to Edit instead of Send. |
| 315 | 317 | 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; | |
| 316 | 322 | const asked = ctx.url.searchParams.get("pane"); |
| 317 | 323 | const view: View | undefined = VIEWS.find((p) => p === asked); |
| 318 | 324 | const shown = view ?? "chat"; |
| 319 | 325 | const pane = talkPane(ctx, user, repoOf(turns), works, view); |
| 320 | 326 | const body = html`<h1 class="sr-only">Talk</h1> |
| 327 | ${box} | |
| 321 | 328 | <div class="talk-shell" data-shell data-pane="${shown}"> |
| 322 | 329 | <div class="talk-side">${chats(ctx, conversation, showArchived, typed)}</div> |
| 323 | 330 | <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 | ||
| 356 | 363 | </form> |
| 357 | 364 | </div> |
| 358 | 365 | ${pane.html} |
| 366 | ${panel} | |
| 359 | 367 | </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 }) }); | |
| 361 | 369 | } |
| 362 | 370 | |
| 363 | 371 | /** 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 | | ||
| 368 | 376 | }; |
| 369 | 377 | |
| 370 | 378 | // GET /talk and GET /talk/:id |
| 371 | export function talkView(ctx: Ctx): Response { | |
| 379 | export function talkView(ctx: Ctx): Response | Promise<Response> { | |
| 372 | 380 | if (ctx.auth.user === undefined) return signInFirst(ctx); |
| 373 | 381 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 374 | 382 | 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"; | ||
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 18 | 18 | 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"; | |
| 19 | 21 | import { createIssue } from "../src/core/issues.ts"; |
| 20 | 22 | import { pin } from "../src/core/pins.ts"; |
| 21 | 23 | import { getRepo } from "../src/core/repos.ts"; |
| @@ -133,6 +135,14 @@ beforeAll(async () => { | ||
| 133 | 135 | const pull = createIssue(app.db, { repoId: forge.id, authorId: bob.id, title: "A pull", body: "body", kind: "pull" }); |
| 134 | 136 | if (!pull.ok) throw new Error(pull.error); |
| 135 | 137 | 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`; | |
| 136 | 146 | // An agent's start the claim page can show (`/claim/start/:token`): bob's, so no extra account joins the walk. |
| 137 | 147 | recordStart(app.db, bob.id, "claim_fixture", "127.0.0.1"); |
| 138 | 148 | servers = await serve(app); |
test/palette-roster.test.ts +2
| @@ -89,6 +89,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 89 | 89 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
| 90 | 90 | ["trusted", "Trusted sources", "who folds in without review; a trusts, x removes, s stops all, v reverts"], |
| 91 | 91 | ["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"], | |
| 92 | 93 | ["ssh-keys", "Computer keys", "list, add and remove the keys that sign in as you"], |
| 93 | 94 | ["passkeys", "Passkeys", "the passkeys that sign in as you"], |
| 94 | 95 | ["account", "Account settings", undefined], |
| @@ -300,6 +301,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 300 | 301 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 301 | 302 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
| 302 | 303 | ["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"], | |
| 303 | 305 | ["ssh-keys", "Computer keys", "list, add and remove the keys that sign in as you"], |
| 304 | 306 | ["passkeys", "Passkeys", "the passkeys that sign in as you"], |
| 305 | 307 | ["account", "Account settings", undefined], |
test/security-floor-routes.test.ts +11 −3
| @@ -28,6 +28,8 @@ import { createRun } from "../src/core/runs.ts"; | ||
| 28 | 28 | import { createSession } from "../src/core/sessions.ts"; |
| 29 | 29 | import { repoDir } from "../src/git/repos.ts"; |
| 30 | 30 | 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"; | |
| 31 | 33 | import { seedAdmin } from "./helpers/admin.ts"; |
| 32 | 34 | import { quietLogs } from "./helpers/globals.ts"; |
| 33 | 35 | import { pool } from "./helpers/sweep-clock.ts"; |
| @@ -35,7 +37,7 @@ import { pool } from "./helpers/sweep-clock.ts"; | ||
| 35 | 37 | quietLogs(); |
| 36 | 38 | |
| 37 | 39 | /** 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"]); | |
| 39 | 41 | |
| 40 | 42 | const ORIGIN = "needs a connected origin account and a mirror job; the seed connects none"; |
| 41 | 43 | const MIGRATE = "needs a migration job, which only an import creates"; |
| @@ -174,6 +176,11 @@ beforeAll(async () => { | ||
| 174 | 176 | path: "a.txt", env: "prod", slug: "notes", spec: "192", machine: "standard", |
| 175 | 177 | issue: String(issue.value.number), pull: String(pull.value.number), run: run.id, |
| 176 | 178 | }); |
| 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; | |
| 177 | 184 | cookie = `forge_session=${createSession(app.db, ada.id).token}`; |
| 178 | 185 | }, 30_000); |
| 179 | 186 | |
| @@ -191,7 +198,8 @@ const getPatterns = (): string[] => | ||
| 191 | 198 | |
| 192 | 199 | function pathFor(pattern: string): string { |
| 193 | 200 | 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"; | |
| 195 | 203 | return pattern |
| 196 | 204 | .replace(/[:*]([a-z_]+)/g, (_, key: string) => { |
| 197 | 205 | if (key === "number") return number!; |
| @@ -204,7 +212,7 @@ function pathFor(pattern: string): string { | ||
| 204 | 212 | |
| 205 | 213 | const unseeded = (pattern: string): boolean => |
| 206 | 214 | 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/")), | |
| 208 | 216 | ); |
| 209 | 217 | |
| 210 | 218 | /** |
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 | }); |
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.