Public: anyone can read this; the crew can change it.
Crew roles are config: an agent says what it does, migrate never picks a planner as builder, and a repo's team is three lists to choose from #8922
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8907-crew-roles-are-config-an-agent-says-what → main 3 commits fork
90 files
changed +800
−332
docs/PLANNING.md +10 −2
| @@ -470,8 +470,16 @@ Everything it does, it does as that person, through the same actions they | ||
| 470 | 470 | would take by hand — an assignment, a merge — so every run and every |
| 471 | 471 | landing is credited and bounded exactly as theirs would be. |
| 472 | 472 | |
| 473 | **The crew's roles.** Each crew member has a role on the repository, set | |
| 474 | under **Settings → Crew**: a _builder_ takes what is ready; a _planner_ | |
| 473 | **The crew's roles.** An agent declares its roles in its own config, beside | |
| 474 | its model and brief (Settings → Crew → the agent; `roles` on `/api/v1/agents` | |
| 475 | and the `agent` MCP tool): any of planner, builder, reviewer, one or more. | |
| 476 | A repository's team is three lists, Planners, Builders and Reviewers, each | |
| 477 | picking only from agents that declare the role, with how many may work at | |
| 478 | once beside it; a repository narrows what an agent declares and never widens | |
| 479 | it, and there is no default to builder. `/migrate` and a new app offer | |
| 480 | builders only, and a dispatch never hands an agent a job outside its seat: | |
| 481 | an issue assigned to a planner gets one comment saying so. Each crew member | |
| 482 | then holds one of its roles on the repository: a _builder_ takes what is ready; a _planner_ | |
| 475 | 483 | turns the direction into a map; a _reviewer_ is for the look-back. Which |
| 476 | 484 | builder takes an issue is one fixed-choice function: the issue's own |
| 477 | 485 | assignee when it is a builder here, else the builder with the least in |
docs/features/crew-roles.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # An agent says what it does; a repo's team is three lists | |
| 2 | ||
| 3 | An agent declares its **roles** in its own config: any of planner, builder, reviewer (at least one; none given reads as builder). | |
| 4 | ||
| 5 | - **Where to set them.** Settings → Crew → the agent has a *Jobs* row of checkboxes. The same is `roles` (a list of strings) on `POST`/`PATCH /api/v1/agents`, on the `agent` MCP tool, and `r` on the terminal's *Crew agents* screen (comma separated, plan and confirm first). Anything but a list of the three names is `invalid_roles` (422). | |
| 6 | - **A repository narrows, never widens.** A repo's *Settings → Crew* shows three lists, Planners, Builders and Reviewers, each with how many may work at once. An *Add…* list offers only agents that declare that role; the API refuses a role the agent does not declare (`role_not_declared`). There is no default to builder: an agent added with no seat takes the first it declares. | |
| 7 | - **No silent moves.** Taking a role off an agent that still holds that seat on a repository is refused (`role_held`); move it off that repository's team first. | |
| 8 | - **A dispatch keeps to the seat.** An assigned issue, feedback, a red check, a conflict, a converge and a resolve run only for a builder (the direction issue only for a planner); a review only for a reviewer. An issue assigned to a planner or reviewer gets one comment saying so. | |
| 9 | - **`/migrate`** offers builders only; `crew=<name>` naming anyone else is refused in a sentence and the first builder stays the default. |
src/actions/apps.ts +5 −2
| @@ -284,6 +284,7 @@ export type NewAppError = | ||
| 284 | 284 | | CreateAppError |
| 285 | 285 | | "scaffold_failed" |
| 286 | 286 | | "no_such_agent" |
| 287 | | "not_a_builder" | |
| 287 | 288 | | "app_limit"; |
| 288 | 289 | |
| 289 | 290 | /** |
| @@ -307,6 +308,7 @@ export async function newApp( | ||
| 307 | 308 | ? undefined |
| 308 | 309 | : getAgentByName(app.db, input.agent); |
| 309 | 310 | if (input.agent && !agent) return err("no_such_agent"); |
| 311 | if (agent && !agent.roles.includes("builder")) return err("not_a_builder"); | |
| 310 | 312 | |
| 311 | 313 | // Refuse at the limit before a repository exists to take back. |
| 312 | 314 | // The owner is resolved with the actor's rights, as createRepo does, so |
| @@ -371,7 +373,7 @@ export async function newApp( | ||
| 371 | 373 | const binary = process.env["FORGE_CLAUDE"] ?? "claude"; |
| 372 | 374 | if (planningAvailable(binary)) { |
| 373 | 375 | const roster = listAllAgents(app, user.value); |
| 374 | const agents = roster.ok ? roster.value : []; | |
| 376 | const agents = roster.ok ? roster.value.filter((a) => a.roles.includes("builder")) : []; | |
| 375 | 377 | if (agents.length > 0) { |
| 376 | 378 | const result = await plan( |
| 377 | 379 | { |
| @@ -401,7 +403,7 @@ export async function newApp( | ||
| 401 | 403 | |
| 402 | 404 | let issue: Issue | undefined; |
| 403 | 405 | if (chosen) { |
| 404 | addCrew(app.db, repo.value.id, chosen.user.id, user.value.id); | |
| 406 | addCrew(app.db, repo.value.id, chosen.user.id, user.value.id, "builder"); | |
| 405 | 407 | app.bus.append({ |
| 406 | 408 | kind: "crew.added", |
| 407 | 409 | repoId: repo.value.id, |
| @@ -459,6 +461,7 @@ export const NEW_APP_ERRORS: Record<string, string> = { | ||
| 459 | 461 | already_app: "That repository is already an app.", |
| 460 | 462 | scaffold_failed: "The starter app could not be committed.", |
| 461 | 463 | no_such_agent: NO_SUCH_AGENT, |
| 464 | not_a_builder: "That agent is not a builder, so it cannot be handed a new app. Pick a builder.", | |
| 462 | 465 | }; |
| 463 | 466 | |
| 464 | 467 | export interface NewAppFromSentenceInput extends Omit<NewAppInput, "name"> { |
src/actions/crew.ts +49 −10
| @@ -17,9 +17,10 @@ import { | ||
| 17 | 17 | isCrew, |
| 18 | 18 | listAgents, |
| 19 | 19 | listCrew as queryCrew, |
| 20 | isCrewRole, | |
| 20 | 21 | listCrewRoles, |
| 21 | 22 | removeCrew as deleteCrew, |
| 22 | setCrewRole, | |
| 23 | seatsOutside, | |
| 23 | 24 | updateAgent as patchAgent, |
| 24 | 25 | type Agent, |
| 25 | 26 | type AgentPatch, |
| @@ -45,6 +46,25 @@ export interface CreateAgentInput { | ||
| 45 | 46 | model?: string; |
| 46 | 47 | brief?: string; |
| 47 | 48 | budgetUsd?: number; |
| 49 | /** The seats it can hold: any of planner, builder, reviewer. */ | |
| 50 | roles?: readonly string[]; | |
| 51 | } | |
| 52 | ||
| 53 | /** An agent change as asked: roles still unchecked strings. */ | |
| 54 | export type AgentChange = Omit<AgentPatch, "roles"> & { roles?: readonly string[] }; | |
| 55 | ||
| 56 | /** | |
| 57 | * The `roles` a request named, as a patch field: nothing when it named none; | |
| 58 | * anything but a list of strings is carried as a value checkPatch refuses. | |
| 59 | */ | |
| 60 | export function askedRoles(value: unknown): { roles?: readonly string[] } { | |
| 61 | if (value === undefined) return {}; | |
| 62 | return { | |
| 63 | roles: | |
| 64 | Array.isArray(value) && value.every((r) => typeof r === "string") | |
| 65 | ? value | |
| 66 | : [""], | |
| 67 | }; | |
| 48 | 68 | } |
| 49 | 69 | |
| 50 | 70 | export type CreateAgentError = |
| @@ -53,7 +73,9 @@ export type CreateAgentError = | ||
| 53 | 73 | | CreateUserError |
| 54 | 74 | | "invalid_model" |
| 55 | 75 | | "brief_too_long" |
| 56 | | "invalid_budget"; | |
| 76 | | "invalid_budget" | |
| 77 | | "invalid_roles" | |
| 78 | | "role_held"; | |
| 57 | 79 | |
| 58 | 80 | function requireForgeAdmin( |
| 59 | 81 | actor: User | undefined, |
| @@ -66,8 +88,13 @@ function requireForgeAdmin( | ||
| 66 | 88 | /** The bounds an agent change must meet; the screens check them before they ask. */ |
| 67 | 89 | export function checkPatch( |
| 68 | 90 | app: App, |
| 69 | patch: AgentPatch, | |
| 70 | ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget"> { | |
| 91 | patch: AgentChange, | |
| 92 | ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget" | "invalid_roles"> { | |
| 93 | let roles: CrewRole[] | undefined; | |
| 94 | if (patch.roles !== undefined) { | |
| 95 | if (patch.roles.length === 0 || !patch.roles.every(isCrewRole)) return err("invalid_roles"); | |
| 96 | roles = patch.roles as CrewRole[]; | |
| 97 | } | |
| 71 | 98 | if (patch.model !== undefined) { |
| 72 | 99 | if (patch.model.length > MODEL_MAX || /[\s\0]/.test(patch.model)) |
| 73 | 100 | return err("invalid_model"); |
| @@ -81,7 +108,8 @@ export function checkPatch( | ||
| 81 | 108 | patch.budgetUsd > limitValue(app.db, "agent.budget_max_usd")) |
| 82 | 109 | ) |
| 83 | 110 | return err("invalid_budget"); |
| 84 | return ok(patch); | |
| 111 | const { roles: _asked, ...rest } = patch; | |
| 112 | return ok({ ...rest, ...(roles === undefined ? {} : { roles }) }); | |
| 85 | 113 | } |
| 86 | 114 | |
| 87 | 115 | /** A new crew member. Admin only: this is an account that will act. */ |
| @@ -96,6 +124,7 @@ export async function createAgent( | ||
| 96 | 124 | ...(input.model === undefined ? {} : { model: input.model }), |
| 97 | 125 | ...(input.brief === undefined ? {} : { brief: input.brief }), |
| 98 | 126 | ...(input.budgetUsd === undefined ? {} : { budgetUsd: input.budgetUsd }), |
| 127 | ...(input.roles === undefined ? {} : { roles: input.roles }), | |
| 99 | 128 | }); |
| 100 | 129 | if (!patch.ok) return patch; |
| 101 | 130 | const user = await createUser(app.db, { |
| @@ -112,6 +141,7 @@ export async function createAgent( | ||
| 112 | 141 | model: patch.value.model, |
| 113 | 142 | brief: patch.value.brief, |
| 114 | 143 | budgetUsd: patch.value.budgetUsd, |
| 144 | roles: patch.value.roles, | |
| 115 | 145 | createdBy: admin.value.id, |
| 116 | 146 | }); |
| 117 | 147 | app.bus.append({ |
| @@ -126,7 +156,7 @@ export function updateAgent( | ||
| 126 | 156 | app: App, |
| 127 | 157 | actor: User | undefined, |
| 128 | 158 | username: string, |
| 129 | patch: AgentPatch, | |
| 159 | patch: AgentChange, | |
| 130 | 160 | ): Result<Agent, CreateAgentError | "not_found"> { |
| 131 | 161 | const admin = requireForgeAdmin(actor); |
| 132 | 162 | if (!admin.ok) return admin; |
| @@ -134,6 +164,11 @@ export function updateAgent( | ||
| 134 | 164 | if (!agent) return err("not_found"); |
| 135 | 165 | const checked = checkPatch(app, patch); |
| 136 | 166 | if (!checked.ok) return checked; |
| 167 | // No silent move: a seat the agent still holds on a repository is moved there first. | |
| 168 | if (checked.value.roles) { | |
| 169 | const held = seatsOutside(app.db, agent.user.id, checked.value.roles); | |
| 170 | if (held.length > 0) return err("role_held"); | |
| 171 | } | |
| 137 | 172 | const updated = patchAgent(app.db, agent.user.id, checked.value); |
| 138 | 173 | if (!updated) return err("not_found"); |
| 139 | 174 | app.bus.append({ |
| @@ -206,6 +241,8 @@ export interface CrewMember { | ||
| 206 | 241 | * or a stranger sees the crew's names and roles. |
| 207 | 242 | */ |
| 208 | 243 | readonly config?: { model: string; brief: string; budgetUsd: number }; |
| 244 | /** The seats the agent declares; the repository's `role` is one of them. */ | |
| 245 | readonly roles: readonly CrewRole[]; | |
| 209 | 246 | } |
| 210 | 247 | |
| 211 | 248 | /** The crew of a repository, shown as `actor` may see it. */ |
| @@ -225,6 +262,7 @@ export function crewAsSeen( | ||
| 225 | 262 | username: agent.user.username, |
| 226 | 263 | displayName: agent.user.displayName, |
| 227 | 264 | role, |
| 265 | roles: agent.roles, | |
| 228 | 266 | ...(configured |
| 229 | 267 | ? { |
| 230 | 268 | config: { |
| @@ -245,7 +283,7 @@ export function addToCrew( | ||
| 245 | 283 | repo: Repo, |
| 246 | 284 | username: string, |
| 247 | 285 | role?: CrewRole, |
| 248 | ): Result<Agent, "not_found" | "no_such_agent" | "not_an_agent" | "unverified"> { | |
| 286 | ): Result<Agent, "not_found" | "no_such_agent" | "not_an_agent" | "unverified" | "role_not_declared"> { | |
| 249 | 287 | const admin = requireRepoAdmin(app, actor, repo); |
| 250 | 288 | if (!admin.ok) return admin; |
| 251 | 289 | const proven = requireVerified(app, admin.value); |
| @@ -254,9 +292,10 @@ export function addToCrew( | ||
| 254 | 292 | // A person is never on a crew: said plainly, before the database would. |
| 255 | 293 | if (!agent) |
| 256 | 294 | return err(getUserByName(app.db, username) ? "not_an_agent" : "no_such_agent"); |
| 257 | if (insertCrew(app.db, repo.id, agent.user.id, admin.value.id)) { | |
| 258 | // A seat is named only for an agent who joins now; one already on the crew keeps its role. | |
| 259 | if (role !== undefined) setCrewRole(app.db, repo.id, agent.user.id, role); | |
| 295 | // A repository narrows what an agent declares, never widens it. | |
| 296 | if (role !== undefined && !agent.roles.includes(role)) return err("role_not_declared"); | |
| 297 | // A seat is named only for an agent who joins now; one already on the crew keeps its role. | |
| 298 | if (insertCrew(app.db, repo.id, agent.user.id, admin.value.id, role)) { | |
| 260 | 299 | app.bus.append({ |
| 261 | 300 | kind: "crew.added", |
| 262 | 301 | repoId: repo.id, |
src/actions/migrate.ts +10 −4
| @@ -133,7 +133,8 @@ export type MigrateError = | ||
| 133 | 133 | | "no_such_origin" |
| 134 | 134 | | "unreachable" |
| 135 | 135 | | "invalid_branch" |
| 136 | | "no_such_agent"; | |
| 136 | | "no_such_agent" | |
| 137 | | "not_a_builder"; | |
| 137 | 138 | |
| 138 | 139 | /** What the clone reads from, resolved: where, and how to authenticate. */ |
| 139 | 140 | interface Resolved { |
| @@ -191,8 +192,11 @@ export async function migrateRepo( | ||
| 191 | 192 | |
| 192 | 193 | const source = await resolveSource(app, user.value, input.source, input.resolve); |
| 193 | 194 | if (!source.ok) return source; |
| 194 | if (input.crew !== undefined && !getAgentByName(app.db, input.crew.agent)) | |
| 195 | return err("no_such_agent"); | |
| 195 | if (input.crew !== undefined) { | |
| 196 | const asked = getAgentByName(app.db, input.crew.agent); | |
| 197 | if (!asked) return err("no_such_agent"); | |
| 198 | if (!asked.roles.includes("builder")) return err("not_a_builder"); | |
| 199 | } | |
| 196 | 200 | const branch = (input.branch ?? "").trim(); |
| 197 | 201 | if (branch !== "" && !isSafeRef(branch)) return err("invalid_branch"); |
| 198 | 202 | |
| @@ -268,6 +272,8 @@ export function migrateMessage(error: MigrateError, limit = AT_REPOSITORY_LIMIT) | ||
| 268 | 272 | return "The origin could not be reached. Try again, or reconnect it under Settings → Origins."; |
| 269 | 273 | case "no_such_agent": |
| 270 | 274 | return NO_SUCH_AGENT; |
| 275 | case "not_a_builder": | |
| 276 | return "That agent is not a builder, so it cannot take a migration. Pick a builder, or change its roles under Settings → Crew."; | |
| 271 | 277 | case "invalid_branch": |
| 272 | 278 | return "That is not a branch name git would take."; |
| 273 | 279 | case "name_taken": |
| @@ -836,7 +842,7 @@ async function putCrewOn( | ||
| 836 | 842 | work: WorkBranch | undefined, |
| 837 | 843 | ): Promise<string> { |
| 838 | 844 | const said: string[] = []; |
| 839 | const added = addToCrew(app, user, repo, crew.agent); | |
| 845 | const added = addToCrew(app, user, repo, crew.agent, "builder"); | |
| 840 | 846 | if (!added.ok) return `${crew.agent} could not join the crew.`; |
| 841 | 847 | const made = enableApp(app, user, repo, {}); |
| 842 | 848 | if (!made.ok) |
src/actions/spawn.ts +4 −2
| @@ -402,9 +402,11 @@ async function copyInto( | ||
| 402 | 402 | if (!forgeCode.ok) return err(forgeCode.error); |
| 403 | 403 | |
| 404 | 404 | for (const b of plan.briefs) { |
| 405 | // The jobs it declares travel with it (#8907). | |
| 406 | const roles = getAgentByName(app.db, b.agent)?.roles; | |
| 405 | 407 | const existing = getAgentByName(child.db, b.agent); |
| 406 | 408 | if (existing) { |
| 407 | updateAgent(child.db, existing.user.id, { brief: b.text }); | |
| 409 | updateAgent(child.db, existing.user.id, { brief: b.text, ...(roles ? { roles } : {}) }); | |
| 408 | 410 | continue; |
| 409 | 411 | } |
| 410 | 412 | const made = await createUser(child.db, { |
| @@ -414,7 +416,7 @@ async function copyInto( | ||
| 414 | 416 | origin: "spawn", |
| 415 | 417 | }); |
| 416 | 418 | if (!made.ok) return err(`could not make crew member ${b.agent}: ${made.error}`); |
| 417 | createAgentRow(child.db, { userId: made.value.id, brief: b.text, createdBy: founder.id }); | |
| 419 | createAgentRow(child.db, { userId: made.value.id, brief: b.text, roles, createdBy: founder.id }); | |
| 418 | 420 | } |
| 419 | 421 | |
| 420 | 422 | // A carried crew member keeps its place on each app: the role it had on |
src/core/agents.ts +68 −14
| @@ -19,6 +19,8 @@ export interface Agent { | ||
| 19 | 19 | readonly brief: string; |
| 20 | 20 | /** Spend per run in USD; 0 means the forge's default. */ |
| 21 | 21 | readonly budgetUsd: number; |
| 22 | /** The seats it can hold, in planner, builder, reviewer order; never empty. */ | |
| 23 | readonly roles: readonly CrewRole[]; | |
| 22 | 24 | readonly createdBy: string | null; |
| 23 | 25 | readonly createdAt: string; |
| 24 | 26 | } |
| @@ -28,6 +30,7 @@ interface AgentRow { | ||
| 28 | 30 | model: string; |
| 29 | 31 | brief: string; |
| 30 | 32 | budget_usd: number; |
| 33 | roles: string; | |
| 31 | 34 | created_by: string | null; |
| 32 | 35 | created_at: string; |
| 33 | 36 | } |
| @@ -46,6 +49,7 @@ function agentOf(user: User, row: AgentRow): Agent { | ||
| 46 | 49 | model: row.model, |
| 47 | 50 | brief: row.brief, |
| 48 | 51 | budgetUsd: row.budget_usd, |
| 52 | roles: parseRoles(row.roles), | |
| 49 | 53 | createdBy: row.created_by, |
| 50 | 54 | createdAt: row.created_at, |
| 51 | 55 | }; |
| @@ -58,17 +62,19 @@ export function createAgentRow( | ||
| 58 | 62 | model?: string | undefined; |
| 59 | 63 | brief?: string | undefined; |
| 60 | 64 | budgetUsd?: number | undefined; |
| 65 | roles?: readonly CrewRole[] | undefined; | |
| 61 | 66 | createdBy?: string | undefined; |
| 62 | 67 | }, |
| 63 | 68 | ): Agent { |
| 64 | 69 | db.query( |
| 65 | "INSERT INTO agent (user_id, model, brief, budget_usd, created_by) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 70 | "INSERT INTO agent (user_id, model, brief, budget_usd, created_by, roles) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 66 | 71 | ).run( |
| 67 | 72 | input.userId, |
| 68 | 73 | input.model ?? "", |
| 69 | 74 | input.brief ?? "", |
| 70 | 75 | input.budgetUsd ?? 0, |
| 71 | 76 | input.createdBy ?? null, |
| 77 | joinRoles(input.roles), | |
| 72 | 78 | ); |
| 73 | 79 | return getAgent(db, input.userId)!; |
| 74 | 80 | } |
| @@ -113,6 +119,8 @@ export interface AgentPatch { | ||
| 113 | 119 | model?: string; |
| 114 | 120 | brief?: string; |
| 115 | 121 | budgetUsd?: number; |
| 122 | /** Replaces the agent's roles; empty or unknown ones are ignored. */ | |
| 123 | roles?: readonly CrewRole[]; | |
| 116 | 124 | } |
| 117 | 125 | |
| 118 | 126 | export function updateAgent( |
| @@ -134,13 +142,23 @@ export function updateAgent( | ||
| 134 | 142 | params.push(Math.min(Math.max(patch.budgetUsd, 0), limitValue(db, "agent.budget_max_usd"))); |
| 135 | 143 | sets.push(`budget_usd = ?${params.length}`); |
| 136 | 144 | } |
| 145 | if (patch.roles !== undefined && patch.roles.length > 0) { | |
| 146 | params.push(joinRoles(patch.roles)); | |
| 147 | sets.push(`roles = ?${params.length}`); | |
| 148 | } | |
| 137 | 149 | if (sets.length > 0) { |
| 138 | 150 | params.push(userId); |
| 139 | 151 | db.query( |
| 140 | 152 | `UPDATE agent SET ${sets.join(", ")} WHERE user_id = ?${params.length}`, |
| 141 | 153 | ).run(...params); |
| 142 | 154 | } |
| 143 | return getAgent(db, userId); | |
| 155 | const agent = getAgent(db, userId); | |
| 156 | // A seat the agent no longer declares moves to one it does: a repo narrows, never widens. | |
| 157 | if (agent && patch.roles !== undefined) | |
| 158 | db.query( | |
| 159 | "UPDATE repo_crew SET role = ?2 WHERE user_id = ?1 AND instr(',' || ?3 || ',', ',' || role || ',') = 0", | |
| 160 | ).run(userId, agent.roles[0]!, agent.roles.join(",")); | |
| 161 | return agent; | |
| 144 | 162 | } |
| 145 | 163 | |
| 146 | 164 | // ---- the crew of a repository ---- |
| @@ -155,13 +173,22 @@ export function addCrew( | ||
| 155 | 173 | repoId: string, |
| 156 | 174 | userId: string, |
| 157 | 175 | addedBy?: string, |
| 176 | role?: CrewRole, | |
| 158 | 177 | ): boolean { |
| 178 | const agent = getAgent(db, userId); | |
| 179 | const declared = agent?.roles ?? []; | |
| 180 | // No default to builder: the seat is the one asked for, else the first the agent declares. | |
| 181 | // A user with no agent row goes on to the insert, where the database refuses a person. | |
| 182 | const seat = agent | |
| 183 | ? role !== undefined && declared.includes(role) ? role : declared[0] | |
| 184 | : (role ?? "builder"); | |
| 185 | if (seat === undefined) return false; | |
| 159 | 186 | return ( |
| 160 | 187 | db |
| 161 | 188 | .query( |
| 162 | "INSERT OR IGNORE INTO repo_crew (repo_id, user_id, added_by) VALUES (?1, ?2, ?3)", | |
| 189 | "INSERT OR IGNORE INTO repo_crew (repo_id, user_id, added_by, role) VALUES (?1, ?2, ?3, ?4)", | |
| 163 | 190 | ) |
| 164 | .run(repoId, userId, addedBy ?? null).changes > 0 | |
| 191 | .run(repoId, userId, addedBy ?? null, seat).changes > 0 | |
| 165 | 192 | ); |
| 166 | 193 | } |
| 167 | 194 | |
| @@ -281,8 +308,8 @@ export function deleteAgent(db: Database, userId: string): boolean { | ||
| 281 | 308 | */ |
| 282 | 309 | export type CrewRole = "builder" | "planner" | "reviewer"; |
| 283 | 310 | export const CREW_ROLES: readonly CrewRole[] = [ |
| 284 | "builder", | |
| 285 | 311 | "planner", |
| 312 | "builder", | |
| 286 | 313 | "reviewer", |
| 287 | 314 | ]; |
| 288 | 315 | export const isCrewRole = (value: unknown): value is CrewRole => |
| @@ -295,11 +322,37 @@ export function crewRole( | ||
| 295 | 322 | ): CrewRole | undefined { |
| 296 | 323 | const row = db |
| 297 | 324 | .query< |
| 298 | { role: string }, | |
| 325 | { role: string; roles: string }, | |
| 299 | 326 | [string, string] |
| 300 | >("SELECT role FROM repo_crew WHERE repo_id = ?1 AND user_id = ?2") | |
| 327 | >("SELECT repo_crew.role, agent.roles FROM repo_crew JOIN agent ON agent.user_id = repo_crew.user_id WHERE repo_id = ?1 AND repo_crew.user_id = ?2") | |
| 301 | 328 | .get(repoId, userId); |
| 302 | return row && isCrewRole(row.role) ? row.role : row ? "builder" : undefined; | |
| 329 | return row ? seatOf(row.role, parseRoles(row.roles)) : undefined; | |
| 330 | } | |
| 331 | ||
| 332 | /** The roles an agent declares, in seat order; a bad or empty value reads as builder. */ | |
| 333 | export function parseRoles(value: string): CrewRole[] { | |
| 334 | const out = CREW_ROLES.filter((r) => value.split(",").includes(r)); | |
| 335 | return out.length > 0 ? out : ["builder"]; | |
| 336 | } | |
| 337 | ||
| 338 | function joinRoles(roles: readonly CrewRole[] | undefined): string { | |
| 339 | return parseRoles((roles ?? []).join(",")).join(","); | |
| 340 | } | |
| 341 | ||
| 342 | /** The seat a repo row holds: its own when the agent declares it, else the agent's first. */ | |
| 343 | function seatOf(role: string, declared: readonly CrewRole[]): CrewRole { | |
| 344 | return isCrewRole(role) && declared.includes(role) ? role : declared[0]!; | |
| 345 | } | |
| 346 | ||
| 347 | /** The repositories where an agent holds a seat that `roles` would not declare. */ | |
| 348 | export function seatsOutside(db: Database, userId: string, roles: readonly CrewRole[]): string[] { | |
| 349 | return db | |
| 350 | .query<{ name: string }, [string, string]>( | |
| 351 | `SELECT repo.name FROM repo_crew JOIN repo ON repo.id = repo_crew.repo_id | |
| 352 | WHERE repo_crew.user_id = ?1 AND instr(',' || ?2 || ',', ',' || repo_crew.role || ',') = 0`, | |
| 353 | ) | |
| 354 | .all(userId, roles.join(",")) | |
| 355 | .map((r) => r.name); | |
| 303 | 356 | } |
| 304 | 357 | |
| 305 | 358 | export function setCrewRole( |
| @@ -308,6 +361,8 @@ export function setCrewRole( | ||
| 308 | 361 | userId: string, |
| 309 | 362 | role: CrewRole, |
| 310 | 363 | ): boolean { |
| 364 | // A repository narrows what an agent declares and never widens it. | |
| 365 | if (!getAgent(db, userId)?.roles.includes(role)) return false; | |
| 311 | 366 | return ( |
| 312 | 367 | db |
| 313 | 368 | .query( |
| @@ -328,13 +383,11 @@ export function listCrewRoles( | ||
| 328 | 383 | "SELECT user_id, role FROM repo_crew WHERE repo_id = ?1", |
| 329 | 384 | ) |
| 330 | 385 | .all(repoId) |
| 331 | .map( | |
| 332 | (r) => [r.user_id, isCrewRole(r.role) ? r.role : "builder"] as const, | |
| 333 | ), | |
| 386 | .map((r) => [r.user_id, r.role] as const), | |
| 334 | 387 | ); |
| 335 | 388 | return listCrew(db, repoId).map((agent) => ({ |
| 336 | 389 | agent, |
| 337 | role: roles.get(agent.user.id) ?? "builder", | |
| 390 | role: seatOf(roles.get(agent.user.id) ?? "", agent.roles), | |
| 338 | 391 | })); |
| 339 | 392 | } |
| 340 | 393 | |
| @@ -360,6 +413,7 @@ export function crewWithRole( | ||
| 360 | 413 | JOIN agent ON agent.user_id = repo_crew.user_id |
| 361 | 414 | JOIN user ON user.id = agent.user_id |
| 362 | 415 | WHERE repo_crew.role = ?1 |
| 416 | AND instr(',' || agent.roles || ',', ',' || ?1 || ',') > 0 | |
| 363 | 417 | AND repo_crew.repo_id IN (${batch.map((_, n) => `?${n + 2}`).join(",")}) |
| 364 | 418 | ORDER BY user.username COLLATE NOCASE`, |
| 365 | 419 | ) |
| @@ -396,8 +450,8 @@ export function reposWithBuilder( | ||
| 396 | 450 | const batch = unique.slice(i, i + CHUNK); |
| 397 | 451 | const rows = db |
| 398 | 452 | .query<{ repo_id: string }, string[]>( |
| 399 | `SELECT DISTINCT repo_id FROM repo_crew | |
| 400 | WHERE role = 'builder' AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`, | |
| 453 | `SELECT DISTINCT repo_id FROM repo_crew JOIN agent ON agent.user_id = repo_crew.user_id | |
| 454 | WHERE role = 'builder' AND instr(',' || agent.roles || ',', ',builder,') > 0 AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`, | |
| 401 | 455 | ) |
| 402 | 456 | .all(...batch); |
| 403 | 457 | for (const row of rows) out.add(row.repo_id); |
src/core/migration-pins/20261103140000-agent-roles.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | c7b646072706c89877014e53aa0c50485cd617b72d77c1c02ab62843e8118689 |
src/core/migrations/20261103140000-agent-roles.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | // An agent's roles are its own config (#8907): the seats it can hold, planner, | |
| 2 | // builder, reviewer, one or more, comma-joined in that order. Existing agents | |
| 3 | // take the roles they hold today on any repository, so nothing changes under | |
| 4 | // them; an agent on no crew keeps `builder`, which is what it read as. Additive. | |
| 5 | export default ` | |
| 6 | ALTER TABLE agent ADD COLUMN roles TEXT NOT NULL DEFAULT 'builder'; | |
| 7 | UPDATE agent SET roles = rtrim( | |
| 8 | (CASE WHEN EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id AND role = 'planner') THEN 'planner,' ELSE '' END) || | |
| 9 | (CASE WHEN EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id AND role = 'builder') THEN 'builder,' ELSE '' END) || | |
| 10 | (CASE WHEN EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id AND role = 'reviewer') THEN 'reviewer,' ELSE '' END), ',') | |
| 11 | WHERE EXISTS (SELECT 1 FROM repo_crew WHERE user_id = agent.user_id); | |
| 12 | `; |
src/crew/dispatch.ts +38
| @@ -374,6 +374,36 @@ export function atCeiling( | ||
| 374 | 374 | return spentUsd >= repo.autonomyCeilingUsd; |
| 375 | 375 | } |
| 376 | 376 | |
| 377 | /** | |
| 378 | * Whether this run is a job outside the agent's seat here: a build for one | |
| 379 | * that is not a builder (the direction issue is the planner's), a review for | |
| 380 | * one that is not a reviewer. On the crew the seat is the repository's; off | |
| 381 | * it, what the agent declares. | |
| 382 | */ | |
| 383 | export function outsideSeat( | |
| 384 | app: App, | |
| 385 | repo: Repo, | |
| 386 | issue: Issue, | |
| 387 | agent: Agent, | |
| 388 | reason: RunReason, | |
| 389 | ): boolean { | |
| 390 | // Every reason but a review is building work: a first go, feedback, a red check, a conflict, a converge. | |
| 391 | const seat = | |
| 392 | reason === "review" | |
| 393 | ? "reviewer" | |
| 394 | : issue.id === repo.directionIssueId | |
| 395 | ? "planner" | |
| 396 | : "builder"; | |
| 397 | const held = crewRole(app.db, repo.id, agent.user.id); | |
| 398 | // A pull assigned to its reviewer is asked for a look, a builder's for a fix: only a planner is outside. | |
| 399 | if (reason !== "review" && issue.kind === "pull") | |
| 400 | return (held ?? agent.roles[0]) === "planner"; | |
| 401 | return held === undefined ? !agent.roles.includes(seat) : held !== seat; | |
| 402 | } | |
| 403 | ||
| 404 | export const NOT_A_BUILDER_NOTE = | |
| 405 | "I am not a builder here, so I have not started on this. Assign it to a builder on the crew, or change my roles in Settings."; | |
| 406 | ||
| 377 | 407 | /** Queue a run about an issue for an agent, unless one is already waiting. */ |
| 378 | 408 | export function queueAgentRun( |
| 379 | 409 | app: App, |
| @@ -390,6 +420,8 @@ export function queueAgentRun( | ||
| 390 | 420 | ): Run | undefined { |
| 391 | 421 | // A suspended or removed owner's repositories run nothing, whoever asks. |
| 392 | 422 | if (!accountActive(app.db, repo.ownerId)) return undefined; |
| 423 | // Never a job outside the agent's seat: building is a builder's, reviewing a reviewer's. | |
| 424 | if (outsideSeat(app, repo, issue, agent, reason)) return undefined; | |
| 393 | 425 | // An archived repository starts nothing new; a run carrying on (`again`) |
| 394 | 426 | // is one already going, and finishes. A first run |
| 395 | 427 | // (`attempt` 0, as a conflict or converge run is queued) is new. |
| @@ -582,6 +614,12 @@ export function route(app: App, event: ForgeEvent): void { | ||
| 582 | 614 | ? getAgent(app.db, issue.assigneeId) |
| 583 | 615 | : undefined; |
| 584 | 616 | if (!agent || issue.state !== "open") return; |
| 617 | // Assigned to a planner or a reviewer: said once, never silently run. | |
| 618 | if (outsideSeat(app, repo, issue, agent, "assigned")) { | |
| 619 | if (!listComments(app.db, issue.id).some((c) => c.body === NOT_A_BUILDER_NOTE)) | |
| 620 | addComment(app, agent.user, repo, issue, NOT_A_BUILDER_NOTE); | |
| 621 | return; | |
| 622 | } | |
| 585 | 623 | queueAgentRun( |
| 586 | 624 | app, |
| 587 | 625 | repo, |
src/crew/planner.ts +2 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { addToCrew } from "../actions/crew.ts"; |
| 10 | import { getAgent, listCrewRoles, setCrewRole, type Agent } from "../core/agents.ts"; | |
| 10 | import { getAgent, listCrewRoles, type Agent } from "../core/agents.ts"; | |
| 11 | 11 | import type { Repo } from "../core/repos.ts"; |
| 12 | 12 | import type { User } from "../core/users.ts"; |
| 13 | 13 | |
| @@ -42,8 +42,7 @@ export function ensurePlanner(app: App, repo: Repo, by: User): Agent | undefined | ||
| 42 | 42 | if (have !== undefined) return have.agent; |
| 43 | 43 | const chosen = ownersPlanner(app, repo); |
| 44 | 44 | if (chosen === undefined) return undefined; |
| 45 | if (!addToCrew(app, by, repo, chosen.user.username).ok) return undefined; | |
| 46 | setCrewRole(app.db, repo.id, chosen.user.id, "planner"); | |
| 45 | if (!addToCrew(app, by, repo, chosen.user.username, "planner").ok) return undefined; | |
| 47 | 46 | return chosen; |
| 48 | 47 | } |
| 49 | 48 |
src/http/api.ts +1
| @@ -988,6 +988,7 @@ export function agentJson(agent: Agent) { | ||
| 988 | 988 | model: agent.model, |
| 989 | 989 | brief: agent.brief, |
| 990 | 990 | budgetUsd: agent.budgetUsd, |
| 991 | roles: agent.roles, | |
| 991 | 992 | createdAt: agent.createdAt, |
| 992 | 993 | }; |
| 993 | 994 | } |
src/http/api/agents.ts +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | askedRoles, | |
| 8 | 9 | createAgent, |
| 9 | 10 | deleteAgent, |
| 10 | 11 | getAgentView, |
| @@ -45,6 +46,7 @@ export function agentRoutes(router: Router<Handler>): void { | ||
| 45 | 46 | ...(typeof input.budgetUsd === "number" |
| 46 | 47 | ? { budgetUsd: input.budgetUsd } |
| 47 | 48 | : {}), |
| 49 | ...askedRoles(input.roles), | |
| 48 | 50 | }); |
| 49 | 51 | if (!made.ok) return respondAccess(made.error, 422); |
| 50 | 52 | return json(agentJson(made.value), 201); |
| @@ -76,6 +78,7 @@ export function agentRoutes(router: Router<Handler>): void { | ||
| 76 | 78 | ...(typeof input.budgetUsd === "number" |
| 77 | 79 | ? { budgetUsd: input.budgetUsd } |
| 78 | 80 | : {}), |
| 81 | ...askedRoles(input.roles), | |
| 79 | 82 | }, |
| 80 | 83 | ); |
| 81 | 84 | if (!updated.ok) return respondAccess(updated.error, 422); |
src/http/mcp/tools/agent.ts +5 −2
| @@ -1,10 +1,10 @@ | ||
| 1 | import { createAgent, deleteAgent, updateAgent } from "../../../actions/crew.ts"; | |
| 1 | import { askedRoles, createAgent, deleteAgent, updateAgent } from "../../../actions/crew.ts"; | |
| 2 | 2 | import type { Agent } from "../../../core/agents.ts"; |
| 3 | 3 | import { good, bad, type Tool, str } from "../shared.ts"; |
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "agent", |
| 7 | doc: "`{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`", | |
| 7 | doc: "`{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer), or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`", | |
| 8 | 8 | description: "Admin: create|update|remove an agent.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| @@ -15,6 +15,7 @@ export const tool: Tool = { | ||
| 15 | 15 | model: { type: "string" }, |
| 16 | 16 | brief: { type: "string" }, |
| 17 | 17 | budgetUsd: { type: "number" }, |
| 18 | roles: { type: "array", items: { type: "string" } }, | |
| 18 | 19 | }, |
| 19 | 20 | required: ["action", "username"], |
| 20 | 21 | }, |
| @@ -30,6 +31,7 @@ export const tool: Tool = { | ||
| 30 | 31 | ...(typeof model === "string" ? { model } : {}), |
| 31 | 32 | ...(typeof brief === "string" ? { brief } : {}), |
| 32 | 33 | ...(typeof budgetUsd === "number" ? { budgetUsd } : {}), |
| 34 | ...askedRoles(args["roles"]), | |
| 33 | 35 | }; |
| 34 | 36 | const shown = (a: Agent) => ({ |
| 35 | 37 | username: a.user.username, |
| @@ -37,6 +39,7 @@ export const tool: Tool = { | ||
| 37 | 39 | model: a.model, |
| 38 | 40 | brief: a.brief, |
| 39 | 41 | budgetUsd: a.budgetUsd, |
| 42 | roles: a.roles, | |
| 40 | 43 | createdAt: a.createdAt, |
| 41 | 44 | }); |
| 42 | 45 | const action = args["action"]; |
src/tui/screens/agents.ts +12 −3
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Crew agents in the terminal, a forge admin's: each agent's model, budget and |
| 3 | 3 | * the first line of its brief, as MCP `agent` and /api/v1/agents have them. |
| 4 | * `m`, `b` and `B` change model, budget and brief, `n` creates one and `d` | |
| 4 | * `m`, `b` and `B` change model, budget and brief, `r` its roles (planner, | |
| 5 | * builder, reviewer: what a repository's team lists offer it for), `n` creates one and `d` | |
| 5 | 6 | * removes one. Every change shows its plan and applies only after a confirm, |
| 6 | 7 | * through `updateAgent`, `createAgent` and `deleteAgent`, so the same bounds |
| 7 | 8 | * apply and a refusal is said in MCP's words (the error code). |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import { checkPatch, createAgent, deleteAgent, listAllAgents, updateAgent } from "../../actions/crew.ts"; |
| 11 | import type { AgentPatch } from "../../core/agents.ts"; | |
| 12 | import { CREW_ROLES, type AgentPatch } from "../../core/agents.ts"; | |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 14 | import { binding } from "../app/keys.ts"; |
| 14 | 15 | import { style } from "../term/style.ts"; |
| @@ -33,7 +34,7 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen { | ||
| 33 | 34 | const rows = (): ListRow[] => |
| 34 | 35 | list().map((a) => ({ |
| 35 | 36 | title: a.user.username, |
| 36 | secondary: `${a.model === "" ? "default model" : a.model} · ${a.budgetUsd === 0 ? "default budget" : `$${a.budgetUsd}`}`, | |
| 37 | secondary: `${a.roles.join("+")} · ${a.model === "" ? "default model" : a.model} · ${a.budgetUsd === 0 ? "default budget" : `$${a.budgetUsd}`}`, | |
| 37 | 38 | meta: firstLine(a.brief), |
| 38 | 39 | })); |
| 39 | 40 | |
| @@ -44,6 +45,7 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen { | ||
| 44 | 45 | binding(["m"], "model"), |
| 45 | 46 | binding(["b"], "budget"), |
| 46 | 47 | binding(["B"], "brief"), |
| 48 | binding(["r"], "roles"), | |
| 47 | 49 | binding(["n"], "new"), |
| 48 | 50 | binding(["d"], "remove"), |
| 49 | 51 | ], |
| @@ -101,6 +103,13 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen { | ||
| 101 | 103 | if (!bad.ok) return `refused: ${bad.error}`; |
| 102 | 104 | return later(ctx, () => confirm(name, { brief: v }, `${name}: brief → "${truncate(v, 60)}". y/n`)); |
| 103 | 105 | }); |
| 106 | else if (e.key === "r") | |
| 107 | ask(name, `roles: ${CREW_ROLES.join(", ")} (comma separated)`, agent.roles.join(", "), (v) => { | |
| 108 | const roles = v.split(",").map((r) => r.trim()).filter((r) => r !== ""); | |
| 109 | const bad = checkPatch(f.app, { roles }); | |
| 110 | if (!bad.ok) return `refused: ${bad.error}`; | |
| 111 | return later(ctx, () => confirm(name, bad.value, `${name}: roles ${agent.roles.join("+")} → ${roles.join("+")}. y/n`)); | |
| 112 | }); | |
| 104 | 113 | else if (e.key === "d") |
| 105 | 114 | ctx.push( |
| 106 | 115 | confirmScreen(ctx, screen, { |
src/web/pages/crew-settings.ts +7 −2
| @@ -43,7 +43,10 @@ export async function crewAdd(ctx: Ctx): Promise<Response> { | ||
| 43 | 43 | const { repo, form } = gate; |
| 44 | 44 | // The team card's one button names the seat it fills. |
| 45 | 45 | const role = field(form, "role"); |
| 46 | const added = addToCrew(ctx.app, ctx.auth.user, repo, field(form, "agent"), isCrewRole(role) ? role : undefined); | |
| 46 | const asked = isCrewRole(role) ? role : undefined; | |
| 47 | const added = addToCrew(ctx.app, ctx.auth.user, repo, field(form, "agent"), asked); | |
| 48 | // An agent already on the crew picked for another list moves to it. | |
| 49 | if (added.ok && asked !== undefined) setCrewRole(ctx.app.db, repo.id, added.value.user.id, asked); | |
| 47 | 50 | return flashTo( |
| 48 | 51 | ctx, |
| 49 | 52 | `${repoBase(repo)}/settings#crew`, |
| @@ -54,7 +57,9 @@ export async function crewAdd(ctx: Ctx): Promise<Response> { | ||
| 54 | 57 | : { |
| 55 | 58 | tone: "danger", |
| 56 | 59 | message: |
| 57 | added.error === "not_an_agent" | |
| 60 | added.error === "role_not_declared" | |
| 61 | ? "That agent does not do that job. Change its roles first, under Settings → Crew." | |
| 62 | : added.error === "not_an_agent" | |
| 58 | 63 | ? "Only an agent can be on a crew; a person is given access through an organization's team." |
| 59 | 64 | : "No such agent on this forge.", |
| 60 | 65 | }, |
src/web/pages/crew.ts +21 −1
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | } from "../../actions/crew.ts"; |
| 16 | 16 | import { |
| 17 | 17 | BRIEF_MAX, |
| 18 | CREW_ROLES, | |
| 18 | 19 | MODEL_MAX, |
| 19 | 20 | type Agent, |
| 20 | 21 | } from "../../core/agents.ts"; |
| @@ -50,7 +51,7 @@ import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts"; | ||
| 50 | 51 | import { listAutonomousRepos, type Repo } from "../../core/repos.ts"; |
| 51 | 52 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 52 | 53 | import { field, readForm, type Form } from "../form.ts"; |
| 53 | import { html, when, type Html } from "../html.ts"; | |
| 54 | import { html, raw, when, type Html } from "../html.ts"; | |
| 54 | 55 | import { flashTo, page } from "../layout.ts"; |
| 55 | 56 | import { |
| 56 | 57 | btn, |
| @@ -158,6 +159,7 @@ interface AgentValues { | ||
| 158 | 159 | model: string; |
| 159 | 160 | brief: string; |
| 160 | 161 | budgetUsd: string; |
| 162 | roles: readonly string[]; | |
| 161 | 163 | } |
| 162 | 164 | |
| 163 | 165 | const agentErrors = (ctx: Ctx): Record<string, string> => ({ |
| @@ -166,6 +168,8 @@ const agentErrors = (ctx: Ctx): Record<string, string> => ({ | ||
| 166 | 168 | email_taken: USERNAME_TAKEN, |
| 167 | 169 | invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`, |
| 168 | 170 | brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`, |
| 171 | invalid_roles: "Pick at least one job: planner, builder or reviewer.", | |
| 172 | role_held: "That agent still holds a seat it would lose on a repository. Move it there first, under Settings → Crew.", | |
| 169 | 173 | invalid_budget: `A budget is a number of dollars between 0 and ${limitValue(ctx.app.db, "agent.budget_max_usd")}.`, |
| 170 | 174 | }); |
| 171 | 175 | |
| @@ -190,6 +194,14 @@ function agentFields(values: AgentValues, error?: string): Html { | ||
| 190 | 194 | hint: "Who this agent is, in its own voice — appended to the prompt of every run. The repository's AGENTS.md says what the work is; this says who does it.", |
| 191 | 195 | ...(error !== undefined && error.startsWith("A brief") ? { error } : {}), |
| 192 | 196 | })} |
| 197 | <fieldset class="field" id="agent-roles"> | |
| 198 | <legend>Jobs</legend> | |
| 199 | ${CREW_ROLES.map( | |
| 200 | (r) => html`<label class="check"><input type="checkbox" name="roles" value="${r}" ${values.roles.includes(r) ? raw("checked") : null} /> | |
| 201 | <span>${r}</span></label>`, | |
| 202 | )} | |
| 203 | <p class="hint">What this agent does. A repository's team lists offer it only for these, and a dispatch never hands it another kind of job.${error !== undefined && error.startsWith("Pick at least") ? html` <strong>${error}</strong>` : null}</p> | |
| 204 | </fieldset> | |
| 193 | 205 | ${uiField({ |
| 194 | 206 | label: "Budget per run (USD)", |
| 195 | 207 | name: "budgetUsd", |
| @@ -206,8 +218,11 @@ const valuesOf = (agent: Agent): AgentValues => ({ | ||
| 206 | 218 | model: agent.model, |
| 207 | 219 | brief: agent.brief, |
| 208 | 220 | budgetUsd: agent.budgetUsd === 0 ? "" : String(agent.budgetUsd), |
| 221 | roles: agent.roles, | |
| 209 | 222 | }); |
| 210 | 223 | |
| 224 | const rolesOf = (form: Form): string[] => form.getAll("roles").map(String); | |
| 225 | ||
| 211 | 226 | const budgetOf = (form: Form): number | undefined => { |
| 212 | 227 | const raw = field(form, "budgetUsd").trim(); |
| 213 | 228 | return raw === "" ? undefined : Number(raw); |
| @@ -930,6 +945,7 @@ const EMPTY: AgentValues = { | ||
| 930 | 945 | model: "", |
| 931 | 946 | brief: "", |
| 932 | 947 | budgetUsd: "", |
| 948 | roles: ["builder"], | |
| 933 | 949 | }; |
| 934 | 950 | |
| 935 | 951 | // GET /settings/crew |
| @@ -963,6 +979,7 @@ export async function agentCreate(ctx: Ctx): Promise<Response> { | ||
| 963 | 979 | model: field(form, "model").trim(), |
| 964 | 980 | brief: field(form, "brief"), |
| 965 | 981 | budgetUsd: field(form, "budgetUsd").trim(), |
| 982 | roles: rolesOf(form), | |
| 966 | 983 | }; |
| 967 | 984 | const budgetUsd = budgetOf(form); |
| 968 | 985 | const made = await createAgent(ctx.app, ctx.auth.user, { |
| @@ -970,6 +987,7 @@ export async function agentCreate(ctx: Ctx): Promise<Response> { | ||
| 970 | 987 | ...(values.displayName === "" ? {} : { displayName: values.displayName }), |
| 971 | 988 | model: values.model, |
| 972 | 989 | brief: values.brief, |
| 990 | roles: values.roles, | |
| 973 | 991 | ...(budgetUsd === undefined ? {} : { budgetUsd }), |
| 974 | 992 | }); |
| 975 | 993 | if (!made.ok) { |
| @@ -1059,11 +1077,13 @@ export async function agentUpdate(ctx: Ctx): Promise<Response> { | ||
| 1059 | 1077 | model: field(form, "model").trim(), |
| 1060 | 1078 | brief: field(form, "brief"), |
| 1061 | 1079 | budgetUsd: field(form, "budgetUsd").trim(), |
| 1080 | roles: rolesOf(form), | |
| 1062 | 1081 | }; |
| 1063 | 1082 | const updated = updateAgent(ctx.app, ctx.auth.user, agent.user.username, { |
| 1064 | 1083 | model: values.model, |
| 1065 | 1084 | brief: values.brief, |
| 1066 | 1085 | budgetUsd: budgetOf(form) ?? 0, |
| 1086 | roles: values.roles, | |
| 1067 | 1087 | }); |
| 1068 | 1088 | if (!updated.ok) { |
| 1069 | 1089 | if (updated.error === "not_found") return notFoundPage(ctx); |
src/web/pages/migrate-prefill.ts +20
| @@ -18,6 +18,8 @@ export interface Prefill { | ||
| 18 | 18 | readonly agent?: string; |
| 19 | 19 | /** Values that matched nothing, as given (capped) — the page escapes them. */ |
| 20 | 20 | readonly unmatched: string[]; |
| 21 | /** Sentences for a name that is on the forge but is not a builder. */ | |
| 22 | readonly refused: string[]; | |
| 21 | 23 | } |
| 22 | 24 | |
| 23 | 25 | const FROM_MAX = 200; |
| @@ -36,8 +38,10 @@ export function prefill( | ||
| 36 | 38 | query: { from?: string | null; crew?: string | null }, |
| 37 | 39 | origins: readonly PrefillOrigin[], |
| 38 | 40 | agents: readonly string[], |
| 41 | notBuilders: readonly string[] = [], | |
| 39 | 42 | ): Prefill { |
| 40 | 43 | const unmatched: string[] = []; |
| 44 | const refused: string[] = []; | |
| 41 | 45 | let repo: Prefill["repo"]; |
| 42 | 46 | let agent: string | undefined; |
| 43 | 47 | |
| @@ -63,6 +67,8 @@ export function prefill( | ||
| 63 | 67 | if (crew !== "") { |
| 64 | 68 | if (crew.length <= CREW_MAX && CREW_SAFE.test(crew) && agents.includes(crew)) |
| 65 | 69 | agent = crew; |
| 70 | else if (crew.length <= CREW_MAX && notBuilders.includes(crew)) | |
| 71 | refused.push(`${crew} is not a builder, so it cannot take a migration. Pick one of the builders below.`); | |
| 66 | 72 | else unmatched.push(`crew=${shown(crew)}`); |
| 67 | 73 | } |
| 68 | 74 | |
| @@ -70,5 +76,19 @@ export function prefill( | ||
| 70 | 76 | ...(repo ? { repo } : {}), |
| 71 | 77 | ...(agent ? { agent } : {}), |
| 72 | 78 | unmatched, |
| 79 | refused, | |
| 73 | 80 | }; |
| 74 | 81 | } |
| 82 | ||
| 83 | /** | |
| 84 | * The agents a migration may be handed to: builders only, the ones that do | |
| 85 | * nothing else first (a specialist is the surest fit), then by name. The | |
| 86 | * first is the page's default, so a planner that sorts first is never picked. | |
| 87 | */ | |
| 88 | export function migrationBuilders<A extends { roles: readonly string[]; user: { username: string } }>( | |
| 89 | agents: readonly A[], | |
| 90 | ): A[] { | |
| 91 | return agents | |
| 92 | .filter((a) => a.roles.includes("builder")) | |
| 93 | .sort((a, b) => a.roles.length - b.roles.length || a.user.username.localeCompare(b.user.username)); | |
| 94 | } |
src/web/pages/migrate.ts +15 −4
| @@ -69,7 +69,7 @@ import { | ||
| 69 | 69 | } from "../../origins/token-reach.ts"; |
| 70 | 70 | import type { TokenReach } from "../../origins/types.ts"; |
| 71 | 71 | import { STAGE_BADGE_JS, stageRow, stageStateFor } from "../stage-row.ts"; |
| 72 | import { prefill, originHost } from "./migrate-prefill.ts"; | |
| 72 | import { prefill, originHost, migrationBuilders } from "./migrate-prefill.ts"; | |
| 73 | 73 | import { githubConnectable, hostLabel } from "./origins.ts"; |
| 74 | 74 | |
| 75 | 75 | interface MigrateValues { |
| @@ -424,6 +424,7 @@ function migrateForm( | ||
| 424 | 424 | branches: BranchChoices | undefined, |
| 425 | 425 | error?: string, |
| 426 | 426 | unmatched: readonly string[] = [], |
| 427 | refused: readonly string[] = [], | |
| 427 | 428 | ): Html { |
| 428 | 429 | const user = ctx.auth.user!; |
| 429 | 430 | const writeOrgs = listOrgsForUser(ctx.app.db, user.id).filter((org) => |
| @@ -431,7 +432,7 @@ function migrateForm( | ||
| 431 | 432 | ); |
| 432 | 433 | const owners = [user.username, ...writeOrgs.map((o) => o.username)]; |
| 433 | 434 | const agents = listAllAgents(ctx.app, user); |
| 434 | const crew = agents.ok ? agents.value : []; | |
| 435 | const crew = agents.ok ? migrationBuilders(agents.value) : []; | |
| 435 | 436 | |
| 436 | 437 | return html`<div class="narrow-wide"> |
| 437 | 438 | ${pageHead("Migrate a repository", { |
| @@ -449,6 +450,11 @@ function migrateForm( | ||
| 449 | 450 | ${icon("globe")} Ignored, nothing here matches: |
| 450 | 451 | ${unmatched.join(", ")} |
| 451 | 452 | </p>`} |
| 453 | ${refused.length === 0 | |
| 454 | ? null | |
| 455 | : html`<p class="muted small mb" id="prefill-refused"> | |
| 456 | ${icon("globe")} ${refused.join(" ")} | |
| 457 | </p>`} | |
| 452 | 458 | ${accounts(ctx, origins)} |
| 453 | 459 | <form method="get" action="/migrate" id="migrate-search"></form> |
| 454 | 460 | ${card( |
| @@ -613,6 +619,7 @@ async function show( | ||
| 613 | 619 | error?: string, |
| 614 | 620 | status = 200, |
| 615 | 621 | unmatched: readonly string[] = [], |
| 622 | refused: readonly string[] = [], | |
| 616 | 623 | ): Promise<Response> { |
| 617 | 624 | // Asked for only: each draw is a listing and a commit per branch at the |
| 618 | 625 | // origin (remembered a minute), so a plain visit costs none of it. |
| @@ -626,7 +633,7 @@ async function show( | ||
| 626 | 633 | { |
| 627 | 634 | ctx, |
| 628 | 635 | title: "Migrate", |
| 629 | body: migrateForm(ctx, values, origins, branches, error, unmatched), | |
| 636 | body: migrateForm(ctx, values, origins, branches, error, unmatched, refused), | |
| 630 | 637 | scriptJs: migrateJs, |
| 631 | 638 | }, |
| 632 | 639 | status, |
| @@ -647,11 +654,14 @@ export async function migratePage(ctx: Ctx): Promise<Response> { | ||
| 647 | 654 | const agents = timePhase(ctx.request, "agents", () => |
| 648 | 655 | listAllAgents(ctx.app, user), |
| 649 | 656 | ); |
| 650 | const names = agents.ok ? agents.value.map((a) => a.user.username) : []; | |
| 657 | const all = agents.ok ? agents.value : []; | |
| 658 | const builders = migrationBuilders(all); | |
| 659 | const names = builders.map((a) => a.user.username); | |
| 651 | 660 | const linked = prefill( |
| 652 | 661 | { from: q.get("from"), crew: q.get("crew") }, |
| 653 | 662 | origins, |
| 654 | 663 | names, |
| 664 | all.filter((a) => !builders.includes(a)).map((a) => a.user.username), | |
| 655 | 665 | ); |
| 656 | 666 | return show( |
| 657 | 667 | ctx, |
| @@ -676,6 +686,7 @@ export async function migratePage(ctx: Ctx): Promise<Response> { | ||
| 676 | 686 | undefined, |
| 677 | 687 | 200, |
| 678 | 688 | linked.unmatched, |
| 689 | linked.refused, | |
| 679 | 690 | ); |
| 680 | 691 | } |
| 681 | 692 |
src/web/pages/settings.ts +3 −1
| @@ -83,7 +83,7 @@ import { | ||
| 83 | 83 | settingsLayout, |
| 84 | 84 | } from "../chrome.ts"; |
| 85 | 85 | import { daySpark } from "../studio/kpi_row.ts"; |
| 86 | import { settingsLead } from "../studio/crew.ts"; | |
| 86 | import { settingsLead, teamJs } from "../studio/crew.ts"; | |
| 87 | 87 | import { pageTitle } from "../studio/nouns.ts"; |
| 88 | 88 | import { appHref } from "./apps.ts"; |
| 89 | 89 | import { field, readForm, type Form } from "../form.ts"; |
| @@ -191,6 +191,7 @@ export function settingsPageResponse( | ||
| 191 | 191 | crumbs: crumbsFor(repo), |
| 192 | 192 | palette: repoPalette(ctx, repo), |
| 193 | 193 | ...(styleCss !== undefined ? { styleCss } : {}), |
| 194 | scriptJs: teamJs, | |
| 194 | 195 | body: html`${repoHeader(ctx, repo, "settings", { compact: true })} |
| 195 | 196 | ${settingsLayout(sections(repoBase(repo), active), body)}`, |
| 196 | 197 | }, |
| @@ -236,6 +237,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 236 | 237 | title: pageTitle("settings", readable, true), |
| 237 | 238 | crumbs: crumbsFor(readable), |
| 238 | 239 | palette: repoPalette(ctx, readable), |
| 240 | scriptJs: teamJs, | |
| 239 | 241 | body: html`${repoHeader(ctx, readable, "settings", { compact: true })} |
| 240 | 242 | ${lead}${proposals ? html`<div class="mt">${proposals}</div>` : null}`, |
| 241 | 243 | }); |
src/web/studio/crew.ts +78 −98
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | */ |
| 27 | 27 | |
| 28 | 28 | import { listAllAgents, listCrew } from "../../actions/crew.ts"; |
| 29 | import { CREW_ROLES, listCrewRoles, type CrewRole } from "../../core/agents.ts"; | |
| 29 | import { listCrewRoles, type Agent, type CrewRole } from "../../core/agents.ts"; | |
| 30 | 30 | import { getAppByRepo } from "../../core/apps.ts"; |
| 31 | 31 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 32 | 32 | import { seatsOf, type Seat } from "../../crew/seats.ts"; |
| @@ -36,7 +36,7 @@ import { governorSeen } from "../../crew/governed.ts"; | ||
| 36 | 36 | import { crewOffInPreview } from "../../crew/preview.ts"; |
| 37 | 37 | import type { Ctx } from "../../http/context.ts"; |
| 38 | 38 | import { repoBase } from "../chrome.ts"; |
| 39 | import { html, raw, type Html } from "../html.ts"; | |
| 39 | import { html, type Html } from "../html.ts"; | |
| 40 | 40 | import { icon } from "../icons.ts"; |
| 41 | 41 | import { crewReadiness } from "../pages/crew.ts"; |
| 42 | 42 | import { grantsSection } from "../pages/subscriptions.ts"; |
| @@ -125,59 +125,93 @@ const SEAT_BADGE: Record<Seat["state"], { text: string; tone: Tone }> = { | ||
| 125 | 125 | invite: { text: "open seat", tone: "default" }, |
| 126 | 126 | }; |
| 127 | 127 | |
| 128 | /** The script: choosing in a team list, or setting its number, saves at once. */ | |
| 129 | export const teamJs = /* js */ ` | |
| 130 | (function () { | |
| 131 | document.querySelectorAll("form[data-team] select, form[data-team] input[type=number]").forEach(function (el) { | |
| 132 | el.addEventListener("change", function () { el.form.requestSubmit(); }); | |
| 133 | }); | |
| 134 | document.querySelectorAll("form[data-team] button[type=submit]").forEach(function (b) { b.hidden = true; }); | |
| 135 | })(); | |
| 136 | `; | |
| 137 | ||
| 138 | const SEAT_TITLE: Record<CrewRole, string> = { | |
| 139 | planner: "Planners", | |
| 140 | builder: "Builders", | |
| 141 | reviewer: "Reviewers", | |
| 142 | }; | |
| 143 | ||
| 128 | 144 | /** |
| 129 | * Your team, as three seats (`seatsOf`, the same facts the MCP `crew` tool | |
| 130 | * returns): whether it works on its own, who fills each seat, what it does, | |
| 131 | * and — for an empty one — a warning when the team is on its own, a calm | |
| 132 | * invitation when it is not, each with one button that puts an agent in it. | |
| 145 | * Your team, as three lists: Planners, Builders, Reviewers (`seatsOf`, the | |
| 146 | * same facts the MCP `crew` tool returns). Each list holds who fills it, a | |
| 147 | * picker of only the agents that declare that role, and how many may work at | |
| 148 | * once; each choice posts at once (`teamJs`; without script, the button is | |
| 149 | * there). A seat with nobody warns when the team is on its own. | |
| 133 | 150 | */ |
| 134 | 151 | function teamCard( |
| 135 | 152 | ctx: Ctx, |
| 136 | 153 | repo: Repo, |
| 137 | 154 | base: string, |
| 138 | 155 | admin: boolean, |
| 139 | candidate: string | undefined, | |
| 156 | everyone: readonly Agent[], | |
| 140 | 157 | ): Html { |
| 141 | 158 | const team = seatsOf(ctx.app.db, repo, crewOffInPreview()); |
| 142 | const fill = (p: Seat): Html | undefined => { | |
| 143 | if (!admin || p.state === "filled") return undefined; | |
| 144 | if (candidate === undefined) | |
| 145 | return ctx.auth.user?.isAdmin | |
| 146 | ? btn("Add an agent", { href: "/settings/crew", size: "sm" }) | |
| 147 | : undefined; | |
| 148 | return html`<form class="row-actions" method="post" action="${base}/settings/crew"> | |
| 149 | <input type="hidden" name="agent" value="${candidate}" /> | |
| 150 | <input type="hidden" name="role" value="${p.role}" /> | |
| 151 | ${btn(`Put ${candidate} on ${p.role}`, { | |
| 152 | size: "sm", | |
| 153 | variant: p.state === "warn" ? "primary" : "secondary", | |
| 154 | })} | |
| 155 | </form>`; | |
| 156 | }; | |
| 159 | const held = new Map(listCrewRoles(ctx.app.db, repo.id).map((c) => [c.agent.user.username, c.role])); | |
| 157 | 160 | const seatSlots = new Map(seatSlotsOf(ctx.app.db, repo).map((p) => [p.role, p])); |
| 158 | 161 | const rows = team.seats.map((p) => { |
| 159 | const fillBy = fill(p); | |
| 160 | const b = SEAT_BADGE[p.state]; | |
| 161 | 162 | const slots = seatSlots.get(p.role)!; |
| 162 | 163 | const paused = slots.slots === 0; |
| 163 | const setter = html`<form class="row-fields" method="post" action="${base}/settings/pillars"> | |
| 164 | <input type="hidden" name="seat" value="${p.role}" /> | |
| 165 | <label class="sr-only" for="f-seat-${p.role}">${p.role}: ${SEAT_UNIT[p.role]}</label> | |
| 166 | <input class="input input-sm" id="f-seat-${p.role}" name="slots" type="number" inputmode="numeric" | |
| 167 | min="0" max="${String(slots.max)}" value="${String(slots.slots)}" /> | |
| 168 | ${btn("Set", { size: "sm" })} | |
| 169 | </form>`; | |
| 164 | const b = SEAT_BADGE[p.state]; | |
| 165 | const picks = everyone.filter( | |
| 166 | (a) => a.roles.includes(p.role) && held.get(a.user.username) !== p.role, | |
| 167 | ); | |
| 168 | const add = | |
| 169 | !admin || picks.length === 0 | |
| 170 | ? null | |
| 171 | : html`<form class="row-fields" method="post" action="${base}/settings/crew" data-team> | |
| 172 | <input type="hidden" name="role" value="${p.role}" /> | |
| 173 | <label class="sr-only" for="f-add-${p.role}">Add to ${SEAT_TITLE[p.role]}</label> | |
| 174 | <select class="input input-sm" id="f-add-${p.role}" name="agent"> | |
| 175 | <option value="" disabled selected>Add…</option> | |
| 176 | ${picks.map((a) => html`<option value="${a.user.username}">${a.user.username}</option>`)} | |
| 177 | </select> | |
| 178 | ${btn("Add", { size: "sm" })} | |
| 179 | </form>`; | |
| 180 | const setter = !admin | |
| 181 | ? null | |
| 182 | : html`<form class="row-fields" method="post" action="${base}/settings/pillars" data-team> | |
| 183 | <input type="hidden" name="seat" value="${p.role}" /> | |
| 184 | <label class="sr-only" for="f-seat-${p.role}">${p.role}: ${SEAT_UNIT[p.role]}</label> | |
| 185 | <input class="input input-sm" id="f-seat-${p.role}" name="slots" type="number" inputmode="numeric" | |
| 186 | min="0" max="${String(slots.max)}" value="${String(slots.slots)}" /> | |
| 187 | ${btn("Set", { size: "sm" })} | |
| 188 | </form>`; | |
| 189 | const members = p.by.map((u) => | |
| 190 | row({ | |
| 191 | lead: icon("bot"), | |
| 192 | title: userLink(u), | |
| 193 | ...(admin | |
| 194 | ? { | |
| 195 | trail: html`<form class="row-actions" method="post" action="${base}/settings/crew/${encodeURIComponent(u)}/remove"> | |
| 196 | ${btn("Remove", { size: "sm", icon: "x" })} | |
| 197 | </form>`, | |
| 198 | } | |
| 199 | : {}), | |
| 200 | data: { member: u, seat: p.role }, | |
| 201 | }), | |
| 202 | ); | |
| 170 | 203 | return row({ |
| 171 | 204 | lead: icon(SEAT_LEAD[p.state]), |
| 172 | title: p.role[0]!.toUpperCase() + p.role.slice(1), | |
| 173 | meta: html`${p.by.length > 0 ? html`${p.by.map((u, i) => html`${i > 0 ? ", " : ""}${userLink(u)}`)}: ` : null}${p.does}${p.state === "warn" ? ` No ${p.role}: ${p.without}.` : null}`, | |
| 174 | trail: html`${paused ? badge("paused", { tone: "attn" }) : badge(b.text, { tone: b.tone })}${fillBy ?? null}`, | |
| 205 | title: SEAT_TITLE[p.role], | |
| 206 | meta: html`${p.does}${p.state === "warn" ? ` No ${p.role}: ${p.without}.` : null}`, | |
| 207 | trail: html`${paused ? badge("paused", { tone: "attn" }) : badge(b.text, { tone: b.tone })}${add}`, | |
| 175 | 208 | data: { seat: p.role, state: p.state }, |
| 176 | 209 | children: [ |
| 210 | ...members, | |
| 177 | 211 | row({ |
| 178 | 212 | title: paused ? "Paused" : `${String(slots.slots)} ${SEAT_UNIT[p.role]}`, |
| 179 | 213 | meta: html`${seatSlotsWords(slots)}`, |
| 180 | ...(admin ? { trail: setter } : {}), | |
| 214 | ...(setter ? { trail: setter } : {}), | |
| 181 | 215 | data: { slots: p.role, at: String(slots.slots) }, |
| 182 | 216 | }), |
| 183 | 217 | ], |
| @@ -187,7 +221,7 @@ function teamCard( | ||
| 187 | 221 | ? "The crew is off in a preview, so nobody here is working." |
| 188 | 222 | : team.auto |
| 189 | 223 | ? `Auto mode is on (${team.mode}): your team works on its own — a planner decides what is next, a builder builds it, a reviewer checks it.` |
| 190 | : "Auto mode is off: your team works only when you ask. Fill the seats you want, then turn auto mode on under On its own. Each seat says how much it may do at once; 0 pauses it without taking it off the team."; | |
| 224 | : "Auto mode is off: your team works only when you ask. Pick who fills each list, then turn auto mode on under On its own. The number beside a list is how many may work at once; 0 pauses it."; | |
| 191 | 225 | return card( |
| 192 | 226 | html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}"> |
| 193 | 227 | ${intro} |
| @@ -256,72 +290,18 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 256 | 290 | ...(admin |
| 257 | 291 | ? { |
| 258 | 292 | trail: html`<form |
| 259 | class="row-fields" | |
| 260 | method="post" | |
| 261 | action="${base}/settings/crew/${encodeURIComponent( | |
| 262 | c.agent.user.username, | |
| 263 | )}/role" | |
| 264 | > | |
| 265 | <label | |
| 266 | class="sr-only" | |
| 267 | for="f-role-${c.agent.user.username}" | |
| 268 | >Role of ${c.agent.user.username}</label | |
| 269 | > | |
| 270 | <select | |
| 271 | class="input input-sm" | |
| 272 | id="f-role-${c.agent.user.username}" | |
| 273 | name="role" | |
| 274 | > | |
| 275 | ${CREW_ROLES.map( | |
| 276 | (r) => | |
| 277 | html`<option | |
| 278 | value="${r}" | |
| 279 | ${c.role === r ? raw("selected") : null} | |
| 280 | > | |
| 281 | ${ROLE_WORD[r]} | |
| 282 | </option>`, | |
| 283 | )} | |
| 284 | </select> | |
| 285 | ${btn("Set", { size: "sm" })} | |
| 286 | </form> | |
| 287 | <form | |
| 288 | class="row-actions" | |
| 289 | method="post" | |
| 290 | action="${base}/settings/crew/${encodeURIComponent( | |
| 291 | c.agent.user.username, | |
| 292 | )}/remove" | |
| 293 | > | |
| 294 | ${btn("Remove", { size: "sm", icon: "x" })} | |
| 295 | </form>`, | |
| 293 | class="row-actions" | |
| 294 | method="post" | |
| 295 | action="${base}/settings/crew/${encodeURIComponent(c.agent.user.username)}/remove" | |
| 296 | > | |
| 297 | ${btn("Remove", { size: "sm", icon: "x" })} | |
| 298 | </form>`, | |
| 296 | 299 | } |
| 297 | 300 | : {}), |
| 298 | 301 | }), |
| 299 | 302 | ), |
| 300 | 303 | ); |
| 301 | 304 | |
| 302 | const add = | |
| 303 | !admin || available.length === 0 | |
| 304 | ? null | |
| 305 | : card( | |
| 306 | html`<form class="edit-row" method="post" action="${base}/settings/crew"> | |
| 307 | <label class="sr-only" for="f-agent">Agent</label> | |
| 308 | <select class="input" id="f-agent" name="agent"> | |
| 309 | ${available.map( | |
| 310 | (agent) => | |
| 311 | html`<option value="${agent.user.username}"> | |
| 312 | ${agent.user.username} | |
| 313 | </option>`, | |
| 314 | )} | |
| 315 | </select> | |
| 316 | ${btn("Add to crew", { | |
| 317 | variant: "primary", | |
| 318 | size: "sm", | |
| 319 | icon: "plus", | |
| 320 | })} | |
| 321 | </form>`, | |
| 322 | { head: html`<h2>Add an agent</h2>`, pad: false }, | |
| 323 | ); | |
| 324 | ||
| 325 | 305 | // Whose money the crew spends here is the forge admin's to say. |
| 326 | 306 | const grants = |
| 327 | 307 | admin && forgeAdmin |
| @@ -336,14 +316,14 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 336 | 316 | })() |
| 337 | 317 | : null; |
| 338 | 318 | return html`<div id="crew"> |
| 339 | ${teamCard(ctx, repo, base, admin, available[0]?.user.username)} | |
| 319 | ${teamCard(ctx, repo, base, admin, allListed.ok ? allListed.value : [])} | |
| 340 | 320 | ${admin ? crewReadiness(ctx) : null} |
| 341 | 321 | ${card(html`${rows}`, { |
| 342 | 322 | head: html`${icon("bot")} |
| 343 | 323 | <h2>Crew</h2>`, |
| 344 | 324 | pad: false, |
| 345 | 325 | })} |
| 346 | ${add} ${grants} | |
| 326 | ${grants} | |
| 347 | 327 | </div>`; |
| 348 | 328 | } |
| 349 | 329 |
test/api-give-back.test.ts +3 −2
| @@ -5,12 +5,13 @@ | ||
| 5 | 5 | * reader, or someone who cannot see the repository, gets `not_found`. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| 11 | 12 | import { join } from "node:path"; |
| 12 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 13 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 14 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 14 | 15 | import { getIssueById, listComments } from "../src/core/issues.ts"; |
| 15 | 16 | import { createRepo, type Repo } from "../src/core/repos.ts"; |
| 16 | 17 | import { submitReview } from "../src/core/reviews.ts"; |
| @@ -121,7 +122,7 @@ describe("POST /api/v1/asks/give-back", () => { | ||
| 121 | 122 | privateRepo = closed.value; |
| 122 | 123 | for (const target of [repo, privateRepo]) { |
| 123 | 124 | for (const agent of [builder, reviewer]) addCrew(app.db, target.id, agent.id, owner.id); |
| 124 | setCrewRole(app.db, target.id, reviewer.id, "reviewer"); | |
| 125 | seatAs(app.db, target.id, reviewer.id, "reviewer"); | |
| 125 | 126 | } |
| 126 | 127 | rw = createToken(app.db, owner.id, "rw", "rw").token; |
| 127 | 128 | ro = createToken(app.db, owner.id, "ro", "ro").token; |
test/api-user-next-steps.test.ts +3 −2
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * same three steps as MCP `next_steps`; anyone else, or an agent, gets []. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { seatAs } from "./helpers/seat.ts"; | |
| 6 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 9 | import { tmpdir } from "node:os"; |
| @@ -10,7 +11,7 @@ import { join } from "node:path"; | ||
| 10 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| 11 | 12 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 12 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 13 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 14 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 14 | 15 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 16 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 16 | 17 | import { commitFiles } from "../src/git/write.ts"; |
| @@ -79,7 +80,7 @@ describe("GET /api/v1/user/next-steps", () => { | ||
| 79 | 80 | createdBy: alice.id, |
| 80 | 81 | }); |
| 81 | 82 | addCrew(parent.db, repo.value.id, atlas.value.id, alice.id); |
| 82 | setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 83 | seatAs(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 83 | 84 | parentServers = await serve(parent); |
| 84 | 85 | const spawned = await spawnForge( |
| 85 | 86 | parent, |
test/ask-settles.test.ts +3 −2
| @@ -5,12 +5,13 @@ | ||
| 5 | 5 | * changes requested goes back to its builder after a day of their silence. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| 11 | 12 | import { join } from "node:path"; |
| 12 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 13 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 14 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 14 | 15 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 15 | 16 | import { submitReview } from "../src/core/reviews.ts"; |
| 16 | 17 | import { createRepo, type Repo } from "../src/core/repos.ts"; |
| @@ -63,7 +64,7 @@ describe("an ask settles", () => { | ||
| 63 | 64 | if (!made.ok) throw new Error(made.error); |
| 64 | 65 | createAgentRow(app.db, { userId: made.value.id, brief: "b" }); |
| 65 | 66 | addCrew(app.db, repo.id, made.value.id, owner.id); |
| 66 | setCrewRole(app.db, repo.id, made.value.id, role); | |
| 67 | seatAs(app.db, repo.id, made.value.id, role); | |
| 67 | 68 | return made.value; |
| 68 | 69 | }; |
| 69 | 70 | const work = (kind: "pull" | "issue", assignee: User | null, createdAt = new Date().toISOString()): Issue => { |
test/asks-bulk.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * first: an outsider and a plain reader act on nothing. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -17,7 +18,7 @@ import { addComment, openIssue } from "../src/actions/issues.ts"; | ||
| 17 | 18 | import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts"; |
| 18 | 19 | import { openPull } from "../src/actions/pulls.ts"; |
| 19 | 20 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 21 | import { getAgentByName } from "../src/core/agents.ts"; | |
| 21 | 22 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; |
| 22 | 23 | import { assignIssue } from "../src/actions/issues.ts"; |
| 23 | 24 | import { mutedRepos, recordInteraction, removeMutes } from "../src/core/interactions.ts"; |
| @@ -169,7 +170,7 @@ describe("acting on many asks at once", () => { | ||
| 169 | 170 | }); |
| 170 | 171 | addToCrew(app, alice, made.value, "bot"); |
| 171 | 172 | addToCrew(app, alice, made.value, "planner"); |
| 172 | setCrewRole( | |
| 173 | seatAs( | |
| 173 | 174 | app.db, |
| 174 | 175 | made.value.id, |
| 175 | 176 | getAgentByName(app.db, "planner")!.user.id, |
test/asks-counts.test.ts +3 −2
| @@ -9,13 +9,14 @@ | ||
| 9 | 9 | * branch of `askCandidatesFor` is on the measured path. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { seatAs } from "./helpers/seat.ts"; | |
| 12 | 13 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 13 | 14 | import type { Database } from "bun:sqlite"; |
| 14 | 15 | import { mkdtemp, rm } from "node:fs/promises"; |
| 15 | 16 | import { tmpdir } from "node:os"; |
| 16 | 17 | import { join } from "node:path"; |
| 17 | 18 | import { createApp, type App } from "../src/app.ts"; |
| 18 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 19 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 19 | 20 | import { createApp as createAppRecord } from "../src/core/apps.ts"; |
| 20 | 21 | import { createRepo, type Repo } from "../src/core/repos.ts"; |
| 21 | 22 | import { submitReview } from "../src/core/reviews.ts"; |
| @@ -94,7 +95,7 @@ describe("home's asks are read grouped, and once", () => { | ||
| 94 | 95 | }); |
| 95 | 96 | if (!made.ok) throw new Error(`seed repo ${name}: ${made.error}`); |
| 96 | 97 | for (const agent of [builder, reviewer]) addCrew(app.db, made.value.id, agent.id, owner.id); |
| 97 | setCrewRole(app.db, made.value.id, reviewer.id, "reviewer"); | |
| 98 | seatAs(app.db, made.value.id, reviewer.id, "reviewer"); | |
| 98 | 99 | app.db.query("UPDATE repo SET autonomy = ?2 WHERE id = ?1").run(made.value.id, autonomy); |
| 99 | 100 | return made.value; |
| 100 | 101 | }; |
test/asks-waiting.test.ts +3 −2
| @@ -4,12 +4,13 @@ | ||
| 4 | 4 | * pull still young stays the crew's. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| 10 | 11 | import { join } from "node:path"; |
| 11 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 13 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 13 | 14 | import { addComment } from "../src/core/issues.ts"; |
| 14 | 15 | import { setHold } from "../src/core/holds.ts"; |
| 15 | 16 | import { createRepo, type Repo } from "../src/core/repos.ts"; |
| @@ -109,7 +110,7 @@ describe("asks that wait on a person", () => { | ||
| 109 | 110 | if (!made.ok) throw new Error(`seed repo: ${made.error}`); |
| 110 | 111 | repo = made.value; |
| 111 | 112 | for (const agent of [builder, reviewer]) addCrew(app.db, repo.id, agent.id, owner.id); |
| 112 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 113 | seatAs(app.db, repo.id, reviewer.id, "reviewer"); | |
| 113 | 114 | token = createToken(app.db, owner.id, "ro", "ro").token; |
| 114 | 115 | |
| 115 | 116 | handed = pull(builder, "Handed over", 3); |
test/authors-pull.test.ts +3 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * following its branch. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { seatAs } from "./helpers/seat.ts"; | |
| 10 | 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 11 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 12 | 13 | import { tmpdir } from "node:os"; |
| @@ -16,7 +17,7 @@ import { openPull } from "../src/actions/pulls.ts"; | ||
| 16 | 17 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 17 | 18 | import { createApp, type App } from "../src/app.ts"; |
| 18 | 19 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 19 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 20 | import {} from "../src/core/agents.ts"; | |
| 20 | 21 | import { getIssueById } from "../src/core/issues.ts"; |
| 21 | 22 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 22 | 23 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| @@ -105,7 +106,7 @@ describe("a person's in-repo pull is a normal pull the crew leaves to its author | ||
| 105 | 106 | } |
| 106 | 107 | builder = getUserByName(app.db, "mason")!; |
| 107 | 108 | reviewer = getUserByName(app.db, "quill")!; |
| 108 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 109 | seatAs(app.db, repo.id, reviewer.id, "reviewer"); | |
| 109 | 110 | app.db |
| 110 | 111 | .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_slots = 3 WHERE id = ?1") |
| 111 | 112 | .run(repo.id, alice.id); |
test/autonomy.test.ts +16 −13
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * here starts a real agent; the crew is not running, so an assignment is |
| 6 | 6 | * the observable effect. |
| 7 | 7 | */ |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -18,7 +19,7 @@ import { | ||
| 18 | 19 | createRepo, |
| 19 | 20 | updateRepoSettings, |
| 20 | 21 | } from "../src/actions/repos.ts"; |
| 21 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 22 | import {} from "../src/core/agents.ts"; | |
| 22 | 23 | import { |
| 23 | 24 | latestDecision, |
| 24 | 25 | recordDecision, |
| @@ -110,9 +111,9 @@ describe("a repository on its own", () => { | ||
| 110 | 111 | }); |
| 111 | 112 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); |
| 112 | 113 | for (const name of ["scout", "mason", "atlas"]) { |
| 113 | const agent = await createAgent(app, alice, { username: name }); | |
| 114 | const agent = await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] }); | |
| 114 | 115 | if (!agent.ok) throw new Error(String(agent.error)); |
| 115 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 116 | expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true); | |
| 116 | 117 | } |
| 117 | 118 | scout = getUserByName( |
| 118 | 119 | app.db, |
| @@ -126,7 +127,7 @@ describe("a repository on its own", () => { | ||
| 126 | 127 | app.db, |
| 127 | 128 | "atlas", |
| 128 | 129 | )!; |
| 129 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 130 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 130 | 131 | for (const title of ["Renderer", "Input", "Scene graph"]) { |
| 131 | 132 | const opened = openIssue(app, alice, repo, { title }); |
| 132 | 133 | if (!opened.ok) throw new Error(String(opened.error)); |
| @@ -298,7 +299,7 @@ describe("a repository on its own", () => { | ||
| 298 | 299 | ], |
| 299 | 300 | }); |
| 300 | 301 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); |
| 301 | const opened = await openPull(app, atlas, repo, { | |
| 302 | const opened = await openPull(app, mason, repo, { | |
| 302 | 303 | title: "Shared", |
| 303 | 304 | baseBranch: "main", |
| 304 | 305 | headBranch: "feature/shared", |
| @@ -335,7 +336,7 @@ describe("a repository on its own", () => { | ||
| 335 | 336 | ); |
| 336 | 337 | expect(conflicts().length).toBe(1); |
| 337 | 338 | expect(conflicts()[0]!.summary).toBe("main moved: shared.ts"); |
| 338 | expect(conflicts()[0]!.actorId).toBe(atlas.id); | |
| 339 | expect(conflicts()[0]!.actorId).toBe(mason.id); | |
| 339 | 340 | // Still not landed, still one run waiting: a second look queues nothing. |
| 340 | 341 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); |
| 341 | 342 | expect(conflicts().length).toBe(1); |
| @@ -505,6 +506,7 @@ describe("a repository on its own", () => { | ||
| 505 | 506 | model: "", |
| 506 | 507 | brief: "", |
| 507 | 508 | budgetUsd: 0, |
| 509 | roles: ["builder"] as const, | |
| 508 | 510 | createdBy: null, |
| 509 | 511 | createdAt: "", |
| 510 | 512 | }; |
| @@ -513,6 +515,7 @@ describe("a repository on its own", () => { | ||
| 513 | 515 | model: "", |
| 514 | 516 | brief: "", |
| 515 | 517 | budgetUsd: 0, |
| 518 | roles: ["builder"] as const, | |
| 516 | 519 | createdBy: null, |
| 517 | 520 | createdAt: "", |
| 518 | 521 | }; |
| @@ -670,16 +673,16 @@ describe("the slot count is the rule the loop enforces", () => { | ||
| 670 | 673 | }); |
| 671 | 674 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); |
| 672 | 675 | for (const name of ["brick", "quill", "chart"]) { |
| 673 | const agent = await createAgent(app, alice, { username: name }); | |
| 676 | const agent = await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] }); | |
| 674 | 677 | if (!agent.ok) throw new Error(String(agent.error)); |
| 675 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 678 | expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true); | |
| 676 | 679 | } |
| 677 | 680 | const byName = getUserByName; |
| 678 | 681 | builder = byName(app.db, "brick")!; |
| 679 | 682 | reviewer = byName(app.db, "quill")!; |
| 680 | 683 | planner = byName(app.db, "chart")!; |
| 681 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 682 | setCrewRole(app.db, repo.id, planner.id, "planner"); | |
| 684 | seatAs(app.db, repo.id, reviewer.id, "reviewer"); | |
| 685 | seatAs(app.db, repo.id, planner.id, "planner"); | |
| 683 | 686 | for (const title of ["Warp", "Weft", "Shuttle", "Heddle"]) { |
| 684 | 687 | const opened = openIssue(app, alice, repo, { title }); |
| 685 | 688 | if (!opened.ok) throw new Error(String(opened.error)); |
| @@ -815,13 +818,13 @@ describe("the planner is held while nothing lands or the plate is full", () => { | ||
| 815 | 818 | }); |
| 816 | 819 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); |
| 817 | 820 | for (const name of ["brick", "chart"]) { |
| 818 | const agent = await createAgent(app, alice, { username: name }); | |
| 821 | const agent = await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] }); | |
| 819 | 822 | if (!agent.ok) throw new Error(String(agent.error)); |
| 820 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 823 | expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true); | |
| 821 | 824 | } |
| 822 | 825 | const byName = getUserByName; |
| 823 | 826 | planner = byName(app.db, "chart")!; |
| 824 | setCrewRole(app.db, repo.id, planner.id, "planner"); | |
| 827 | seatAs(app.db, repo.id, planner.id, "planner"); | |
| 825 | 828 | const on = await updateRepoSettings(app, alice, repo, { |
| 826 | 829 | autonomy: "autonomous", |
| 827 | 830 | autonomyDirection: "Weave the loom", |
test/box-pass.test.ts +5 −4
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * whose boxes are all ticked, and an issue held for boxes gets the same pass |
| 5 | 5 | * once. A builder is still never sent to redo shipped work. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -14,7 +15,7 @@ import { passOf } from "../src/actions/box_pass.ts"; | ||
| 14 | 15 | import { openIssue } from "../src/actions/issues.ts"; |
| 15 | 16 | import { mergePull, openPull, submitReview } from "../src/actions/pulls.ts"; |
| 16 | 17 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { PASS_LEAD, tickBoxes, unticked } from "../src/core/done_on_main.ts"; |
| 19 | 20 | import { addComment, getIssueById, listComments, updateIssueText, type Issue } from "../src/core/issues.ts"; |
| 20 | 21 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| @@ -114,8 +115,8 @@ describe("the reviewer's pass over the boxes", () => { | ||
| 114 | 115 | if (!created.ok) throw new Error(String(created.error)); |
| 115 | 116 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); |
| 116 | 117 | } |
| 117 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 118 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "vesper")!.id, "reviewer"); | |
| 118 | seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 119 | seatAs(app.db, made.value.id, getUserByName(app.db, "vesper")!.id, "reviewer"); | |
| 119 | 120 | vesper = getUserByName(app.db, "vesper")!; |
| 120 | 121 | mason = getUserByName(app.db, "mason")!; |
| 121 | 122 | const set = await updateRepoSettings(app, alice, made.value, { |
| @@ -382,7 +383,7 @@ describe("the review-states door", () => { | ||
| 382 | 383 | const created = await createAgent(app, alice, { username: "vesper" }); |
| 383 | 384 | if (!created.ok) throw new Error(String(created.error)); |
| 384 | 385 | addToCrew(app, alice, repo, "vesper"); |
| 385 | setCrewRole(app.db, repo.id, getUserByName(app.db, "vesper")!.id, "reviewer"); | |
| 386 | seatAs(app.db, repo.id, getUserByName(app.db, "vesper")!.id, "reviewer"); | |
| 386 | 387 | token = tokenFor(app, "vesper", "rw"); |
| 387 | 388 | const seeded = await commitToBranch(app, alice, repo, { |
| 388 | 389 | message: "Start", |
test/client-home.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * developer's words. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -17,7 +18,7 @@ import { getAppByRepo } from "../src/core/apps.ts"; | ||
| 17 | 18 | import { clientApps } from "../src/web/studio/client_home.ts"; |
| 18 | 19 | import type { Ctx } from "../src/http/context.ts"; |
| 19 | 20 | import { clientAppRows, cutTitle, doingLine } from "../src/core/client-apps.ts"; |
| 20 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 21 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 21 | 22 | import { moveArchived } from "../src/core/repos.ts"; |
| 22 | 23 | import { createSession } from "../src/core/sessions.ts"; |
| 23 | 24 | import { linkEveryday } from "../src/core/everyday.ts"; |
| @@ -103,7 +104,7 @@ describe("a client's first screen", () => { | ||
| 103 | 104 | createdBy: alice.id, |
| 104 | 105 | }); |
| 105 | 106 | addCrew(parent.db, repo.value.id, atlas.value.id, alice.id); |
| 106 | setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 107 | seatAs(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 107 | 108 | parentServers = await serve(parent); |
| 108 | 109 | const spawned = await spawnForge( |
| 109 | 110 | parent, |
test/crew-direction-autonomy.test.ts +4 −3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * here starts a real agent: an assignment, a queued run or a merge is the |
| 7 | 7 | * observable effect, and each must not happen. |
| 8 | 8 | */ |
| 9 | import { seatAs } from "./helpers/seat.ts"; | |
| 9 | 10 | import { |
| 10 | 11 | afterAll, |
| 11 | 12 | beforeAll, |
| @@ -35,7 +36,7 @@ import { | ||
| 35 | 36 | updateRepoSettings, |
| 36 | 37 | } from "../src/actions/repos.ts"; |
| 37 | 38 | import { tell } from "../src/actions/tell.ts"; |
| 38 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 39 | import {} from "../src/core/agents.ts"; | |
| 39 | 40 | import { readyNow, repoGraph } from "../src/core/dependencies.ts"; |
| 40 | 41 | import { |
| 41 | 42 | getIssue, |
| @@ -121,8 +122,8 @@ async function autonomous(): Promise<Repo> { | ||
| 121 | 122 | ); |
| 122 | 123 | for (const name of ["mason", "atlas", "judge"]) |
| 123 | 124 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 124 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 125 | setCrewRole(app.db, repo.id, judge.id, "reviewer"); | |
| 125 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 126 | seatAs(app.db, repo.id, judge.id, "reviewer"); | |
| 126 | 127 | ok( |
| 127 | 128 | await updateRepoSettings(app, alice, repo, { |
| 128 | 129 | autonomy: "autonomous", |
test/crew-direction-cap.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * bounds planning is the breaker, the replan floor, the hold, the dollar |
| 5 | 5 | * ceiling — and the direction's own, generous day's share. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 14 | 15 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 17 | 18 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 18 | 19 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| @@ -92,7 +93,7 @@ describe("the direction is paced, not capped", () => { | ||
| 92 | 93 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 93 | 94 | } |
| 94 | 95 | atlas = getUserByName(app.db, "atlas")!; |
| 95 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 96 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 96 | 97 | const set = await updateRepoSettings(app, alice, repo, { |
| 97 | 98 | autonomy: "autonomous", |
| 98 | 99 | autonomyDirection: "Keep the engine small.", |
test/crew-direction.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * steers a run. A reader's words stay on the thread and do nothing more, |
| 6 | 6 | * and a run's brief quotes them as a reader's, never as an instruction. |
| 7 | 7 | */ |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; |
| 10 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -25,7 +26,7 @@ import { | ||
| 25 | 26 | updateRepoSettings, |
| 26 | 27 | } from "../src/actions/repos.ts"; |
| 27 | 28 | import { steerRepoRun } from "../src/actions/runs.ts"; |
| 28 | import { setCrewRole, type Agent } from "../src/core/agents.ts"; | |
| 29 | import { type Agent } from "../src/core/agents.ts"; | |
| 29 | 30 | import { listEvents } from "../src/core/events.ts"; |
| 30 | 31 | import { getIssue, listComments, type Issue } from "../src/core/issues.ts"; |
| 31 | 32 | import { getPull } from "../src/core/pulls.ts"; |
| @@ -136,7 +137,7 @@ fi | ||
| 136 | 137 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); |
| 137 | 138 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); |
| 138 | 139 | expect(addToCrew(app, alice, repo, "judge").ok).toBe(true); |
| 139 | setCrewRole(app.db, repo.id, judge.user.id, "reviewer"); | |
| 140 | seatAs(app.db, repo.id, judge.user.id, "reviewer"); | |
| 140 | 141 | }, 30_000); |
| 141 | 142 | |
| 142 | 143 | afterAll(async () => { |
test/crew-guard.test.ts +3 −2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * since its last pass (#1097). Each skip and cap is counted for the |
| 10 | 10 | * token-return view. |
| 11 | 11 | */ |
| 12 | import { seatAs } from "./helpers/seat.ts"; | |
| 12 | 13 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 13 | 14 | import { mkdtemp, rm } from "node:fs/promises"; |
| 14 | 15 | import { tmpdir } from "node:os"; |
| @@ -26,7 +27,7 @@ import { | ||
| 26 | 27 | updateRepoSettings, |
| 27 | 28 | } from "../src/actions/repos.ts"; |
| 28 | 29 | import { readUsage } from "../src/actions/usage.ts"; |
| 29 | import { getAgent, setCrewRole, type Agent } from "../src/core/agents.ts"; | |
| 30 | import { getAgent, type Agent } from "../src/core/agents.ts"; | |
| 30 | 31 | import { listDecisions } from "../src/core/decisions.ts"; |
| 31 | 32 | import { |
| 32 | 33 | getIssueById, |
| @@ -417,7 +418,7 @@ describe("the planner is not given the direction again with nothing new (#1097)" | ||
| 417 | 418 | ); |
| 418 | 419 | for (const name of ["bot", "atlas"]) |
| 419 | 420 | expect(addToCrew(app, alice, round, name).ok).toBe(true); |
| 420 | setCrewRole(app.db, round.id, atlas.user.id, "planner"); | |
| 421 | seatAs(app.db, round.id, atlas.user.id, "planner"); | |
| 421 | 422 | ok( |
| 422 | 423 | await updateRepoSettings(app, alice, round, { |
| 423 | 424 | autonomy: "autonomous", |
test/crew-held.test.ts +4 −3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * terminal's Flow dialog show the same counts, private repositories to the |
| 7 | 7 | * owner only. Each test makes its own repository and crew. |
| 8 | 8 | */ |
| 9 | import { seatAs } from "./helpers/seat.ts"; | |
| 9 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 12 | import { tmpdir } from "node:os"; |
| @@ -15,7 +16,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 15 | 16 | import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 16 | 17 | import { openPull } from "../src/actions/pulls.ts"; |
| 17 | 18 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 18 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 19 | import { getAgentByName } from "../src/core/agents.ts"; | |
| 19 | 20 | import { getIssueById, listIssues, type Issue } from "../src/core/issues.ts"; |
| 20 | 21 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 21 | 22 | import { DONE_NOTE } from "../src/core/done_on_main.ts"; |
| @@ -49,7 +50,7 @@ async function crewed( | ||
| 49 | 50 | } |
| 50 | 51 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); |
| 51 | 52 | } |
| 52 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 53 | seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 53 | 54 | const set = await updateRepoSettings(app, alice, made.value, { |
| 54 | 55 | autonomy: "autonomous", |
| 55 | 56 | autonomyDirection: "Keep the engine small.", |
| @@ -272,7 +273,7 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 272 | 273 | if (!made.ok) throw new Error(String(made.error)); |
| 273 | 274 | expect(addToCrew(app, alice, fresh(), "sentinel").ok).toBe(true); |
| 274 | 275 | const sentinel = getUserByName(app.db, "sentinel")!; |
| 275 | setCrewRole(app.db, repo.id, sentinel.id, "reviewer"); | |
| 276 | seatAs(app.db, repo.id, sentinel.id, "reviewer"); | |
| 276 | 277 | |
| 277 | 278 | // A lane past its budget: the sentinel files it at once, unassigned. |
| 278 | 279 | const now = Date.now(); |
test/crew-hot-files.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * the feed, and the feed through the MCP. The hold lifts when the pile |
| 6 | 6 | * drains under five. |
| 7 | 7 | */ |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -14,7 +15,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 14 | 15 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 15 | 16 | import { openPull } from "../src/actions/pulls.ts"; |
| 16 | 17 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 19 | 20 | import type { Pull } from "../src/core/pulls.ts"; |
| 20 | 21 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| @@ -122,7 +123,7 @@ describe("the picker holds a build on a hot file", () => { | ||
| 122 | 123 | if (!created.ok) throw new Error(String(created.error)); |
| 123 | 124 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); |
| 124 | 125 | } |
| 125 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 126 | seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 126 | 127 | const set = await updateRepoSettings(app, alice, made.value, { |
| 127 | 128 | autonomy: "autonomous", |
| 128 | 129 | autonomyDirection: "Keep the engine small.", |
test/crew-loop.test.ts +10 −7
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * leaves no comment; a finished planning run lets go of the direction; three |
| 5 | 5 | * failed runs in an hour park the issue until a person speaks. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -18,7 +19,7 @@ import { HANDOVER_NOTE, handedOverPulls, handoverOf } from "../src/crew/guard.ts | ||
| 18 | 19 | import { addReviewComment } from "../src/core/reviews.ts"; |
| 19 | 20 | import { sendToReview, waitsForPerson } from "../src/crew/review.ts"; |
| 20 | 21 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 21 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 22 | import {} from "../src/core/agents.ts"; | |
| 22 | 23 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; |
| 23 | 24 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 24 | 25 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| @@ -74,7 +75,7 @@ describe("an agent's own comment never starts its next run", () => { | ||
| 74 | 75 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 75 | 76 | } |
| 76 | 77 | atlas = getUserByName(app.db, "atlas")!; |
| 77 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 78 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 78 | 79 | const set = await updateRepoSettings(app, alice, repo, { |
| 79 | 80 | autonomy: "autonomous", |
| 80 | 81 | autonomyDirection: "Keep the engine small.", |
| @@ -138,13 +139,15 @@ describe("an agent's own comment never starts its next run", () => { | ||
| 138 | 139 | if (!issued.ok) throw new Error(String(issued.error)); |
| 139 | 140 | const issue = issued.value; |
| 140 | 141 | const { assignIssue } = await import("../src/actions/issues.ts"); |
| 141 | assignIssue(app, alice, fresh(), issue, atlas); | |
| 142 | // A builder: an issue assigned to the planner is explained, not run (#8907). | |
| 143 | const builder = getUserByName(app.db, "mason")!; | |
| 144 | assignIssue(app, alice, fresh(), issue, builder); | |
| 142 | 145 | await new Promise((r) => setTimeout(r, 50)); |
| 143 | 146 | // The person's assignment started one run; the agent's words add none. |
| 144 | 147 | expect(agentRuns(issue).length).toBe(1); |
| 145 | 148 | const before = agentRuns(issue).length; |
| 146 | 149 | for (let i = 0; i < 5; i++) { |
| 147 | addComment(app, atlas, fresh(), issue, `Declined: not feedback ${i}`); | |
| 150 | addComment(app, builder, fresh(), issue, `Declined: not feedback ${i}`); | |
| 148 | 151 | await new Promise((r) => setTimeout(r, 20)); |
| 149 | 152 | } |
| 150 | 153 | expect(agentRuns(issue).length).toBe(before); |
| @@ -155,13 +158,13 @@ describe("an agent's own comment never starts its next run", () => { | ||
| 155 | 158 | const issued = openIssue(app, alice, fresh(), { title: "Stuck", body: "" }); |
| 156 | 159 | if (!issued.ok) throw new Error(String(issued.error)); |
| 157 | 160 | const issue = issued.value; |
| 158 | const agent = getAgent(app.db, atlas.id)!; | |
| 161 | const agent = getAgent(app.db, getUserByName(app.db, "mason")!.id)!; | |
| 159 | 162 | for (let i = 0; i < 3; i++) { |
| 160 | 163 | const r = createRun(app.db, { |
| 161 | 164 | repoId: repo.id, |
| 162 | 165 | kind: "agent", |
| 163 | 166 | issueId: issue.id, |
| 164 | actorId: atlas.id, | |
| 167 | actorId: getUserByName(app.db, "mason")!.id, | |
| 165 | 168 | reason: "feedback", |
| 166 | 169 | }); |
| 167 | 170 | claimRun(app.db, r.id); |
| @@ -245,7 +248,7 @@ describe("a crew reviewer's changes-requested wakes the builder", () => { | ||
| 245 | 248 | } |
| 246 | 249 | mason = getUserByName(app.db, "mason")!; |
| 247 | 250 | sentinel = getUserByName(app.db, "sentinel")!; |
| 248 | setCrewRole(app.db, repo.id, sentinel.id, "reviewer"); | |
| 251 | seatAs(app.db, repo.id, sentinel.id, "reviewer"); | |
| 249 | 252 | crew = await startCrew(app, { |
| 250 | 253 | forgeUrl: "http://127.0.0.1:1", |
| 251 | 254 | localUrl: "http://127.0.0.1:1", |
test/crew-parks-planner.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * migration 43 rewords old outage parks so they no longer count. Split from |
| 5 | 5 | * `test/crew-parks.test.ts`, whose `engine` it shares. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { Database } from "bun:sqlite"; |
| 9 | 10 | import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; |
| @@ -11,7 +12,7 @@ import { join } from "node:path"; | ||
| 11 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 12 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 13 | 14 | import { commitToBranch } from "../src/actions/repos.ts"; |
| 14 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 15 | import {} from "../src/core/agents.ts"; | |
| 15 | 16 | import { listRuns } from "../src/core/runs.ts"; |
| 16 | 17 | import { migrations } from "../src/core/schema.ts"; |
| 17 | 18 | import { submitReview } from "../src/core/reviews.ts"; |
| @@ -132,7 +133,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Nothing to | ||
| 132 | 133 | if (!agent.ok) throw new Error(String(agent.error)); |
| 133 | 134 | expect(addToCrew(e.app, e.alice, e.fresh(), "sentinel").ok).toBe(true); |
| 134 | 135 | const sentinel = getUserByName(e.app.db, "sentinel")!; |
| 135 | setCrewRole(e.app.db, e.repo.id, sentinel.id, "reviewer"); | |
| 136 | seatAs(e.app.db, e.repo.id, sentinel.id, "reviewer"); | |
| 136 | 137 | const branch = await commitToBranch(e.app, e.alice, e.fresh(), { |
| 137 | 138 | branch: "feature/spent", |
| 138 | 139 | base: "main", |
test/crew-reserve.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * brief names the target; the tick line says `reserve=startable/target`; |
| 5 | 5 | * and only a pass that did not end well waits out the 20-minute floor. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { addComment, openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 14 | 15 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 15 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { getAgentByName } from "../src/core/agents.ts"; | |
| 16 | 17 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 17 | 18 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 18 | 19 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| @@ -105,7 +106,7 @@ describe("a planner that keeps a standing reserve", () => { | ||
| 105 | 106 | } |
| 106 | 107 | mason = getUserByName(app.db, "mason")!; |
| 107 | 108 | atlas = getUserByName(app.db, "atlas")!; |
| 108 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 109 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 109 | 110 | // Seven slots, every one of them busy: seven issues with a run in flight. |
| 110 | 111 | for (let n = 0; n < 7; n++) { |
| 111 | 112 | const opened = openIssue(app, alice, repo, { title: `Busy ${n}` }); |
test/crew-review-budget.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * `crew.test.ts` so each part runs apart (#1798). |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import type { App } from "../src/app.ts"; |
| 9 | 10 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -12,7 +13,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 12 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 13 | 14 | import { commitToBranch } from "../src/actions/repos.ts"; |
| 14 | 15 | import { queueAgentRun } from "../src/crew/dispatch.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import { getIssue } from "../src/core/issues.ts"; |
| 17 | 18 | import { listRuns, type Run } from "../src/core/runs.ts"; |
| 18 | 19 | import { |
| @@ -45,7 +46,7 @@ describe("the crew, with a stand-in Claude Code: review budget", () => { | ||
| 45 | 46 | const made = await createAgent(app, alice, { username: "slowrev" }); |
| 46 | 47 | if (!made.ok) throw new Error(String(made.error)); |
| 47 | 48 | expect(addToCrew(app, alice, repo, "slowrev").ok).toBe(true); |
| 48 | setCrewRole(app.db, repo.id, made.value.user.id, "reviewer"); | |
| 49 | seatAs(app.db, repo.id, made.value.user.id, "reviewer"); | |
| 49 | 50 | const branch = await commitToBranch(app, alice, repo, { |
| 50 | 51 | branch: "slow-fence", |
| 51 | 52 | base: "main", |
test/crew-review.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * `crew.test.ts` so each part runs apart (#1798). |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { readFileSync } from "node:fs"; |
| 9 | 10 | import type { App } from "../src/app.ts"; |
| @@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 13 | 14 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 15 | import { commitToBranch } from "../src/actions/repos.ts"; |
| 15 | 16 | import { queueAgentRun } from "../src/crew/dispatch.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import {} from "../src/core/agents.ts"; | |
| 17 | 18 | import { getIssue, listComments } from "../src/core/issues.ts"; |
| 18 | 19 | import { listReviews } from "../src/core/reviews.ts"; |
| 19 | 20 | import type { Run } from "../src/core/runs.ts"; |
| @@ -47,7 +48,7 @@ describe("the crew, with a stand-in Claude Code: review", () => { | ||
| 47 | 48 | const made = await createAgent(app, alice, { username: "rev" }); |
| 48 | 49 | if (!made.ok) throw new Error(String(made.error)); |
| 49 | 50 | expect(addToCrew(app, alice, repo, "rev").ok).toBe(true); |
| 50 | setCrewRole(app.db, repo.id, made.value.user.id, "reviewer"); | |
| 51 | seatAs(app.db, repo.id, made.value.user.id, "reviewer"); | |
| 51 | 52 | const branch = await commitToBranch(app, alice, repo, { |
| 52 | 53 | branch: "fence", |
| 53 | 54 | base: "main", |
test/crew-roles.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * An agent's roles are its own config (#8907): a repository narrows them and | |
| 3 | * never widens them, a dispatch never hands over a job outside them, and | |
| 4 | * /migrate offers builders only. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, askedRoles, createAgent, updateAgent } from "../src/actions/crew.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { crewRole, getAgentByName, type Agent } from "../src/core/agents.ts"; | |
| 14 | import { createRepo, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { outsideSeat } from "../src/crew/dispatch.ts"; | |
| 17 | import { migrationBuilders, prefill } from "../src/web/pages/migrate-prefill.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let app: App; | |
| 23 | let admin: User; | |
| 24 | let repo: Repo; | |
| 25 | ||
| 26 | const agent = (name: string): Agent => getAgentByName(app.db, name)!; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | app = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-roles-")), port: 0 }); | |
| 30 | const made = await createUser(app.db, { origin: "seed", username: "alice", email: "[email protected]", password: "a-long-crew-passphrase", adminIfFirst: true, trusted: true }); | |
| 31 | if (!made.ok) throw new Error("user"); | |
| 32 | admin = made.value; | |
| 33 | const r = await createRepo(app.db, app.reposRoot, { ownerId: admin.id, name: "yard", visibility: "public" }); | |
| 34 | if (!r.ok) throw new Error(String(r.error)); | |
| 35 | repo = r.value; | |
| 36 | const make = async (username: string, roles?: string[]) => { | |
| 37 | const a = await createAgent(app, admin, { username, ...(roles ? { roles } : {}) }); | |
| 38 | if (!a.ok) throw new Error(`${username}: ${a.error}`); | |
| 39 | }; | |
| 40 | await make("atlas", ["planner"]); | |
| 41 | await make("both", ["planner", "builder"]); | |
| 42 | await make("mason", ["builder"]); | |
| 43 | await make("rev", ["reviewer"]); | |
| 44 | await make("plain"); | |
| 45 | }); | |
| 46 | afterAll(() => app.db.close()); | |
| 47 | ||
| 48 | describe("roles are the agent's config", () => { | |
| 49 | test("an agent says its roles; none given reads as builder, bad ones are refused", () => { | |
| 50 | expect(agent("atlas").roles).toEqual(["planner"]); | |
| 51 | expect(agent("both").roles).toEqual(["planner", "builder"]); | |
| 52 | expect(agent("plain").roles).toEqual(["builder"]); | |
| 53 | const bad = updateAgent(app, admin, "mason", { roles: ["boss"] }); | |
| 54 | expect(bad.ok ? "" : bad.error).toBe("invalid_roles"); | |
| 55 | const empty = updateAgent(app, admin, "mason", { roles: [] }); | |
| 56 | expect(empty.ok ? "" : empty.error).toBe("invalid_roles"); | |
| 57 | expect(agent("mason").roles).toEqual(["builder"]); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a repository can narrow a role, never widen it", () => { | |
| 61 | const widened = addToCrew(app, admin, repo, "atlas", "builder"); | |
| 62 | expect(widened.ok ? "" : widened.error).toBe("role_not_declared"); | |
| 63 | expect(crewRole(app.db, repo.id, agent("atlas").user.id)).toBeUndefined(); | |
| 64 | expect(addToCrew(app, admin, repo, "both", "builder").ok).toBe(true); | |
| 65 | expect(crewRole(app.db, repo.id, agent("both").user.id)).toBe("builder"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("no role named: the first the agent declares, never builder by default", () => { | |
| 69 | expect(addToCrew(app, admin, repo, "rev").ok).toBe(true); | |
| 70 | expect(crewRole(app.db, repo.id, agent("rev").user.id)).toBe("reviewer"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("taking a role an agent still holds on a repository is refused, not moved silently", () => { | |
| 74 | const held = updateAgent(app, admin, "both", { roles: ["planner"] }); | |
| 75 | expect(held.ok ? "" : held.error).toBe("role_held"); | |
| 76 | expect(agent("both").roles).toEqual(["planner", "builder"]); | |
| 77 | expect(crewRole(app.db, repo.id, agent("both").user.id)).toBe("builder"); | |
| 78 | expect(updateAgent(app, admin, "both", { roles: ["builder", "reviewer"] }).ok).toBe(true); | |
| 79 | updateAgent(app, admin, "both", { roles: ["planner", "builder"] }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("roles that are not a list of strings are invalid_roles, not ignored", () => { | |
| 83 | for (const bad of ["planner", 3, null, { a: 1 }, [1], [["planner"]]]) { | |
| 84 | const r = updateAgent(app, admin, "mason", { ...askedRoles(bad) }); | |
| 85 | expect(r.ok ? "" : r.error).toBe("invalid_roles"); | |
| 86 | } | |
| 87 | expect(askedRoles(undefined)).toEqual({}); | |
| 88 | expect(agent("mason").roles).toEqual(["builder"]); | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | describe("a dispatch never hands over a job outside the seat", () => { | |
| 93 | let issue: Awaited<ReturnType<typeof openIssue>> & { ok: true }; | |
| 94 | beforeAll(async () => { | |
| 95 | const i = await openIssue(app, admin, repo, { title: "work" }); | |
| 96 | if (!i.ok) throw new Error(i.error); | |
| 97 | issue = i as typeof issue; | |
| 98 | addToCrew(app, admin, repo, "mason", "builder"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("planner seat: a build is refused", () => { | |
| 102 | app.db.query("UPDATE repo_crew SET role = 'planner' WHERE user_id = ?1").run(agent("both").user.id); | |
| 103 | expect(outsideSeat(app, repo, issue.value, agent("both"), "assigned")).toBe(true); | |
| 104 | }); | |
| 105 | test("builder seat: a build is allowed, a review is not", () => { | |
| 106 | expect(outsideSeat(app, repo, issue.value, agent("mason"), "assigned")).toBe(false); | |
| 107 | expect(outsideSeat(app, repo, issue.value, agent("mason"), "review")).toBe(true); | |
| 108 | }); | |
| 109 | test("reviewer seat: a review is allowed, a build is not", () => { | |
| 110 | expect(outsideSeat(app, repo, issue.value, agent("rev"), "review")).toBe(false); | |
| 111 | expect(outsideSeat(app, repo, issue.value, agent("rev"), "assigned")).toBe(true); | |
| 112 | }); | |
| 113 | }); | |
| 114 | ||
| 115 | describe("every kind of run keeps to the seat", () => { | |
| 116 | const reasons = ["assigned", "feedback", "check_failed", "conflict", "resolve", "converge"] as const; | |
| 117 | test("a non-builder is outside every building reason; a builder is outside a review", async () => { | |
| 118 | const i = await openIssue(app, admin, repo, { title: "more work" }); | |
| 119 | if (!i.ok) throw new Error(i.error); | |
| 120 | app.db.query("UPDATE repo_crew SET role = 'planner' WHERE user_id = ?1").run(agent("both").user.id); | |
| 121 | for (const reason of reasons) { | |
| 122 | expect(outsideSeat(app, repo, i.value, agent("both"), reason)).toBe(true); | |
| 123 | expect(outsideSeat(app, repo, i.value, agent("rev"), reason)).toBe(true); | |
| 124 | expect(outsideSeat(app, repo, i.value, agent("mason"), reason)).toBe(false); | |
| 125 | } | |
| 126 | expect(outsideSeat(app, repo, i.value, agent("mason"), "review")).toBe(true); | |
| 127 | }); | |
| 128 | }); | |
| 129 | ||
| 130 | describe("/migrate offers builders only", () => { | |
| 131 | test("a planner that sorts first is never the default; crew= naming it is refused in a sentence", () => { | |
| 132 | const all = ["atlas", "both", "mason", "rev", "plain"].map(agent); | |
| 133 | const builders = migrationBuilders(all).map((a) => a.user.username); | |
| 134 | expect(builders).not.toContain("atlas"); | |
| 135 | expect(builders).not.toContain("rev"); | |
| 136 | expect(builders[0]).toBe("mason"); | |
| 137 | const linked = prefill({ crew: "atlas" }, [], builders, ["atlas", "rev"]); | |
| 138 | expect(linked.agent).toBeUndefined(); | |
| 139 | expect(linked.refused[0]).toContain("atlas is not a builder"); | |
| 140 | }); | |
| 141 | }); |
test/crew-seats.test.ts +13 −11
| @@ -4,13 +4,14 @@ | ||
| 4 | 4 | * `addToCrew` names the seat only for an agent who joins now; the terminal's |
| 5 | 5 | * About card lists the seats and `p` fills one. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| 10 | 11 | import { join } from "node:path"; |
| 11 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | import { crewRole, listCrewRoles, removeCrew, setCrewRole } from "../src/core/agents.ts"; | |
| 14 | import { crewRole, listCrewRoles, removeCrew } from "../src/core/agents.ts"; | |
| 14 | 15 | import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts"; |
| 15 | 16 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 16 | 17 | import { seatsOf } from "../src/crew/seats.ts"; |
| @@ -58,7 +59,7 @@ describe("the team's seats", () => { | ||
| 58 | 59 | const made = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "yard", visibility: "public" }); |
| 59 | 60 | if (!made.ok) throw new Error(String(made.error)); |
| 60 | 61 | repo = made.value; |
| 61 | for (const name of ["aplan", "bbuild"]) if (!(await createAgent(app, alice, { username: name })).ok) throw new Error("agent"); | |
| 62 | for (const name of ["aplan", "bbuild"]) if (!(await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] })).ok) throw new Error("agent"); | |
| 62 | 63 | cookies = { alice: await signIn("alice"), bob: await signIn("bob") }; |
| 63 | 64 | }, 30_000); |
| 64 | 65 | |
| @@ -83,30 +84,31 @@ describe("the team's seats", () => { | ||
| 83 | 84 | expect(seatsOf(app.db, fresh(), true).auto).toBe(false); |
| 84 | 85 | }); |
| 85 | 86 | |
| 86 | test("the card puts the first free agent on the seat it names, for an admin only", async () => { | |
| 87 | test("each list picks from the agents that declare its role, for an admin only", async () => { | |
| 87 | 88 | mode("autonomous"); |
| 88 | 89 | const html = await page("alice"); |
| 89 | for (const role of ["planner", "builder", "reviewer"]) | |
| 90 | for (const role of ["planner", "builder", "reviewer"]) { | |
| 90 | 91 | expect(html).toContain(`name="role" value="${role}"`); |
| 91 | expect(html).toContain('name="agent" value="aplan"'); | |
| 92 | expect(html).toContain("Put aplan on reviewer"); | |
| 92 | expect(html).toContain(`id="f-add-${role}"`); | |
| 93 | } | |
| 94 | expect(html).toContain('<option value="aplan">'); | |
| 95 | expect(html).toContain("Planners"); | |
| 93 | 96 | expect(await page("bob")).not.toContain('name="role"'); |
| 94 | 97 | }); |
| 95 | 98 | |
| 96 | test("a filled seat names who fills it and has no button; the others still do", async () => { | |
| 99 | test("a filled seat names who fills it; the others still have a picker", async () => { | |
| 97 | 100 | expect(addToCrew(app, alice, fresh(), "aplan", "planner").ok).toBe(true); |
| 98 | 101 | expect(states()).toEqual({ planner: "filled", builder: "warn", reviewer: "warn" }); |
| 99 | 102 | expect(seatsOf(app.db, fresh()).seats[0]!.by).toEqual(["aplan"]); |
| 100 | 103 | const html = await page("alice"); |
| 101 | expect(html).not.toContain('name="role" value="planner"'); | |
| 102 | expect(html).toContain('name="role" value="builder"'); | |
| 103 | expect(html).toContain('name="agent" value="bbuild"'); | |
| 104 | expect(html).toContain('data-member="aplan"'); | |
| 105 | expect(html).toContain('id="f-add-builder"'); | |
| 104 | 106 | expect(html).not.toContain("No planner:"); |
| 105 | 107 | expect(html).toContain("No builder: nothing gets built."); |
| 106 | 108 | }); |
| 107 | 109 | |
| 108 | 110 | test("an agent already on the crew keeps its role", () => { |
| 109 | setCrewRole(app.db, repo.id, idOf("aplan"), "reviewer"); | |
| 111 | seatAs(app.db, repo.id, idOf("aplan"), "reviewer"); | |
| 110 | 112 | const again = addToCrew(app, alice, fresh(), "aplan", "builder"); |
| 111 | 113 | expect(again.ok).toBe(true); |
| 112 | 114 | expect(crewRole(app.db, repo.id, idOf("aplan"))).toBe("reviewer"); |
test/crew-settings-doors.test.ts +2 −2
| @@ -97,7 +97,7 @@ describe("settings › crew doors", () => { | ||
| 97 | 97 | }); |
| 98 | 98 | if (!made.ok) throw new Error(String(made.error)); |
| 99 | 99 | repo = made.value; |
| 100 | const bot = await createAgent(app, a.value, { username: "yardbot" }); | |
| 100 | const bot = await createAgent(app, a.value, { username: "yardbot", roles: ["planner", "builder", "reviewer"] }); | |
| 101 | 101 | if (!bot.ok) throw new Error("agent"); |
| 102 | 102 | botId = bot.value.user.id; |
| 103 | 103 | const sub = addSubscription(app.db, dataDir, { |
| @@ -106,7 +106,7 @@ describe("settings › crew doors", () => { | ||
| 106 | 106 | }); |
| 107 | 107 | if (!sub.ok) throw new Error("subscription"); |
| 108 | 108 | subId = sub.value.id; |
| 109 | const other = await createAgent(app, a.value, { username: "otherbot" }); | |
| 109 | const other = await createAgent(app, a.value, { username: "otherbot", roles: ["planner", "builder", "reviewer"] }); | |
| 110 | 110 | if (!other.ok) throw new Error("agent"); |
| 111 | 111 | otherId = other.value.user.id; |
| 112 | 112 | const c = await createUser(app.db, { |
test/crew-stalls.test.ts +3 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * The repositories are assisted, where nothing parks a stall when its run |
| 8 | 8 | * ends, so the tick is what frees it. |
| 9 | 9 | */ |
| 10 | import { seatAs } from "./helpers/seat.ts"; | |
| 10 | 11 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; |
| 11 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 12 | 13 | import { tmpdir } from "node:os"; |
| @@ -16,7 +17,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 16 | 17 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 17 | 18 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 18 | 19 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 19 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 20 | import { getAgentByName } from "../src/core/agents.ts"; | |
| 20 | 21 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; |
| 21 | 22 | import { getRepoById, listAutonomousRepos, type Repo } from "../src/core/repos.ts"; |
| 22 | 23 | import { |
| @@ -55,7 +56,7 @@ async function crewed(builders: readonly string[]): Promise<Repo> { | ||
| 55 | 56 | } |
| 56 | 57 | expect(addToCrew(app, alice, created.value, name).ok).toBe(true); |
| 57 | 58 | } |
| 58 | setCrewRole(app.db, created.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 59 | seatAs(app.db, created.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 59 | 60 | const set = await updateRepoSettings(app, alice, created.value, { |
| 60 | 61 | autonomy: "assisted", |
| 61 | 62 | autonomyDirection: "Keep the engine small.", |
test/decide.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * which case the rule's answer stands and the caller never knows the |
| 5 | 5 | * difference except by `by`. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { openIssue } from "../src/actions/issues.ts"; |
| 14 | 15 | import { createRepo } from "../src/actions/repos.ts"; |
| 15 | import { listCrewRoles, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { listCrewRoles } from "../src/core/agents.ts"; | |
| 16 | 17 | import { getIssueById } from "../src/core/issues.ts"; |
| 17 | 18 | import type { Repo } from "../src/core/repos.ts"; |
| 18 | 19 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| @@ -252,7 +253,7 @@ describe("routing an issue", () => { | ||
| 252 | 253 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 253 | 254 | } |
| 254 | 255 | scout = getUserByName(app.db, "scout")!; |
| 255 | setCrewRole(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 256 | seatAs(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 256 | 257 | }); |
| 257 | 258 | afterAll(async () => { |
| 258 | 259 | app.close(); |
test/finishing-attempts.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * and ended without a push. A run the forge cut short — a network error, a |
| 5 | 5 | * checkout that failed — is no attempt, and a new base is a new chance. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import type { Pull } from "../src/core/pulls.ts"; |
| 17 | 18 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 18 | 19 | import { submitReview } from "../src/core/reviews.ts"; |
| @@ -73,7 +74,7 @@ beforeAll(async () => { | ||
| 73 | 74 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 74 | 75 | } |
| 75 | 76 | const quill = getUserByName(app.db, "quill")!; |
| 76 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 77 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 77 | 78 | const branch = await commitToBranch(app, alice, repo, { |
| 78 | 79 | branch: "feature/1", |
| 79 | 80 | base: "main", |
test/finishing-pages.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * up to date. Its entry and every pull it drops are logged once per reason, |
| 5 | 5 | * and with no candidate the tick line says `0 candidates of N open`. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import { createRun } from "../src/core/runs.ts"; |
| 17 | 18 | import type { Pull } from "../src/core/pulls.ts"; |
| 18 | 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| @@ -86,7 +87,7 @@ beforeAll(async () => { | ||
| 86 | 87 | if (!agent.ok) throw new Error(String(agent.error)); |
| 87 | 88 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 88 | 89 | } |
| 89 | setCrewRole(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer"); | |
| 90 | seatAs(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer"); | |
| 90 | 91 | // The oldest pull edits the file main is about to change; fifty newer |
| 91 | 92 | // ones each add their own file and merge clean. |
| 92 | 93 | oldest = await pullOn(0, "export const zero = 'branch';\n"); |
test/finishing-room.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * queues no more bring-up-to-date runs — each a push, each a check — than |
| 5 | 5 | * the lane has slots for, however many builder slots are free. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import { setCapacity } from "../src/core/capacity.ts"; |
| 17 | 18 | import type { Pull } from "../src/core/pulls.ts"; |
| 18 | 19 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -69,7 +70,7 @@ beforeAll(async () => { | ||
| 69 | 70 | } |
| 70 | 71 | const mason = getUserByName(app.db, "mason")!; |
| 71 | 72 | const quill = getUserByName(app.db, "quill")!; |
| 72 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 73 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 73 | 74 | |
| 74 | 75 | // Three approved pulls that all touch the file main is about to change. |
| 75 | 76 | for (const n of [1, 2, 3]) { |
test/finishing-skips.test.ts +4 −3
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * only when the reason changes, and the tick line's |
| 5 | 5 | * `finishing="queued Q, in flight F, skipped S: <why>×k"`. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import { setCapacity } from "../src/core/capacity.ts"; |
| 17 | 18 | import { setHold } from "../src/core/holds.ts"; |
| 18 | 19 | import type { Pull } from "../src/core/pulls.ts"; |
| @@ -74,7 +75,7 @@ beforeAll(async () => { | ||
| 74 | 75 | } |
| 75 | 76 | const mason = getUserByName(app.db, "mason")!; |
| 76 | 77 | const quill = getUserByName(app.db, "quill")!; |
| 77 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 78 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 78 | 79 | for (const n of [1, 2, 3]) { |
| 79 | 80 | const branch = await commitToBranch(app, alice, repo, { |
| 80 | 81 | branch: `feature/${n}`, |
| @@ -147,7 +148,7 @@ test("a changed reason is said again, for that pull only", async () => { | ||
| 147 | 148 | expect(full.skips[0]).toContain('finishing: skip pull=3 why="waits for room in the check lane"'); |
| 148 | 149 | expect(listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === pulls[2]!.id)).toHaveLength(0); |
| 149 | 150 | |
| 150 | setCrewRole(app.db, repo.id, getUserByName(app.db, "mason")!.id, "reviewer"); | |
| 151 | seatAs(app.db, repo.id, getUserByName(app.db, "mason")!.id, "reviewer"); | |
| 151 | 152 | const none = await pass(1); |
| 152 | 153 | expect(none.skips).toHaveLength(1); |
| 153 | 154 | expect(none.skips[0]).toContain(`finishing: skip pull=3 why=${JSON.stringify(NO_BUILDER_ON_CREW)}`); |
test/finishing.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * updates that end without a push hand it to a person, on their review |
| 5 | 5 | * list, instead of closing it or trying again. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 13 | 14 | import { openIssue } from "../src/actions/issues.ts"; |
| 14 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 15 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import {} from "../src/core/agents.ts"; | |
| 17 | 18 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; |
| 18 | 19 | import type { Pull } from "../src/core/pulls.ts"; |
| 19 | 20 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -85,7 +86,7 @@ beforeAll(async () => { | ||
| 85 | 86 | } |
| 86 | 87 | mason = getUserByName(app.db, "mason")!; |
| 87 | 88 | const quill = getUserByName(app.db, "quill")!; |
| 88 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 89 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 89 | 90 | |
| 90 | 91 | const branch = await commitToBranch(app, alice, repo, { |
| 91 | 92 | branch: "feature/shared", |
test/first-login.test.ts +3 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * same three steps over the MCP. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { seatAs } from "./helpers/seat.ts"; | |
| 10 | 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 11 | 12 | import { orgAccessByOrg } from "../src/core/orgs.ts"; |
| 12 | 13 | import { getUserSetting } from "../src/core/settings.ts"; |
| @@ -16,7 +17,7 @@ import { join } from "node:path"; | ||
| 16 | 17 | import { createRepo } from "../src/actions/repos.ts"; |
| 17 | 18 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 18 | 19 | import { createApp, type App } from "../src/app.ts"; |
| 19 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 20 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 20 | 21 | import { createSession } from "../src/core/sessions.ts"; |
| 21 | 22 | import { createToken } from "../src/core/tokens.ts"; |
| 22 | 23 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| @@ -108,7 +109,7 @@ describe("the first login to a spawned forge", () => { | ||
| 108 | 109 | createdBy: alice.id, |
| 109 | 110 | }); |
| 110 | 111 | addCrew(parent.db, repo.value.id, atlas.value.id, alice.id); |
| 111 | setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 112 | seatAs(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 112 | 113 | parentServers = await serve(parent); |
| 113 | 114 | const spawned = await spawnForge( |
| 114 | 115 | parent, |
test/guests.test.ts +5 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * can; and every older refusal keeps its own reason. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { setArchivedIn } from "./helpers/archive.ts"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -14,7 +15,7 @@ import { changeCapacity } from "../src/actions/capacity.ts"; | ||
| 14 | 15 | import { proposePlan } from "../src/actions/plan.ts"; |
| 15 | 16 | import { redirectProposal } from "../src/actions/proposals.ts"; |
| 16 | 17 | import { ARCHIVED_WHY } from "../src/core/repos.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { getProposal } from "../src/core/proposals.ts"; |
| 19 | 20 | import { listComments } from "../src/core/issues.ts"; |
| 20 | 21 | import { ensureDirectionIssue } from "../src/crew/autonomy.ts"; |
| @@ -266,7 +267,7 @@ describe("a guest cannot own, copy or run", () => { | ||
| 266 | 267 | expect(assigned).toBe(0); |
| 267 | 268 | }); |
| 268 | 269 | test("redirecting a wave: a guest's own pending proposal is refused before anything is written", async () => { |
| 269 | setCrewRole(app.db, lab.id, helper.id, "planner"); | |
| 270 | seatAs(app.db, lab.id, helper.id, "planner"); | |
| 270 | 271 | const direction = ensureDirectionIssue(app, alice, lab)!; |
| 271 | 272 | const proposed = proposePlan(app, alice, lab, [{ key: "a", title: "Weed", body: "", blockedBy: [] }], { |
| 272 | 273 | title: "Spring", |
| @@ -313,6 +314,8 @@ describe("a guest cannot own, copy or run", () => { | ||
| 313 | 314 | }); |
| 314 | 315 | |
| 315 | 316 | test("the wake-up sweep starts no run on an issue a guest assigned, and a hundred of them do not hold back a member's", async () => { |
| 317 | // Back to building: a dispatch gives a planner no build (#8907). | |
| 318 | seatAs(app.db, lab.id, helper.id, "builder"); | |
| 316 | 319 | // As an assignment made before the guard (or by a member since demoted): |
| 317 | 320 | // the agent set, the event kept. |
| 318 | 321 | const assignedBy = (who: User, title: string) => { |
test/handover-give-back.test.ts +4 −3
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * and the card leaves; a foreign Origin is refused; a reader gets a 404. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { chmodSync, writeFileSync } from "node:fs"; |
| 9 | 10 | import { addComment } from "../src/actions/issues.ts"; |
| @@ -12,7 +13,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 12 | 13 | import { tmpdir } from "node:os"; |
| 13 | 14 | import { join } from "node:path"; |
| 14 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 15 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 16 | 17 | import { getIssueById, listComments, setAssignee } from "../src/core/issues.ts"; |
| 17 | 18 | import { handoverOf } from "../src/crew/guard.ts"; |
| 18 | 19 | import { createRepo, type Repo } from "../src/core/repos.ts"; |
| @@ -146,7 +147,7 @@ describe("give a handed-over pull back to the crew", () => { | ||
| 146 | 147 | if (!made.ok) throw new Error(`seed repo: ${made.error}`); |
| 147 | 148 | repo = made.value; |
| 148 | 149 | for (const agent of [builder, reviewer]) addCrew(app.db, repo.id, agent.id, owner.id); |
| 149 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 150 | seatAs(app.db, repo.id, reviewer.id, "reviewer"); | |
| 150 | 151 | cookie = await signIn("alice", "a-good-passphrase"); |
| 151 | 152 | readerCookie = await signIn("reader", "a-good-passphrase"); |
| 152 | 153 | }, 30_000); |
| @@ -226,7 +227,7 @@ describe("give a handed-over pull back to the crew", () => { | ||
| 226 | 227 | if (!planner.ok) throw new Error(planner.error); |
| 227 | 228 | createAgentRow(app.db, { userId: planner.value.id, brief: "plans." }); |
| 228 | 229 | addCrew(app.db, repo.id, planner.value.id, owner.id); |
| 229 | setCrewRole(app.db, repo.id, planner.value.id, "planner"); | |
| 230 | seatAs(app.db, repo.id, planner.value.id, "planner"); | |
| 230 | 231 | const fake = join(dataDir, "fake-claude.sh"); |
| 231 | 232 | writeFileSync( |
| 232 | 233 | fake, |
test/helpers/parks-engine.ts +3 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import { seatAs } from "./seat.ts"; | |
| 1 | 2 | import { expect } from "bun:test"; |
| 2 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| @@ -6,7 +7,7 @@ import { createApp } from "../../src/app.ts"; | ||
| 6 | 7 | import { addToCrew, createAgent } from "../../src/actions/crew.ts"; |
| 7 | 8 | import { openIssue } from "../../src/actions/issues.ts"; |
| 8 | 9 | import { createRepo, updateRepoSettings } from "../../src/actions/repos.ts"; |
| 9 | import { setCrewRole } from "../../src/core/agents.ts"; | |
| 10 | import {} from "../../src/core/agents.ts"; | |
| 10 | 11 | import type { Issue } from "../../src/core/issues.ts"; |
| 11 | 12 | import { getRepoById } from "../../src/core/repos.ts"; |
| 12 | 13 | import { claimRun, createRun, finishRun } from "../../src/core/runs.ts"; |
| @@ -48,7 +49,7 @@ export async function engine( | ||
| 48 | 49 | } |
| 49 | 50 | const atlas = getUserByName(app.db, "atlas")!; |
| 50 | 51 | const mason = getUserByName(app.db, "mason")!; |
| 51 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 52 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 52 | 53 | const set = await updateRepoSettings(app, alice, repo, { |
| 53 | 54 | autonomy: "autonomous", |
| 54 | 55 | autonomyDirection: "Keep the engine small.", |
test/helpers/seat.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** | |
| 2 | * Seat an agent for a test: widens what it declares to include the seat, then | |
| 3 | * sets it on the repository. The product only ever narrows (#8907); a test | |
| 4 | * that wants a planner out of a plain agent says so here. | |
| 5 | */ | |
| 6 | import type { Database } from "bun:sqlite"; | |
| 7 | import { getAgent, parseRoles, setCrewRole, type CrewRole } from "../../src/core/agents.ts"; | |
| 8 | ||
| 9 | export function seatAs(db: Database, repoId: string, userId: string, role: CrewRole): boolean { | |
| 10 | const agent = getAgent(db, userId); | |
| 11 | if (agent && !agent.roles.includes(role)) | |
| 12 | db.query("UPDATE agent SET roles = ?2 WHERE user_id = ?1").run(userId, parseRoles([...agent.roles, role].join(",")).join(",")); | |
| 13 | return setCrewRole(db, repoId, userId, role); | |
| 14 | } |
test/holds.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * or lands it. The refusals come first. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -14,7 +15,7 @@ import { openIssue } from "../src/actions/issues.ts"; | ||
| 14 | 15 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 15 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | 17 | import { createApp, type App } from "../src/app.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 19 | 20 | import { |
| 20 | 21 | cleanWhy, |
| @@ -453,7 +454,7 @@ describe("a held pull and the rules", () => { | ||
| 453 | 454 | }); |
| 454 | 455 | |
| 455 | 456 | test("a held green pull with an approving review does not land", async () => { |
| 456 | setCrewRole(f.app.db, f.repo.id, quill.id, "reviewer"); | |
| 457 | seatAs(f.app.db, f.repo.id, quill.id, "reviewer"); | |
| 457 | 458 | const pull = await greenPull("approved", mason); |
| 458 | 459 | approve(pull, f.alice); |
| 459 | 460 | hold(pull); |
test/issue-teams-doors.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * on the crew can ask never stops the pull being reviewed. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -14,7 +15,7 @@ import { openIssue } from "../src/actions/issues.ts"; | ||
| 14 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 15 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | 17 | import { createApp, type App } from "../src/app.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { listEvents } from "../src/core/events.ts"; |
| 19 | 20 | import { teamsOfIssue, teamVerdicts } from "../src/core/issue_teams.ts"; |
| 20 | 21 | import type { Issue } from "../src/core/issues.ts"; |
| @@ -142,7 +143,7 @@ describe("naming teams, at every door", () => { | ||
| 142 | 143 | const agent = await createAgent(app, alice, { username: "quill" }); |
| 143 | 144 | if (!agent.ok) throw new Error(String(agent.error)); |
| 144 | 145 | addToCrew(app, alice, repo, "quill"); |
| 145 | setCrewRole(app.db, repo.id, id("quill"), "reviewer"); | |
| 146 | seatAs(app.db, repo.id, id("quill"), "reviewer"); | |
| 146 | 147 | }, 30_000); |
| 147 | 148 | |
| 148 | 149 | afterAll(async () => { |
test/issue-teams.test.ts +4 −3
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * and the naming is a writer's, validated at the boundary. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -14,7 +15,7 @@ import { openIssue } from "../src/actions/issues.ts"; | ||
| 14 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 15 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | 17 | import { createApp, type App } from "../src/app.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { teamsOfIssue, teamVerdicts } from "../src/core/issue_teams.ts"; |
| 19 | 20 | import type { Issue } from "../src/core/issues.ts"; |
| 20 | 21 | import type { Pull } from "../src/core/pulls.ts"; |
| @@ -114,7 +115,7 @@ describe("cross-team work", () => { | ||
| 114 | 115 | const agent = await createAgent(app, alice, { username: name }); |
| 115 | 116 | if (!agent.ok) throw new Error(String(agent.error)); |
| 116 | 117 | addToCrew(app, alice, repo, name); |
| 117 | setCrewRole(app.db, repo.id, id(name), "reviewer"); | |
| 118 | seatAs(app.db, repo.id, id(name), "reviewer"); | |
| 118 | 119 | } |
| 119 | 120 | }); |
| 120 | 121 | await step("teams", () => { |
| @@ -191,7 +192,7 @@ describe("cross-team work", () => { | ||
| 191 | 192 | const agent = await createAgent(app, alice, { username: "ember" }); |
| 192 | 193 | if (!agent.ok) throw new Error(String(agent.error)); |
| 193 | 194 | addToCrew(app, alice, repo, "ember"); |
| 194 | setCrewRole(app.db, repo.id, id("ember"), "reviewer"); | |
| 195 | seatAs(app.db, repo.id, id("ember"), "reviewer"); | |
| 195 | 196 | saveTeam(app.db, "security", { charter: "", dial: 0, members: [id("ember")] }); |
| 196 | 197 | saveTeam(app.db, "platform", { charter: "", dial: 0, members: [id("ember")] }); |
| 197 | 198 | const pull = await pullClosing(issueNaming(["security", "platform"])); |
test/landed-held.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * holds it under `landed`, and a person's comment or edit since the merge |
| 5 | 5 | * makes it pickable again; an agent's word does not. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 13 | 14 | import { addComment, editIssue, openIssue } from "../src/actions/issues.ts"; |
| 14 | 15 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 15 | 16 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import {} from "../src/core/agents.ts"; | |
| 17 | 18 | import { landedNote } from "../src/core/done_on_main.ts"; |
| 18 | 19 | import type { ForgeEvent } from "../src/core/events.ts"; |
| 19 | 20 | import { getIssueById, listComments, setAssignee, type Issue } from "../src/core/issues.ts"; |
| @@ -79,7 +80,7 @@ describe("a landing with boxes unticked waits for a person", () => { | ||
| 79 | 80 | if (!created.ok) throw new Error(String(created.error)); |
| 80 | 81 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); |
| 81 | 82 | } |
| 82 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 83 | seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 83 | 84 | mason = getUserByName(app.db, "mason")!; |
| 84 | 85 | const set = await updateRepoSettings(app, alice, made.value, { |
| 85 | 86 | autonomy: "autonomous", |
test/ledger.test.ts +3 −2
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * ship or show, try again or stop — each with a rule underneath and a fake |
| 4 | 4 | * decider on top, and each written down with what came of it. |
| 5 | 5 | */ |
| 6 | import { seatAs } from "./helpers/seat.ts"; | |
| 6 | 7 | import { beforeAll, afterAll, describe, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 9 | import { tmpdir } from "node:os"; |
| @@ -30,7 +31,7 @@ import { submitReview } from "../src/core/reviews.ts"; | ||
| 30 | 31 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; |
| 31 | 32 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| 32 | 33 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 33 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 34 | import {} from "../src/core/agents.ts"; | |
| 34 | 35 | import type { Answer, Decider, Questions } from "../src/crew/decide.ts"; |
| 35 | 36 | import { driveRepo, landIfGreen } from "../src/crew/autonomy.ts"; |
| 36 | 37 | import { summariseCheckFailure } from "../src/checks/summary.ts"; |
| @@ -103,7 +104,7 @@ describe("the decision ledger", () => { | ||
| 103 | 104 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 104 | 105 | } |
| 105 | 106 | scout = getUserByName(app.db, "scout")!; |
| 106 | setCrewRole(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 107 | seatAs(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 107 | 108 | updateRepoSettings(app.db, repo, { |
| 108 | 109 | autonomy: "autonomous", |
| 109 | 110 | autonomyDirection: "Sell one real thing end to end.", |
test/limb-replan.test.ts +3 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * closes only as split into what is new; a person takes it back before it |
| 8 | 8 | * runs; the milestone shows the change. |
| 9 | 9 | */ |
| 10 | import { seatAs } from "./helpers/seat.ts"; | |
| 10 | 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 11 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 12 | 13 | import { tmpdir } from "node:os"; |
| @@ -17,7 +18,7 @@ import { linkIssue } from "../src/actions/dependencies.ts"; | ||
| 17 | 18 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 18 | 19 | import { previewReplan, replanLimb, takeBackLimb } from "../src/actions/limb_replan.ts"; |
| 19 | 20 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 20 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 21 | import {} from "../src/core/agents.ts"; | |
| 21 | 22 | import { readyNow, repoGraph } from "../src/core/dependencies.ts"; |
| 22 | 23 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 23 | 24 | import { pendingReplans, replanSince } from "../src/core/limb_replan.ts"; |
| @@ -141,7 +142,7 @@ describe("a limb handed to the planner", () => { | ||
| 141 | 142 | } |
| 142 | 143 | atlas = getUserByName(app.db, "atlas")!; |
| 143 | 144 | mason = getUserByName(app.db, "mason")!; |
| 144 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 145 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 145 | 146 | const set = await updateRepoSettings(app, alice, repo, { |
| 146 | 147 | autonomy: "autonomous", |
| 147 | 148 | autonomyDirection: "Keep the engine small.", |
test/limbs.test.ts +3 −2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * writes, what something outside the limb still needs is never taken, and |
| 10 | 10 | * undo puts it back. |
| 11 | 11 | */ |
| 12 | import { seatAs } from "./helpers/seat.ts"; | |
| 12 | 13 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 13 | 14 | import { mkdtemp, rm } from "node:fs/promises"; |
| 14 | 15 | import { tmpdir } from "node:os"; |
| @@ -20,7 +21,7 @@ import { undoFleetOp } from "../src/actions/fleet.ts"; | ||
| 20 | 21 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 21 | 22 | import { applyLimb, previewLimb } from "../src/actions/limbs.ts"; |
| 22 | 23 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 23 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 24 | import {} from "../src/core/agents.ts"; | |
| 24 | 25 | import { |
| 25 | 26 | dependenciesOf, |
| 26 | 27 | readyNow, |
| @@ -202,7 +203,7 @@ describe("limb actions", () => { | ||
| 202 | 203 | } |
| 203 | 204 | mason = getUserByName(app.db, "mason")!; |
| 204 | 205 | atlas = getUserByName(app.db, "atlas")!; |
| 205 | setCrewRole(app.db, engine.id, atlas.id, "planner"); | |
| 206 | seatAs(app.db, engine.id, atlas.id, "planner"); | |
| 206 | 207 | |
| 207 | 208 | trust = issue(engine, "P0 Trust: agents contained"); |
| 208 | 209 | feed = issue(engine, "P1 Feed: one front door"); |
test/map-first.test.ts +3 −2
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * "This first" (#7806): a writer's rank puts a ready issue ahead of the rest |
| 3 | 3 | * for the dispatcher; it never lifts a blocker, and it ends when the issue closes. |
| 4 | 4 | */ |
| 5 | import { seatAs } from "./helpers/seat.ts"; | |
| 5 | 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 6 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| @@ -10,7 +11,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 10 | 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 11 | 12 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 12 | 13 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 13 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 14 | import {} from "../src/core/agents.ts"; | |
| 14 | 15 | import { addDependency, readyNow, repoGraph } from "../src/core/dependencies.ts"; |
| 15 | 16 | import { firstAt, putFirst } from "../src/core/first.ts"; |
| 16 | 17 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| @@ -45,7 +46,7 @@ describe("this first", () => { | ||
| 45 | 46 | if (!created.ok) throw new Error(String(created.error)); |
| 46 | 47 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); |
| 47 | 48 | } |
| 48 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 49 | seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 49 | 50 | const set = await updateRepoSettings(app, alice, made.value, { |
| 50 | 51 | autonomy: "autonomous", |
| 51 | 52 | autonomyDirection: "Keep the engine small.", |
test/mcp.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * action the pages call — so what a person may not do on a page they may |
| 5 | 5 | * not do here. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { chmodSync, writeFileSync } from "node:fs"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -14,7 +15,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 14 | 15 | import { pullDir } from "../src/core/pulls.ts"; |
| 15 | 16 | import { resolveOid } from "../src/git/merge.ts"; |
| 16 | 17 | import { openIssue } from "../src/actions/issues.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { openPull } from "../src/actions/pulls.ts"; |
| 19 | 20 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 20 | 21 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| @@ -571,7 +572,7 @@ describe("the forge over MCP", () => { | ||
| 571 | 572 | const made = await createAgent(app, alice, { username: "closer", brief: "x" }); |
| 572 | 573 | if (!made.ok) throw new Error(String(made.error)); |
| 573 | 574 | expect(addToCrew(app, alice, game, "closer").ok).toBe(true); |
| 574 | setCrewRole(app.db, game.id, made.value.user.id, "planner"); | |
| 575 | seatAs(app.db, game.id, made.value.user.id, "planner"); | |
| 575 | 576 | const botToken = createToken(app.db, made.value.user.id, "bot", "rw").token; |
| 576 | 577 | const mine = (title: string) => { |
| 577 | 578 | const o = openIssue(app, made.value.user, game, { title }); |
test/member-crew.test.ts +1 −1
| @@ -47,7 +47,7 @@ describe("a member and the crew's standing instructions", () => { | ||
| 47 | 47 | fetch(`${servers.url}/settings/crew/robin`, { |
| 48 | 48 | method: "POST", |
| 49 | 49 | headers: { cookie, origin: FORGE_URL }, |
| 50 | body: new URLSearchParams({ model: "", brief: text, budgetUsd: "1" }), | |
| 50 | body: new URLSearchParams({ model: "", brief: text, budgetUsd: "1", roles: "builder" }), | |
| 51 | 51 | redirect: "manual", |
| 52 | 52 | }); |
| 53 | 53 |
test/next-steps-card.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * second account. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { join } from "node:path"; | ||
| 12 | 13 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 14 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 14 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 15 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 16 | 17 | import { createSession } from "../src/core/sessions.ts"; |
| 17 | 18 | import { createUser } from "../src/core/users.ts"; |
| 18 | 19 | import { commitFiles } from "../src/git/write.ts"; |
| @@ -102,7 +103,7 @@ describe("the next-steps card of a spawned forge", () => { | ||
| 102 | 103 | createdBy: alice.id, |
| 103 | 104 | }); |
| 104 | 105 | addCrew(parent.db, repo.value.id, atlas.value.id, alice.id); |
| 105 | setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 106 | seatAs(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 106 | 107 | parentServers = await serve(parent); |
| 107 | 108 | const spawned = await spawnForge( |
| 108 | 109 | parent, |
test/planner-closes-pull.test.ts +3 −2
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * The planner's one close on a pull (#3301): an agent's pull whose issue is |
| 3 | 3 | * closed. An open issue, a person's pull, a merge and a reopen stay refused. |
| 4 | 4 | */ |
| 5 | import { seatAs } from "./helpers/seat.ts"; | |
| 5 | 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 6 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| @@ -11,7 +12,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 11 | 12 | import { listComments } from "../src/core/issues.ts"; |
| 12 | 13 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 13 | 14 | import { createRepo } from "../src/actions/repos.ts"; |
| 14 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 15 | import {} from "../src/core/agents.ts"; | |
| 15 | 16 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 16 | 17 | import type { Repo } from "../src/core/repos.ts"; |
| 17 | 18 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -71,7 +72,7 @@ describe("the planner closes a pull", () => { | ||
| 71 | 72 | } |
| 72 | 73 | atlas = getUserByName(app.db, "atlas")!; |
| 73 | 74 | mason = getUserByName(app.db, "mason")!; |
| 74 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 75 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 75 | 76 | token = createToken(app.db, atlas.id, "rw", "rw").token; |
| 76 | 77 | }); |
| 77 | 78 |
test/planner-lent.test.ts +4 −3
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * A repository in auto mode with a direction and no planner gets one, and |
| 3 | 3 | * its first planned issues start without a further step (#7637). |
| 4 | 4 | */ |
| 5 | import { seatAs } from "./helpers/seat.ts"; | |
| 5 | 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 6 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| @@ -9,7 +10,7 @@ import { join } from "node:path"; | ||
| 9 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 11 | 12 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 12 | import { crewRole, setCrewRole } from "../src/core/agents.ts"; | |
| 13 | import { crewRole } from "../src/core/agents.ts"; | |
| 13 | 14 | import { getIssueById } from "../src/core/issues.ts"; |
| 14 | 15 | import { createRepo as coreCreateRepo, getRepoById, type Repo } from "../src/core/repos.ts"; |
| 15 | 16 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| @@ -60,7 +61,7 @@ describe("a migrated repository with only a builder", () => { | ||
| 60 | 61 | // Elsewhere on the forge atlas plans. |
| 61 | 62 | const home = await repo("home"); |
| 62 | 63 | expect(addToCrew(app, alice, home, "atlas").ok).toBe(true); |
| 63 | setCrewRole(app.db, home.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 64 | seatAs(app.db, home.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 64 | 65 | // The migrated one: a builder, nothing else. |
| 65 | 66 | pixel = await repo("pixel-plays"); |
| 66 | 67 | expect(addToCrew(app, alice, pixel, "torque").ok).toBe(true); |
| @@ -121,7 +122,7 @@ describe("a migrated repository with only a builder", () => { | ||
| 121 | 122 | test("a planner with a crew but no direction issue yet reads the second message", async () => { |
| 122 | 123 | const third = await repo("third"); |
| 123 | 124 | expect(addToCrew(app, alice, third, "atlas").ok).toBe(true); |
| 124 | setCrewRole(app.db, third.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 125 | seatAs(app.db, third.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 125 | 126 | await updateRepoSettings(app, alice, third, { |
| 126 | 127 | autonomy: "assisted", |
| 127 | 128 | autonomyDirection: "more", |
test/proposal-card.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * Refusals come first: a non-owner and a cross-origin post change nothing. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { proposePlan } from "../src/actions/plan.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { getAgentByName } from "../src/core/agents.ts"; | |
| 16 | 17 | import { listIssues, listComments } from "../src/core/issues.ts"; |
| 17 | 18 | import { getProposal } from "../src/core/proposals.ts"; |
| 18 | 19 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -90,7 +91,7 @@ describe("a wave proposal is one inbox card", () => { | ||
| 90 | 91 | repo = made.value; |
| 91 | 92 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# wave\n" }] }); |
| 92 | 93 | addToCrew(app, alice, repo, "planner"); |
| 93 | setCrewRole(app.db, repo.id, getAgentByName(app.db, "planner")!.user.id, "planner"); | |
| 94 | seatAs(app.db, repo.id, getAgentByName(app.db, "planner")!.user.id, "planner"); | |
| 94 | 95 | }, 30_000); |
| 95 | 96 | |
| 96 | 97 | afterAll(async () => { |
test/pull-tui-teams.test.ts +3 −2
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * The terminal's pull screen names the team that still owes a review (#2897), |
| 3 | 3 | * as the web pull page's team card does. |
| 4 | 4 | */ |
| 5 | import { seatAs } from "./helpers/seat.ts"; | |
| 5 | 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 6 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { openIssue } from "../src/actions/issues.ts"; | ||
| 12 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 13 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 14 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import { getPull } from "../src/core/pulls.ts"; |
| 17 | 18 | import { submitReview } from "../src/core/reviews.ts"; |
| 18 | 19 | import { saveTeam } from "../src/core/teams.ts"; |
| @@ -58,7 +59,7 @@ test("a two-team issue shows one team approved and one awaited; no team shows no | ||
| 58 | 59 | const agent = await createAgent(app, alice, { username: name }); |
| 59 | 60 | if (!agent.ok) throw new Error(String(agent.error)); |
| 60 | 61 | addToCrew(app, alice, repo, name); |
| 61 | setCrewRole(app.db, repo.id, id(name), "reviewer"); | |
| 62 | seatAs(app.db, repo.id, id(name), "reviewer"); | |
| 62 | 63 | } |
| 63 | 64 | saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] }); |
| 64 | 65 | saveTeam(app.db, "data-science", { charter: "", dial: 0, members: [id("pike")] }); |
test/red-approved.test.ts +3 −2
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * An approved pull red for 24 h gets one re-check per head, then goes back |
| 3 | 3 | * to its builder naming the failing test (#6349). `now` is passed in. |
| 4 | 4 | */ |
| 5 | import { seatAs } from "./helpers/seat.ts"; | |
| 5 | 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 6 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| @@ -11,7 +12,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 11 | 12 | import { openPull } from "../src/actions/pulls.ts"; |
| 12 | 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | 14 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; |
| 14 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 15 | import {} from "../src/core/agents.ts"; | |
| 15 | 16 | import { listComments } from "../src/core/issues.ts"; |
| 16 | 17 | import type { Pull } from "../src/core/pulls.ts"; |
| 17 | 18 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -54,7 +55,7 @@ beforeAll(async () => { | ||
| 54 | 55 | } |
| 55 | 56 | const mason = getUserByName(app.db, "mason")!; |
| 56 | 57 | quill = getUserByName(app.db, "quill")!; |
| 57 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 58 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 58 | 59 | await commitToBranch(app, alice, repo, { |
| 59 | 60 | branch: "feature/x", |
| 60 | 61 | base: "main", |
test/requested-pulls.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * share, so these pin it. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -13,7 +14,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 13 | 14 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import {} from "../src/core/agents.ts"; | |
| 17 | 18 | import type { Pull } from "../src/core/pulls.ts"; |
| 18 | 19 | import { getPull } from "../src/core/pulls.ts"; |
| 19 | 20 | import { requestedPulls, verdictIsStale } from "../src/core/requested.ts"; |
| @@ -100,7 +101,7 @@ describe("requestedPulls", () => { | ||
| 100 | 101 | if (!r.ok) throw new Error(String(r.error)); |
| 101 | 102 | await push(r.value, "main"); |
| 102 | 103 | for (const who of ["mason", "quill"]) expect(addToCrew(app, alice, r.value, who).ok).toBe(true); |
| 103 | setCrewRole(app.db, r.value.id, reviewer.id, "reviewer"); | |
| 104 | seatAs(app.db, r.value.id, reviewer.id, "reviewer"); | |
| 104 | 105 | repos.push(r.value); |
| 105 | 106 | } |
| 106 | 107 | [garden, orchard] = repos as [Repo, Repo]; |
test/requested.test.ts +3 −2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * which pulls wait on a builder and which on a reviewer. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { seatAs } from "./helpers/seat.ts"; | |
| 9 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 12 | import { tmpdir } from "node:os"; |
| @@ -14,7 +15,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 14 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 15 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | 17 | import { createApp, type App } from "../src/app.ts"; |
| 17 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 18 | import {} from "../src/core/agents.ts"; | |
| 18 | 19 | import { getIssueById, listComments } from "../src/core/issues.ts"; |
| 19 | 20 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 20 | 21 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| @@ -148,7 +149,7 @@ describe("a pull with changes requested always moves", () => { | ||
| 148 | 149 | } |
| 149 | 150 | builder = getUserByName(app.db, "mason")!; |
| 150 | 151 | reviewer = getUserByName(app.db, "quill")!; |
| 151 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 152 | seatAs(app.db, repo.id, reviewer.id, "reviewer"); | |
| 152 | 153 | // Assisted: a lapsed head is not sent for being autonomous, only for answering a verdict. |
| 153 | 154 | app.db |
| 154 | 155 | .query("UPDATE repo SET autonomy = 'assisted', autonomy_by = ?2, autonomy_slots = 3 WHERE id = ?1") |
test/review-from-check.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * reviewer's task (#1237). A review that ends with no verdict is sent again, |
| 5 | 5 | * to the other reviewer when the crew has one. |
| 6 | 6 | */ |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 14 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import type { Repo } from "../src/core/repos.ts"; |
| 17 | 18 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| 18 | 19 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| @@ -99,7 +100,7 @@ describe("a review that comes back with no verdict", () => { | ||
| 99 | 100 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 100 | 101 | } |
| 101 | 102 | for (const name of ["juniper", "sentinel"]) |
| 102 | setCrewRole(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer"); | |
| 103 | seatAs(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer"); | |
| 103 | 104 | }); |
| 104 | 105 | |
| 105 | 106 | afterAll(async () => { |
test/review-from-green.test.ts +3 −2
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * a slow check queue must not lapse a pull before review was ever sent. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { seatAs } from "./helpers/seat.ts"; | |
| 6 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 9 | import { tmpdir } from "node:os"; |
| @@ -11,7 +12,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 11 | 12 | import { openPull } from "../src/actions/pulls.ts"; |
| 12 | 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | 14 | import { createApp, type App } from "../src/app.ts"; |
| 14 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 15 | import {} from "../src/core/agents.ts"; | |
| 15 | 16 | import type { Pull } from "../src/core/pulls.ts"; |
| 16 | 17 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 17 | 18 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| @@ -98,7 +99,7 @@ describe("review is owed from the check's green", () => { | ||
| 98 | 99 | const agent = await createAgent(app, alice, { username: "quill" }); |
| 99 | 100 | if (!agent.ok) throw new Error(String(agent.error)); |
| 100 | 101 | addToCrew(app, alice, repo, "quill"); |
| 101 | setCrewRole(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer"); | |
| 102 | seatAs(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer"); | |
| 102 | 103 | }, 30_000); |
| 103 | 104 | |
| 104 | 105 | afterAll(async () => { |
test/review-gate.test.ts +13 −12
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * reviewer: it waits for a person (#597). A person's own pull lands as |
| 6 | 6 | * before. |
| 7 | 7 | */ |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -13,7 +14,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 13 | 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 14 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 15 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import {} from "../src/core/agents.ts"; | |
| 17 | 18 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 18 | 19 | import { listComments } from "../src/core/issues.ts"; |
| 19 | 20 | import { |
| @@ -137,7 +138,7 @@ describe("the review gate", () => { | ||
| 137 | 138 | ["an agent's", () => mason], |
| 138 | 139 | ] as const) { |
| 139 | 140 | test(`${who} green pull is reviewed, and lands only on an approval of its head`, async () => { |
| 140 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 141 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 141 | 142 | const name = who.includes("person") ? "by-person" : "by-agent"; |
| 142 | 143 | const pull = await greenPull(author(), name); |
| 143 | 144 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); |
| @@ -180,7 +181,7 @@ describe("the review gate", () => { | ||
| 180 | 181 | }; |
| 181 | 182 | |
| 182 | 183 | test("an agent's pull whose review lapses never ships: it waits for a person, and a person's approval lands it", async () => { |
| 183 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 184 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 184 | 185 | const pull = await greenPull(mason, "unanswered"); |
| 185 | 186 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); |
| 186 | 187 | unanswered(pull); |
| @@ -200,7 +201,7 @@ describe("the review gate", () => { | ||
| 200 | 201 | }); |
| 201 | 202 | |
| 202 | 203 | test("with no reviewer on the crew an agent's pull waits for a person and says why", async () => { |
| 203 | setCrewRole(app.db, repo.id, quill.id, "builder"); | |
| 204 | seatAs(app.db, repo.id, quill.id, "builder"); | |
| 204 | 205 | const pull = await greenPull(mason, "no-reviewer"); |
| 205 | 206 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); |
| 206 | 207 | expect(reviewRuns(pull)).toHaveLength(0); |
| @@ -210,7 +211,7 @@ describe("the review gate", () => { | ||
| 210 | 211 | }); |
| 211 | 212 | |
| 212 | 213 | test("a person's own pull is unchanged: a review that lapses does not hold it; it lands, and says so", async () => { |
| 213 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 214 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 214 | 215 | const pull = await greenPull(alice, "person-unanswered"); |
| 215 | 216 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); |
| 216 | 217 | unanswered(pull); |
| @@ -222,7 +223,7 @@ describe("the review gate", () => { | ||
| 222 | 223 | }); |
| 223 | 224 | |
| 224 | 225 | test("a change requested on the head holds the pull", async () => { |
| 225 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 226 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 226 | 227 | const pull = await greenPull(alice, "contested"); |
| 227 | 228 | submitReview(app.db, { |
| 228 | 229 | issueId: pull.id, |
| @@ -255,11 +256,11 @@ describe("the review gate", () => { | ||
| 255 | 256 | } |
| 256 | 257 | |
| 257 | 258 | test("a reviewer taken off the crew no longer holds the pulls it judged", async () => { |
| 258 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 259 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 259 | 260 | const pull = await greenPull(mason, "judged"); |
| 260 | 261 | askChanges(pull, quill); |
| 261 | 262 | expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(1); |
| 262 | setCrewRole(app.db, repo.id, quill.id, "builder"); | |
| 263 | seatAs(app.db, repo.id, quill.id, "builder"); | |
| 263 | 264 | expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(0); |
| 264 | 265 | // A person's request is the person's, whatever their crew. |
| 265 | 266 | askChanges(pull, alice); |
| @@ -267,7 +268,7 @@ describe("the review gate", () => { | ||
| 267 | 268 | }); |
| 268 | 269 | |
| 269 | 270 | test(`the reviewer gets ${REVIEW_ROUNDS} rounds of changes; a third is advice and the pull lands`, async () => { |
| 270 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 271 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 271 | 272 | let pull = await greenPull(mason, "rounds"); |
| 272 | 273 | askChanges(pull, quill); |
| 273 | 274 | // Asked twice on one head is still one round. |
| @@ -283,8 +284,8 @@ describe("the review gate", () => { | ||
| 283 | 284 | }); |
| 284 | 285 | |
| 285 | 286 | test("a builder's run that answers a change request without pushing parks the pull", async () => { |
| 286 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 287 | setCrewRole(app.db, repo.id, mason.id, "builder"); | |
| 287 | seatAs(app.db, repo.id, quill.id, "reviewer"); | |
| 288 | seatAs(app.db, repo.id, mason.id, "builder"); | |
| 288 | 289 | const pull = await greenPull(mason, "unmoved"); |
| 289 | 290 | askChanges(pull, quill); |
| 290 | 291 | expect( |
| @@ -348,7 +349,7 @@ describe("a pull nobody reviewed, however old (#1655)", () => { | ||
| 348 | 349 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 349 | 350 | } |
| 350 | 351 | mason = getUserByName(app.db, "mason")!; |
| 351 | setCrewRole(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer"); | |
| 352 | seatAs(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer"); | |
| 352 | 353 | }); |
| 353 | 354 | |
| 354 | 355 | afterAll(async () => { |
test/review-page.test.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | /** `/review` lists each ask waiting on a person as one row with its why. */ |
| 2 | import { seatAs } from "./helpers/seat.ts"; | |
| 2 | 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 4 | 5 | import { tmpdir } from "node:os"; |
| @@ -6,7 +7,7 @@ import { join } from "node:path"; | ||
| 6 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 8 | import { addComment, openIssue } from "../src/actions/issues.ts"; |
| 8 | 9 | import { addComment as addRawComment } from "../src/core/issues.ts"; |
| 9 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 10 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 10 | 11 | import { recordLook } from "../src/core/looks.ts"; |
| 11 | 12 | import { previewEnv } from "../src/apps/data.ts"; |
| 12 | 13 | import { createApp as createAppRow } from "../src/core/apps.ts"; |
| @@ -58,7 +59,7 @@ beforeAll(async () => { | ||
| 58 | 59 | if (!made.ok) throw new Error(made.error); |
| 59 | 60 | const repo = made.value; |
| 60 | 61 | for (const a of [builder, reviewer]) addCrew(app.db, repo.id, a.id, alice.id); |
| 61 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 62 | seatAs(app.db, repo.id, reviewer.id, "reviewer"); | |
| 62 | 63 | let seq = 0; |
| 63 | 64 | const pull = (author: string, title: string, age: number) => { |
| 64 | 65 | const issueId = id(); |
test/review-route.test.ts +3 −2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * not only one that ships on its own. A review run is judged by its verdict: |
| 7 | 7 | * it pushes nothing by design. |
| 8 | 8 | */ |
| 9 | import { seatAs } from "./helpers/seat.ts"; | |
| 9 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 12 | import { tmpdir } from "node:os"; |
| @@ -14,7 +15,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 14 | 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 15 | 16 | import { openPull } from "../src/actions/pulls.ts"; |
| 16 | 17 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 17 | import { setCrewRole, type Agent } from "../src/core/agents.ts"; | |
| 18 | import { type Agent } from "../src/core/agents.ts"; | |
| 18 | 19 | import type { Pull } from "../src/core/pulls.ts"; |
| 19 | 20 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 20 | 21 | import type { Review } from "../src/core/reviews.ts"; |
| @@ -185,7 +186,7 @@ describe("every green pull an agent wrote is sent to review", () => { | ||
| 185 | 186 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 186 | 187 | } |
| 187 | 188 | for (const name of ["juniper", "sentinel"]) |
| 188 | setCrewRole(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer"); | |
| 189 | seatAs(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer"); | |
| 189 | 190 | // Not autonomous: review is not the shipping loop's alone. |
| 190 | 191 | expect(repo.autonomy).not.toBe("autonomous"); |
| 191 | 192 | }); |
test/review-wait.test.ts +3 −2
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * word), not in the owner's asks, and moves to the owner on a verdict (#1602). |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { seatAs } from "./helpers/seat.ts"; | |
| 6 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 9 | import { tmpdir } from "node:os"; |
| @@ -11,7 +12,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 11 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 12 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 13 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 14 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 15 | import {} from "../src/core/agents.ts"; | |
| 15 | 16 | import type { Pull } from "../src/core/pulls.ts"; |
| 16 | 17 | import type { Repo } from "../src/core/repos.ts"; |
| 17 | 18 | import { submitReview } from "../src/core/reviews.ts"; |
| @@ -84,7 +85,7 @@ describe("a green agent pull and its reviewer", () => { | ||
| 84 | 85 | await createAgent(app, alice, { username: name }); |
| 85 | 86 | addToCrew(app, alice, repo, name); |
| 86 | 87 | } |
| 87 | setCrewRole(app.db, repo.id, getUserByName(app.db, "rev")!.id, "reviewer"); | |
| 88 | seatAs(app.db, repo.id, getUserByName(app.db, "rev")!.id, "reviewer"); | |
| 88 | 89 | pull = await greenPull(1); |
| 89 | 90 | await home(); // warm the first render so no test pays for it under load |
| 90 | 91 | }, 60_000); |
test/seat-slots.test.ts +10 −9
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * a change is audited and takes effect on the next tick, and the MCP `crew` |
| 6 | 6 | * tool sets the same numbers. |
| 7 | 7 | */ |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { join } from "node:path"; | ||
| 12 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 14 | 15 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import {} from "../src/core/agents.ts"; | |
| 16 | 17 | import { crewNow } from "../src/actions/crew_now.ts"; |
| 17 | 18 | import { reviewSays } from "../src/crew/review_says.ts"; |
| 18 | 19 | import { askLinesFor } from "../src/web/studio/asks.ts"; |
| @@ -72,13 +73,13 @@ beforeAll(async () => { | ||
| 72 | 73 | servers = await serve(app); |
| 73 | 74 | alice = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, adminIfFirst: true })); |
| 74 | 75 | proveAll(app); |
| 75 | for (const name of ["mason", "atlas", "judge"]) ok(await createAgent(app, alice, { username: name })); | |
| 76 | for (const name of ["mason", "atlas", "judge"]) ok(await createAgent(app, alice, { username: name, roles: ["planner", "builder", "reviewer"] })); | |
| 76 | 77 | repo = ok(await createRepo(app, alice, { name: "yard", visibility: "public" })); |
| 77 | 78 | ok(await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "README.md", content: "# yard\n" }] })); |
| 78 | for (const name of ["mason", "atlas", "judge"]) expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 79 | for (const name of ["mason", "atlas", "judge"]) expect(addToCrew(app, alice, repo, name, "builder").ok).toBe(true); | |
| 79 | 80 | const id = (n: string) => getUserByName(app.db, n)!.id; |
| 80 | setCrewRole(app.db, repo.id, id("atlas"), "planner"); | |
| 81 | setCrewRole(app.db, repo.id, id("judge"), "reviewer"); | |
| 81 | seatAs(app.db, repo.id, id("atlas"), "planner"); | |
| 82 | seatAs(app.db, repo.id, id("judge"), "reviewer"); | |
| 82 | 83 | ok(await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", autonomyDirection: "Make the yard rounder.", autonomyCeilingUsd: 10, autonomySlots: 2, autonomyBy: alice.id })); |
| 83 | 84 | const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" }); |
| 84 | 85 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; |
| @@ -230,11 +231,11 @@ test("a lapsed review says why on the now list, the MCP and the asks: paused, un | ||
| 230 | 231 | expect(paused.ask).toContain("the reviewer is paused"); |
| 231 | 232 | app.db.query("UPDATE repo SET reviewer_slots = 8 WHERE id = ?1").run(repo.id); |
| 232 | 233 | |
| 233 | setCrewRole(app.db, repo.id, judge.id, "builder"); | |
| 234 | seatAs(app.db, repo.id, judge.id, "builder"); | |
| 234 | 235 | const none = await says(); |
| 235 | 236 | expect(none.now).toBe("no reviewer on the crew"); |
| 236 | 237 | expect(none.ask).toContain("no reviewer on the crew"); |
| 237 | setCrewRole(app.db, repo.id, judge.id, "reviewer"); | |
| 238 | seatAs(app.db, repo.id, judge.id, "reviewer"); | |
| 238 | 239 | }); |
| 239 | 240 | |
| 240 | 241 | test("three change requests are worded as such, never as an empty crew", () => { |
| @@ -291,8 +292,8 @@ describe("the reviewer's cap, end to end (#8734)", () => { | ||
| 291 | 292 | beforeAll(async () => { |
| 292 | 293 | porch = ok(await createRepo(app, alice, { name: "porch", visibility: "private" })); |
| 293 | 294 | ok(await commitToBranch(app, alice, porch, { message: "Start", files: [{ path: "README.md", content: "# porch\n" }] })); |
| 294 | expect(addToCrew(app, alice, porch, "judge").ok).toBe(true); | |
| 295 | setCrewRole(app.db, porch.id, getUserByName(app.db, "judge")!.id, "reviewer"); | |
| 295 | expect(addToCrew(app, alice, porch, "judge", "builder").ok).toBe(true); | |
| 296 | seatAs(app.db, porch.id, getUserByName(app.db, "judge")!.id, "reviewer"); | |
| 296 | 297 | }); |
| 297 | 298 | |
| 298 | 299 | test("at 1, the second agent pull is queued only after the first review ends", async () => { |
test/spawn-child-apps.test.ts +3 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * credential, the home and the app page say so and where to add one. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 11 | import { tmpdir } from "node:os"; |
| @@ -12,7 +13,7 @@ import { join } from "node:path"; | ||
| 12 | 13 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 14 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 14 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 15 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 16 | 17 | import { getAppByRepo, listApps } from "../src/core/apps.ts"; |
| 17 | 18 | import { getRepo } from "../src/core/repos.ts"; |
| 18 | 19 | import { |
| @@ -91,7 +92,7 @@ describe("a spawned child's apps", () => { | ||
| 91 | 92 | createdBy: alice.id, |
| 92 | 93 | }); |
| 93 | 94 | addCrew(parent.db, repo.value.id, atlas.value.id, alice.id); |
| 94 | setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 95 | seatAs(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 95 | 96 | } |
| 96 | 97 | } |
| 97 | 98 | const spawned = await spawnForge( |
test/spawn-first-boot.test.ts +3 −3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * child's own pages — and a link that is used, expired or made up says so. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { seatAs } from "./helpers/seat.ts"; | |
| 9 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 11 | import { readdir, mkdtemp, rm } from "node:fs/promises"; |
| 11 | 12 | import { tmpdir } from "node:os"; |
| @@ -21,8 +22,7 @@ import { | ||
| 21 | 22 | addCrew, |
| 22 | 23 | createAgentRow, |
| 23 | 24 | listCrewRoles, |
| 24 | setCrewRole, | |
| 25 | } from "../src/core/agents.ts"; | |
| 25 | } from "../src/core/agents.ts"; | |
| 26 | 26 | import { listIssues } from "../src/core/issues.ts"; |
| 27 | 27 | import { getOrg, listTeams, listTeamMembers } from "../src/core/orgs.ts"; |
| 28 | 28 | import { getRepo } from "../src/core/repos.ts"; |
| @@ -113,7 +113,7 @@ describe("a spawned child's first boot", () => { | ||
| 113 | 113 | createdBy: alice.id, |
| 114 | 114 | }); |
| 115 | 115 | addCrew(parent.db, repo.value.id, agent.value.id, alice.id); |
| 116 | setCrewRole(parent.db, repo.value.id, agent.value.id, role); | |
| 116 | seatAs(parent.db, repo.value.id, agent.value.id, role); | |
| 117 | 117 | } |
| 118 | 118 | const source: SpawnSource = { |
| 119 | 119 | catalog: [ |
test/studio-runs.test.ts +4 −3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * test/studio*.test.ts. It runs in about 2.5 s with no waits (#3578). |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | 9 | import { |
| 9 | 10 | afterAll, |
| 10 | 11 | afterEach, |
| @@ -34,7 +35,7 @@ import { | ||
| 34 | 35 | createRepo, |
| 35 | 36 | updateRepoSettings, |
| 36 | 37 | } from "../src/actions/repos.ts"; |
| 37 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 38 | import {} from "../src/core/agents.ts"; | |
| 38 | 39 | import type { Issue } from "../src/core/issues.ts"; |
| 39 | 40 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 40 | 41 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -112,8 +113,8 @@ describe("the studio's slot count", () => { | ||
| 112 | 113 | } |
| 113 | 114 | const reviewer = getUserByName(app.db, "quill")!; |
| 114 | 115 | const planner = getUserByName(app.db, "chart")!; |
| 115 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 116 | setCrewRole(app.db, repo.id, planner.id, "planner"); | |
| 116 | seatAs(app.db, repo.id, reviewer.id, "reviewer"); | |
| 117 | seatAs(app.db, repo.id, planner.id, "planner"); | |
| 117 | 118 | const on = await updateRepoSettings(app, alice, repo, { |
| 118 | 119 | autonomy: "autonomous", |
| 119 | 120 | autonomyDirection: "", |
test/team-dispatch.test.ts +3 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * a planning run receives the teams section in its task. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -13,7 +14,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 13 | 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 14 | 15 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 15 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import {} from "../src/core/agents.ts"; | |
| 17 | 18 | import { createLabel, getIssueById, setIssueLabels, type Issue } from "../src/core/issues.ts"; |
| 18 | 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 19 | 20 | import { listRuns } from "../src/core/runs.ts"; |
| @@ -64,7 +65,7 @@ beforeAll(async () => { | ||
| 64 | 65 | if (!agent.ok) throw new Error(String(agent.error)); |
| 65 | 66 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 66 | 67 | } |
| 67 | setCrewRole(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 68 | seatAs(app.db, repo.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 68 | 69 | // The stand-in agent keeps the first line it is told and ends at once. |
| 69 | 70 | const fake = join(dataDir, "fake-claude"); |
| 70 | 71 | writeFileSync( |
test/tui-agents.test.ts +18
| @@ -191,6 +191,24 @@ describe("agents in the terminal", () => { | ||
| 191 | 191 | h.stop(); |
| 192 | 192 | }); |
| 193 | 193 | |
| 194 | test("roles change after a confirm and a bad one is refused before it", async () => { | |
| 195 | const h = boot("alice"); | |
| 196 | await open(h); | |
| 197 | await h.press("r"); | |
| 198 | await clear(h, 20); | |
| 199 | await h.type("planner, boss"); | |
| 200 | await h.press("enter"); | |
| 201 | expect(getAgentByName(forge.db, "bot1")!.roles).toEqual(["builder"]); | |
| 202 | await h.press("escape"); | |
| 203 | await h.press("r"); | |
| 204 | await clear(h, 20); | |
| 205 | await h.type("planner, reviewer"); | |
| 206 | await h.press("enter"); | |
| 207 | await h.press("y"); | |
| 208 | expect(getAgentByName(forge.db, "bot1")!.roles).toEqual(["planner", "reviewer"]); | |
| 209 | h.stop(); | |
| 210 | }); | |
| 211 | ||
| 194 | 212 | test("a non-admin and an admin agent are never offered the screen", async () => { |
| 195 | 213 | for (const who of ["bob", "carol"]) { |
| 196 | 214 | const h = boot(who); |
test/tui-limbs-replanned.test.ts +3 −2
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * `Re-planned <time ago>` and one line per split, as the milestones page does, |
| 4 | 4 | * and Enter on a split line opens the new issue. Owner facts only. |
| 5 | 5 | */ |
| 6 | import { seatAs } from "./helpers/seat.ts"; | |
| 6 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 9 | import { tmpdir } from "node:os"; |
| @@ -13,7 +14,7 @@ import { linkIssue } from "../src/actions/dependencies.ts"; | ||
| 13 | 14 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 14 | 15 | import { replanLimb } from "../src/actions/limb_replan.ts"; |
| 15 | 16 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import {} from "../src/core/agents.ts"; | |
| 17 | 18 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 18 | 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 19 | 20 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| @@ -90,7 +91,7 @@ describe("the limbs screen after a re-plan", () => { | ||
| 90 | 91 | if (!agent.ok) throw new Error(String(agent.error)); |
| 91 | 92 | expect(addToCrew(app, alice, repo, "atlas").ok).toBe(true); |
| 92 | 93 | atlas = getUserByName(app.db, "atlas")!; |
| 93 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 94 | seatAs(app.db, repo.id, atlas.id, "planner"); | |
| 94 | 95 | const set = await updateRepoSettings(app, alice, repo, { |
| 95 | 96 | autonomy: "autonomous", |
| 96 | 97 | autonomyDirection: "Keep the engine small.", |
test/tui-limbs.test.ts +3 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * keys to change it, a guest sees nothing private, and a move reaches the |
| 9 | 9 | * outbox (and so a milestones page open on the web) as `limb.moved`. |
| 10 | 10 | */ |
| 11 | import { seatAs } from "./helpers/seat.ts"; | |
| 11 | 12 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 12 | 13 | import { mkdtemp, rm } from "node:fs/promises"; |
| 13 | 14 | import { tmpdir } from "node:os"; |
| @@ -16,7 +17,7 @@ import { linkIssue } from "../src/actions/dependencies.ts"; | ||
| 16 | 17 | import { openIssue } from "../src/actions/issues.ts"; |
| 17 | 18 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 18 | 19 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 19 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 20 | import {} from "../src/core/agents.ts"; | |
| 20 | 21 | import { pendingReplans } from "../src/core/limb_replan.ts"; |
| 21 | 22 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 22 | 23 | import { listEvents } from "../src/core/events.ts"; |
| @@ -274,7 +275,7 @@ describe("raise and lower a limb in the terminal", () => { | ||
| 274 | 275 | const agent = await createAgent(forge, alice, { username: "atlas" }); |
| 275 | 276 | if (!agent.ok) throw new Error(String(agent.error)); |
| 276 | 277 | addToCrew(forge, alice, r, "atlas"); |
| 277 | setCrewRole(forge.db, r.id, getUserByName(forge.db, "atlas")!.id, "planner"); | |
| 278 | seatAs(forge.db, r.id, getUserByName(forge.db, "atlas")!.id, "planner"); | |
| 278 | 279 | await updateRepoSettings(forge, alice, r, { |
| 279 | 280 | autonomy: "autonomous", |
| 280 | 281 | autonomyDirection: "Keep it small.", |
test/web-apps.test.ts +4 −3
| @@ -732,7 +732,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 732 | 732 | expect(await bodyOf(alice, "/settings")).toContain('href="/settings/crew"'); |
| 733 | 733 | |
| 734 | 734 | const bad = await web(alice, "/settings/crew", { |
| 735 | form: { username: "bot", budgetUsd: "-4" }, | |
| 735 | form: { username: "bot", budgetUsd: "-4", roles: "builder" }, | |
| 736 | 736 | }); |
| 737 | 737 | expect(bad.status).toBe(422); |
| 738 | 738 | const badPage = words(await bad.text()); |
| @@ -751,6 +751,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 751 | 751 | model: "", |
| 752 | 752 | brief: "Be brief.", |
| 753 | 753 | budgetUsd: "2", |
| 754 | roles: "builder", | |
| 754 | 755 | }, |
| 755 | 756 | }); |
| 756 | 757 | expect(made.status).toBe(303); |
| @@ -765,14 +766,14 @@ describe("apps, runs and the crew on the web", () => { | ||
| 765 | 766 | expect(edit).toContain('value="bot"'); |
| 766 | 767 | expect(edit).toContain("Retire this agent"); |
| 767 | 768 | const updated = await web(alice, "/settings/crew/bot", { |
| 768 | form: { model: "sonnet", brief: "Be brief.", budgetUsd: "3" }, | |
| 769 | form: { model: "sonnet", brief: "Be brief.", budgetUsd: "3", roles: "builder" }, | |
| 769 | 770 | }); |
| 770 | 771 | expect(updated.status).toBe(303); |
| 771 | 772 | expect(getAgentByName(app.db, "bot")?.model).toBe("sonnet"); |
| 772 | 773 | expect(getAgentByName(app.db, "bot")?.budgetUsd).toBe(3); |
| 773 | 774 | // A refused edit shows the error beside its field. |
| 774 | 775 | const refused = await web(alice, "/settings/crew/bot", { |
| 775 | form: { model: "sonnet", brief: "Be brief.", budgetUsd: "-4" }, | |
| 776 | form: { model: "sonnet", brief: "Be brief.", budgetUsd: "-4", roles: "builder" }, | |
| 776 | 777 | }); |
| 777 | 778 | expect(refused.status).toBe(422); |
| 778 | 779 | const refusedPage = words(await refused.text()); |
new above
offline