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

Merged opened this

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

Merged

torque/forge:torque/7900-get-api-v1-crew-now-answers-what-the-cre → main 4 commits fork 2 threads · 2 unresolved

  • Merged. torque/7900-get-api-v1-crew-now-answers-what-the-cre was merged into main as 6075feb.

Review

Sign in to review this change.
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";
outdated

Import the sibling as ./runs.ts, not ../actions/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";
4747 export { route as getApiV1BoardsSlugPanelsId } from "./getApiV1BoardsSlugPanelsId.ts";
4848 export { route as getApiV1BoardsSources } from "./getApiV1BoardsSources.ts";
4949 export { route as getApiV1Contribute } from "./getApiV1Contribute.ts";
50 export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts";
5051 export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts";
5152 export { route as getApiV1Explore } from "./getApiV1Explore.ts";
5253 export { route as getApiV1Family } from "./getApiV1Family.ts";
src/http/api.ts +15
@@ -30,6 +30,7 @@ import { DOMAINS } from "../kernel/domains.ts";
3030 import { readBounded } from "./limits.ts";
3131 import { askCheck } from "../checks/again.ts";
3232 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
33 import { crewNow } from "../actions/crew_now.ts";
3334 import { readScorecards } from "../actions/scorecard.ts";
3435 import {
3536 accountSignIns,
@@ -1175,6 +1176,20 @@ export function apiRoutes(router: Router<Handler>): void {
11751176 return timePhase(ctx.request, "render", () => json(card));
11761177 });
11771178
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
11781193 // The token the forge's agent acts with in one conversation (#7648): only
11791194 // the person's own browser session mints it, same-origin, for themself.
11801195 // 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";
122 import { good, type Tool } from "../shared.ts";
133
144 export const tool: Tool = {
@@ -19,87 +9,6 @@ export const tool: Tool = {
199 inputSchema: { type: "object", properties: {} },
2010 writes: false,
2111 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));
10413 },
10514 };
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs