Public: anyone can read this; the crew can change it.
GET /api/v1/crew/now answers what the crew is doing right now, as MCP `now` does #7910
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7900-get-api-v1-crew-now-answers-what-the-cre → main 4 commits fork 2 threads · 2 unresolved
6 files
changed +270
−93
src/actions/crew_now.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * What the crew is doing right now, as data, for the doors that are not a | |
| 3 | * page (the MCP `now` tool and GET /api/v1/crew/now): who, on what, since | |
| 4 | * when, at what cost. A planner that declined the direction and waits for | |
| 5 | * the reader comes first. The visibility checks live here, not in a caller. | |
| 6 | */ | |
| 7 | import { mayViewId } from "../core/sealed.ts"; | |
| 8 | import { getIssueById, listOpenAgentPullsVisibleTo } from "../core/issues.ts"; | |
| 9 | import { waitsForPerson } from "../crew/review.ts"; | |
| 10 | import { getPull } from "../core/pulls.ts"; | |
| 11 | import { ownerFactRepoIds } from "../core/repos.ts"; | |
| 12 | import { plannerDeclines } from "../crew/autonomy.ts"; | |
| 13 | import { listActiveRuns } from "../core/runs.ts"; | |
| 14 | import { getUsersByIds } from "../core/users.ts"; | |
| 15 | import { appsOf } from "../web/studio/asks.ts"; | |
| 16 | import { mayReadRuns, runForReader } from "./runs.ts"; | |
| 17 | import { placementWords } from "../checks/placement.ts"; | |
| 18 | import type { Ctx } from "../http/context.ts"; | |
| 19 | import type { User } from "../core/users.ts"; | |
| 20 | ||
| 21 | /** Rows of the crew's work as `user` may see them (none for no one). */ | |
| 22 | export function crewNow(ctx: Ctx, user: User) { | |
| 23 | const { repos } = appsOf(ctx, user); | |
| 24 | const byId = new Map(repos.map((r) => [r.id, r])); | |
| 25 | // A private app's runs are for those who may enter it (`mayReadRuns`). | |
| 26 | // A run on a sealed report is for those who may see the report. | |
| 27 | const runs = listActiveRuns( | |
| 28 | ctx.app.db, | |
| 29 | repos.filter((r) => mayReadRuns(ctx.app, user, r)).map((r) => r.id), | |
| 30 | ).filter((r) => | |
| 31 | mayViewId(ctx.app.db, byId.get(r.repoId)!, r.issueId, user), | |
| 32 | ); | |
| 33 | const actors = getUsersByIds( | |
| 34 | ctx.app.db, | |
| 35 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 36 | ); | |
| 37 | // A planner that declined the direction is the crew waiting on you: | |
| 38 | // it rides the same list, as a row with no run. | |
| 39 | // Owner facts only: a stranger's public app never shows its planner here. | |
| 40 | const owned = ownerFactRepoIds(ctx.app.db, repos, user); | |
| 41 | const waiting = plannerDeclines( | |
| 42 | ctx.app.db, | |
| 43 | repos.filter((r) => owned.has(r.id)), | |
| 44 | ).map((d) => ({ | |
| 45 | run: null, | |
| 46 | app: `${d.repo.ownerName}/${d.repo.name}`, | |
| 47 | who: "the planner", | |
| 48 | on: `#${d.issue.number} ${d.issue.title}`, | |
| 49 | why: "declined", | |
| 50 | state: "waiting for you", | |
| 51 | says: d.words, | |
| 52 | since: d.at, | |
| 53 | costUsd: 0, | |
| 54 | })); | |
| 55 | // An agent's green pull that waits for a person's review (#597): no | |
| 56 | // reviewer on the crew, or one that lapsed — with its age, never silent. | |
| 57 | const ownRepos = repos.filter((r) => owned.has(r.id)); | |
| 58 | const reviewWaits = listOpenAgentPullsVisibleTo(ctx.app.db, user, 200) | |
| 59 | .filter((i) => ownRepos.some((r) => r.id === i.repoId)) | |
| 60 | .flatMap((issue) => { | |
| 61 | const pull = getPull(ctx.app.db, issue.repoId, issue.number); | |
| 62 | const repo = byId.get(issue.repoId); | |
| 63 | const wait = pull && repo ? waitsForPerson(ctx.app.db, repo.id, pull) : undefined; | |
| 64 | return wait === undefined || repo === undefined | |
| 65 | ? [] | |
| 66 | : [ | |
| 67 | { | |
| 68 | run: null, | |
| 69 | app: `${repo.ownerName}/${repo.name}`, | |
| 70 | who: "you", | |
| 71 | on: `#${issue.number} ${issue.title}`, | |
| 72 | why: "review", | |
| 73 | state: "waiting for your review", | |
| 74 | says: wait.why === "lapsed" ? "the reviewer did not answer" : "no reviewer on the crew", | |
| 75 | since: wait.since, | |
| 76 | costUsd: 0, | |
| 77 | }, | |
| 78 | ]; | |
| 79 | }); | |
| 80 | return [ | |
| 81 | ...waiting, | |
| 82 | ...reviewWaits, | |
| 83 | ...runs.map((run) => { | |
| 84 | const repo = byId.get(run.repoId)!; | |
| 85 | // As this caller may read it (`runForReader`): cost and node are | |
| 86 | // the owner's facts. | |
| 87 | const r = runForReader(ctx.app, user, repo)(run); | |
| 88 | const issue = | |
| 89 | r.issueId === null | |
| 90 | ? undefined | |
| 91 | : getIssueById(ctx.app.db, r.issueId); | |
| 92 | return { | |
| 93 | run: r.id, | |
| 94 | app: `${repo.ownerName}/${repo.name}`, | |
| 95 | who: (r.actorId && actors.get(r.actorId)?.username) || "the crew", | |
| 96 | on: issue === undefined ? null : `#${issue.number} ${issue.title}`, | |
| 97 | why: r.reason, | |
| 98 | state: r.state, | |
| 99 | since: r.startedAt ?? r.createdAt, | |
| 100 | costUsd: r.costUsd, | |
| 101 | ...(placementWords(r) === "" ? {} : { ran: placementWords(r) }), | |
| 102 | }; | |
| 103 | }), | |
| 104 | ]; | |
| 105 | } |
src/contract/openapi/routes/getApiV1CrewNow.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/crew/now", | |
| 6 | summary: "What the crew is doing right now, as MCP `now` answers it: { items: [{ run, app, who, on, why, state, since, costUsd, ran? }], next_cursor: null } for the apps the caller may read; a planner that declined the direction and waits for the caller comes first (`run: null`, its words in `says`), then pulls waiting for their review", | |
| 7 | auth: true, | |
| 8 | refusals: { unauthorized: "no valid credentials were sent" }, | |
| 9 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -47,6 +47,7 @@ export { route as getApiV1BoardsSlug } from "./getApiV1BoardsSlug.ts"; | ||
| 47 | 47 | export { route as getApiV1BoardsSlugPanelsId } from "./getApiV1BoardsSlugPanelsId.ts"; |
| 48 | 48 | export { route as getApiV1BoardsSources } from "./getApiV1BoardsSources.ts"; |
| 49 | 49 | export { route as getApiV1Contribute } from "./getApiV1Contribute.ts"; |
| 50 | export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts"; | |
| 50 | 51 | export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts"; |
| 51 | 52 | export { route as getApiV1Explore } from "./getApiV1Explore.ts"; |
| 52 | 53 | export { route as getApiV1Family } from "./getApiV1Family.ts"; |
src/http/api.ts +15
| @@ -30,6 +30,7 @@ import { DOMAINS } from "../kernel/domains.ts"; | ||
| 30 | 30 | import { readBounded } from "./limits.ts"; |
| 31 | 31 | import { askCheck } from "../checks/again.ts"; |
| 32 | 32 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 33 | import { crewNow } from "../actions/crew_now.ts"; | |
| 33 | 34 | import { readScorecards } from "../actions/scorecard.ts"; |
| 34 | 35 | import { |
| 35 | 36 | accountSignIns, |
| @@ -1175,6 +1176,20 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1175 | 1176 | return timePhase(ctx.request, "render", () => json(card)); |
| 1176 | 1177 | }); |
| 1177 | 1178 | |
| 1179 | // What the crew is doing right now, as MCP `now` answers it: a read, so | |
| 1180 | // any scope; the visibility checks live in `actions/crew_now.ts`. | |
| 1181 | router.add("GET", "/api/v1/crew/now", (ctx) => { | |
| 1182 | const denied = requireScope(ctx, "ro"); | |
| 1183 | if (denied) return denied; | |
| 1184 | if (!ctx.auth.user) return unauthorized(); | |
| 1185 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1186 | const user = ctx.auth.user; | |
| 1187 | const items = timePhase(ctx.request, "crew", () => crewNow(ctx, user)); | |
| 1188 | return timePhase(ctx.request, "render", () => | |
| 1189 | json({ items, next_cursor: null }), | |
| 1190 | ); | |
| 1191 | }); | |
| 1192 | ||
| 1178 | 1193 | // The token the forge's agent acts with in one conversation (#7648): only |
| 1179 | 1194 | // the person's own browser session mints it, same-origin, for themself. |
| 1180 | 1195 | // Room for `words` at their cap even when every character is escaped. |
src/http/mcp/tools/now.ts +2 −93
| @@ -1,14 +1,4 @@ | ||
| 1 | import { mayViewId } from "../../../core/sealed.ts"; | |
| 2 | import { getIssueById, listOpenAgentPullsVisibleTo } from "../../../core/issues.ts"; | |
| 3 | import { waitsForPerson } from "../../../crew/review.ts"; | |
| 4 | import { getPull } from "../../../core/pulls.ts"; | |
| 5 | import { ownerFactRepoIds } from "../../../core/repos.ts"; | |
| 6 | import { plannerDeclines } from "../../../crew/autonomy.ts"; | |
| 7 | import { listActiveRuns } from "../../../core/runs.ts"; | |
| 8 | import { getUsersByIds } from "../../../core/users.ts"; | |
| 9 | import { appsOf } from "../../../web/studio/asks.ts"; | |
| 10 | import { mayReadRuns, runForReader } from "../../../actions/runs.ts"; | |
| 11 | import { placementWords } from "../../../checks/placement.ts"; | |
| 1 | import { crewNow } from "../../../actions/crew_now.ts"; | |
| 12 | 2 | import { good, type Tool } from "../shared.ts"; |
| 13 | 3 | |
| 14 | 4 | export const tool: Tool = { |
| @@ -19,87 +9,6 @@ export const tool: Tool = { | ||
| 19 | 9 | inputSchema: { type: "object", properties: {} }, |
| 20 | 10 | writes: false, |
| 21 | 11 | run(ctx, user) { |
| 22 | const { repos } = appsOf(ctx, user); | |
| 23 | const byId = new Map(repos.map((r) => [r.id, r])); | |
| 24 | // A private app's runs are for those who may enter it (`mayReadRuns`). | |
| 25 | // A run on a sealed report is for those who may see the report. | |
| 26 | const runs = listActiveRuns( | |
| 27 | ctx.app.db, | |
| 28 | repos.filter((r) => mayReadRuns(ctx.app, user, r)).map((r) => r.id), | |
| 29 | ).filter((r) => | |
| 30 | mayViewId(ctx.app.db, byId.get(r.repoId)!, r.issueId, user), | |
| 31 | ); | |
| 32 | const actors = getUsersByIds( | |
| 33 | ctx.app.db, | |
| 34 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 35 | ); | |
| 36 | // A planner that declined the direction is the crew waiting on you: | |
| 37 | // it rides the same list, as a row with no run. | |
| 38 | // Owner facts only: a stranger's public app never shows its planner here. | |
| 39 | const owned = ownerFactRepoIds(ctx.app.db, repos, user); | |
| 40 | const waiting = plannerDeclines( | |
| 41 | ctx.app.db, | |
| 42 | repos.filter((r) => owned.has(r.id)), | |
| 43 | ).map((d) => ({ | |
| 44 | run: null, | |
| 45 | app: `${d.repo.ownerName}/${d.repo.name}`, | |
| 46 | who: "the planner", | |
| 47 | on: `#${d.issue.number} ${d.issue.title}`, | |
| 48 | why: "declined", | |
| 49 | state: "waiting for you", | |
| 50 | says: d.words, | |
| 51 | since: d.at, | |
| 52 | costUsd: 0, | |
| 53 | })); | |
| 54 | // An agent's green pull that waits for a person's review (#597): no | |
| 55 | // reviewer on the crew, or one that lapsed — with its age, never silent. | |
| 56 | const ownRepos = repos.filter((r) => owned.has(r.id)); | |
| 57 | const reviewWaits = listOpenAgentPullsVisibleTo(ctx.app.db, user, 200) | |
| 58 | .filter((i) => ownRepos.some((r) => r.id === i.repoId)) | |
| 59 | .flatMap((issue) => { | |
| 60 | const pull = getPull(ctx.app.db, issue.repoId, issue.number); | |
| 61 | const repo = byId.get(issue.repoId); | |
| 62 | const wait = pull && repo ? waitsForPerson(ctx.app.db, repo.id, pull) : undefined; | |
| 63 | return wait === undefined || repo === undefined | |
| 64 | ? [] | |
| 65 | : [ | |
| 66 | { | |
| 67 | run: null, | |
| 68 | app: `${repo.ownerName}/${repo.name}`, | |
| 69 | who: "you", | |
| 70 | on: `#${issue.number} ${issue.title}`, | |
| 71 | why: "review", | |
| 72 | state: "waiting for your review", | |
| 73 | says: wait.why === "lapsed" ? "the reviewer did not answer" : "no reviewer on the crew", | |
| 74 | since: wait.since, | |
| 75 | costUsd: 0, | |
| 76 | }, | |
| 77 | ]; | |
| 78 | }); | |
| 79 | return good([ | |
| 80 | ...waiting, | |
| 81 | ...reviewWaits, | |
| 82 | ...runs.map((run) => { | |
| 83 | const repo = byId.get(run.repoId)!; | |
| 84 | // As this caller may read it (`runForReader`): cost and node are | |
| 85 | // the owner's facts. | |
| 86 | const r = runForReader(ctx.app, user, repo)(run); | |
| 87 | const issue = | |
| 88 | r.issueId === null | |
| 89 | ? undefined | |
| 90 | : getIssueById(ctx.app.db, r.issueId); | |
| 91 | return { | |
| 92 | run: r.id, | |
| 93 | app: `${repo.ownerName}/${repo.name}`, | |
| 94 | who: (r.actorId && actors.get(r.actorId)?.username) || "the crew", | |
| 95 | on: issue === undefined ? null : `#${issue.number} ${issue.title}`, | |
| 96 | why: r.reason, | |
| 97 | state: r.state, | |
| 98 | since: r.startedAt ?? r.createdAt, | |
| 99 | costUsd: r.costUsd, | |
| 100 | ...(placementWords(r) === "" ? {} : { ran: placementWords(r) }), | |
| 101 | }; | |
| 102 | }), | |
| 103 | ]); | |
| 12 | return good(crewNow(ctx, user)); | |
| 104 | 13 | }, |
| 105 | 14 | }; |
test/api-crew-now.test.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * What the crew is doing right now (#7900): GET /api/v1/crew/now and the MCP | |
| 3 | * `now` tool answer the same JSON to the same reader; a run on a sealed | |
| 4 | * report the reader cannot see is absent; no one gets 401. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createRun } from "../src/core/runs.ts"; | |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | describe("the crew's work right now over the API and MCP", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let ada: string; | |
| 29 | let bob: string; | |
| 30 | let openRun: string; | |
| 31 | let sealedRun: string; | |
| 32 | let privateRun: string; | |
| 33 | ||
| 34 | const get = (token?: string) => | |
| 35 | fetch(`${servers.url}/api/v1/crew/now`, { | |
| 36 | headers: token === undefined ? {} : { authorization: `Bearer ${token}` }, | |
| 37 | }); | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dataDir = await mkdtemp(join(tmpdir(), "forge-crew-now-")); | |
| 41 | app = createApp({ dataDir, port: 0 }); | |
| 42 | servers = await serve(app); | |
| 43 | const a = await seedAdmin(app, { username: "ada", email: "[email protected]" }); | |
| 44 | if (!a.ok) throw new Error(a.error); | |
| 45 | const b = await createUser(app.db, { | |
| 46 | origin: "seed", | |
| 47 | trusted: true, | |
| 48 | username: "bob", | |
| 49 | email: "[email protected]", | |
| 50 | password: "a-good-passphrase", | |
| 51 | }); | |
| 52 | if (!b.ok) throw new Error(b.error); | |
| 53 | const repo = await createRepo(app, a.value, { name: "hello", visibility: "public" }); | |
| 54 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 55 | createAppRecord(app.db, { repoId: repo.value.id, slug: "hello", access: "public" }); | |
| 56 | const open = createIssue(app.db, { repoId: repo.value.id, authorId: a.value.id, title: "open work" }); | |
| 57 | const sealed = createIssue(app.db, { | |
| 58 | repoId: repo.value.id, | |
| 59 | authorId: a.value.id, | |
| 60 | title: "hole in the door", | |
| 61 | sealed: true, | |
| 62 | }); | |
| 63 | if (!open.ok || !sealed.ok) throw new Error("issues"); | |
| 64 | openRun = createRun(app.db, { repoId: repo.value.id, kind: "agent", issueId: open.value.id, reason: "work" }).id; | |
| 65 | sealedRun = createRun(app.db, { repoId: repo.value.id, kind: "agent", issueId: sealed.value.id, reason: "work" }).id; | |
| 66 | // A public repository whose app is private: its runs are for those who may enter it. | |
| 67 | const vault = await createRepo(app, a.value, { name: "vault", visibility: "public" }); | |
| 68 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 69 | createAppRecord(app.db, { repoId: vault.value.id, slug: "vault", access: "private" }); | |
| 70 | privateRun = createRun(app.db, { repoId: vault.value.id, kind: "agent", reason: "work" }).id; | |
| 71 | // The planner declined the direction on hello: the owner's fact, a waiting row with no run. | |
| 72 | const direction = createIssue(app.db, { repoId: repo.value.id, authorId: a.value.id, title: "make it rounder" }); | |
| 73 | if (!direction.ok) throw new Error("direction"); | |
| 74 | const declined = createRun(app.db, { | |
| 75 | repoId: repo.value.id, | |
| 76 | kind: "agent", | |
| 77 | issueId: direction.value.id, | |
| 78 | reason: "plan", | |
| 79 | }); | |
| 80 | app.db | |
| 81 | .query("UPDATE run SET state = 'failed', summary = 'declined: too vague' WHERE id = ?1") | |
| 82 | .run(declined.id); | |
| 83 | app.db | |
| 84 | .query("UPDATE repo SET autonomy = 'autonomous', direction_issue_id = ?2 WHERE id = ?1") | |
| 85 | .run(repo.value.id, direction.value.id); | |
| 86 | ada = createToken(app.db, a.value.id, "t", "admin").token; | |
| 87 | bob = createToken(app.db, b.value.id, "t", "ro").token; | |
| 88 | }); | |
| 89 | afterAll(async () => { | |
outdated
Add a run on a private app and assert bob gets no row for it. Add an owner-only row (declined planner or review wait) and assert it appears for the owner and not for bob. Only the sealed-report boundary is proven today. |
||
| 90 | await servers.stop(); | |
| 91 | app.close(); | |
| 92 | await rm(dataDir, { recursive: true, force: true }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("no credentials is 401", async () => { | |
| 96 | expect((await get()).status).toBe(401); | |
| 97 | }); | |
| 98 | ||
| 99 | test("REST and MCP agree, and a stranger does not see the sealed run", async () => { | |
| 100 | for (const token of [ada, bob]) { | |
| 101 | const res = await get(token); | |
| 102 | expect(res.status).toBe(200); | |
| 103 | const rest = ((await res.json()) as any).items as any[]; | |
| 104 | expect(rest).toEqual((await mcpTool(servers.url, token, "now")).value); | |
| 105 | expect(rest.map((r) => r.run)).toContain(openRun); | |
| 106 | } | |
| 107 | const seen = (((await (await get(bob)).json()) as any).items as any[]).map((r) => r.run); | |
| 108 | expect(seen).not.toContain(sealedRun); | |
| 109 | const row = (((await (await get(bob)).json()) as any).items as any[]).find((r) => r.run === openRun); | |
| 110 | expect(row).toMatchObject({ app: "ada/hello", on: "#1 open work", why: "work", state: "queued" }); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a run on a private app is hidden from one who may not enter it", async () => { | |
| 114 | const seen = (((await (await get(bob)).json()) as any).items as any[]).map((r) => r.run); | |
| 115 | expect(seen).not.toContain(privateRun); | |
| 116 | const mine = (((await (await get(ada)).json()) as any).items as any[]).map((r) => r.run); | |
| 117 | expect(mine).toContain(privateRun); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a declined planner waits for the owner, not for a stranger", async () => { | |
| 121 | const waits = async (token: string) => | |
| 122 | (((await (await get(token)).json()) as any).items as any[]).filter((r) => r.run === null); | |
| 123 | expect(await waits(ada)).toMatchObject([ | |
| 124 | { app: "ada/hello", who: "the planner", why: "declined", says: "too vague", state: "waiting for you" }, | |
| 125 | ]); | |
| 126 | expect(await waits(bob)).toEqual([]); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the author of the sealed report sees its run", async () => { | |
| 130 | const rest = ((await (await get(ada)).json()) as any).items as any[]; | |
| 131 | expect(rest.map((r) => r.run)).toContain(sealedRun); | |
| 132 | }); | |
| 133 | ||
| 134 | test("openapi lists the route", async () => { | |
| 135 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 136 | expect(doc.paths["/api/v1/crew/now"].get).toBeDefined(); | |
| 137 | }); | |
| 138 | }); |
new above
offline
Import the sibling as
./runs.ts, not../actions/runs.ts.