Show every member which coordinators are on in a project (#189)
* Show every member which coordinators are on in a project * Coordinators list e2e: check no access with the rival's session
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
7 files changed+50−3
Files (7)
modifiedapps/diffinery/docs/agents/projects.md+2−1View file
| @@ -48,7 +48,7 @@ | ||
| 48 | 48 | |
| 49 | 49 | Open **Your coordinator** from the side of the Project tab. Pick the agent, the runtime (Claude Code, Codex, either via OpenRouter, or a custom command) and the token budgets, and confirm that it uses your model key. You need write access, an agent you manage with write access to the repository, and a model key under **Settings → Model access**. |
| 50 | 50 | |
| 51 | -Each person sets up their own coordinator, and it handles only their posts. Untick **Hand this to my coordinator** to post without it. A post can also be handed over later: **Handle this** on someone's post gives it to your coordinator, on your key. Each post is handled once. | |
| 51 | +Each person sets up their own coordinator, and it handles only their posts. Someone else's coordinator doesn't handle yours, even if you own the repository: to have your posts planned, turn on your own. Everyone with write access sees whose coordinators are on under **Other coordinators here** on the side of the Project tab, with each one's agent and model, but never their budgets, command or key. Untick **Hand this to my coordinator** to post without it. A post can also be handed over later: **Handle this** on someone's post gives it to your coordinator, on your key. Each post is handled once. | |
| 52 | 52 | |
| 53 | 53 | ### Limits |
| 54 | 54 | |
| @@ -112,6 +112,7 @@ | ||
| 112 | 112 | | `PUT .../diffinery/project/memory` | Save `{ "body": "…" }`, or restore `{ "restore": 4 }` | Write access; browser session only | |
| 113 | 113 | | `PUT .../diffinery/project/settings` | Set `{ "max_running": 2, "instructions": "…", "plan_first": "default" }` (`plan_first` is `off`, `default` or `required`) | Admin; browser session only | |
| 114 | 114 | | `GET .../diffinery/project/coordinator` | Your coordinator's settings | Write access; any token | |
| 115 | +| `GET .../diffinery/project/coordinators` | Everyone's coordinator here: `person`, `agent`, `runtime`, `provider`, `model`, `opted_in_at` | Write access; any token | | |
| 115 | 116 | | `PUT .../diffinery/project/coordinator` | Turn yours on or change it: `{ "agent": "my-bot", "runtime": "claude-code", "tokens_per_run": 1000000, "tokens_per_task": 2000000, "accept": true }` | Write access; browser session only | |
| 116 | 117 | | `DELETE .../diffinery/project/coordinator` | Turn yours off | Write access; browser session only | |
| 117 | 118 | | `POST .../diffinery/project/posts/{id}/handle` | Hand a post to your coordinator | Write access; browser session only | |
modifiedapps/diffinery/src/agentic/ProjectDO.ts+9−0View file
| @@ -513,6 +513,15 @@ | ||
| 513 | 513 | return row ? (JSON.parse(row.json) as CoordinatorSettings) : null; |
| 514 | 514 | } |
| 515 | 515 | |
| 516 | + /** Every person's coordinator here, oldest first. */ | |
| 517 | + coordinators(): CoordinatorSettings[] { | |
| 518 | + return this.sql | |
| 519 | + .exec<{ json: string }>("SELECT json FROM coordinators") | |
| 520 | + .toArray() | |
| 521 | + .map((r) => JSON.parse(r.json) as CoordinatorSettings) | |
| 522 | + .sort((a, b) => a.optedInAt.localeCompare(b.optedInAt)); | |
| 523 | + } | |
| 524 | + | |
| 516 | 525 | /** Turning a coordinator off stops its run in progress; tasks it started keep running. */ |
| 517 | 526 | async setCoordinator(personId: string, settings: CoordinatorSettings | null): Promise<void> { |
| 518 | 527 | if (settings) return void this.sql.exec("INSERT OR REPLACE INTO coordinators (person_id, json) VALUES (?, ?)", personId, JSON.stringify(settings)); |
modifiedapps/diffinery/src/agentic/api.ts+7−1View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | import { gatherToday, todayJson } from "./today.js"; |
| 10 | 10 | import { orgUsageReport, orgUsageReportJson, usageDays, usageReport, usageReportJson } from "./ledger.js"; |
| 11 | 11 | import { orgPolicy, orgSpend, parsePolicy, policyJson, savePolicy, spendMonth, type PolicyInput } from "./org-policy.js"; |
| 12 | -import { boardJson, coordinatorJson, handlePost, splitPlan, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js"; | |
| 12 | +import { boardJson, coordinatorJson, coordinatorSummaryJson, handlePost, splitPlan, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js"; | |
| 13 | 13 | |
| 14 | 14 | async function jsonBody<T>(c: AppContext): Promise<T> { |
| 15 | 15 | try { |
| @@ -298,6 +298,12 @@ | ||
| 298 | 298 | return c.json(coordinatorJson(await projectStub(c.env, l.repo.id).coordinator(me.id))); |
| 299 | 299 | }); |
| 300 | 300 | |
| 301 | +agenticApi.get("/repos/:owner/:repo/diffinery/project/coordinators", async (c) => { | |
| 302 | + const l = await repoOf(c); | |
| 303 | + requireTaskReader(l); | |
| 304 | + return c.json((await projectStub(c.env, l.repo.id).coordinators()).map(coordinatorSummaryJson)); | |
| 305 | +}); | |
| 306 | + | |
| 301 | 307 | agenticApi.put("/repos/:owner/:repo/diffinery/project/coordinator", async (c) => { |
| 302 | 308 | const l = await repoOf(c); |
| 303 | 309 | requireTaskReader(l); |
modifiedapps/diffinery/src/agentic/project-web.tsx+14−1View file
| @@ -167,7 +167,9 @@ | ||
| 167 | 167 | const tk = csrfToken(c); |
| 168 | 168 | const me = requireAccount(c); |
| 169 | 169 | const stub = projectStub(c.env, l.repo.id); |
| 170 | - const [board, posts, mine] = await Promise.all([loadBoard(c, l), stub.posts(0, POSTS_SHOWN), stub.coordinator(me.id)]); | |
| 170 | + const [board, posts, coordinators] = await Promise.all([loadBoard(c, l), stub.posts(0, POSTS_SHOWN), stub.coordinators()]); | |
| 171 | + const mine = coordinators.find((x) => x.person.id === me.id) ?? null; | |
| 172 | + const others = coordinators.filter((x) => x !== mine); | |
| 171 | 173 | // The memory changes coordinators made, with the version before each, for their diffs. |
| 172 | 174 | const changes = new Map<number, MemoryChange>(); |
| 173 | 175 | for (const p of posts.filter((x) => x.memory_version).slice(-10)) { |
| @@ -258,6 +260,17 @@ | ||
| 258 | 260 | <p class="muted small">An agent that reads your posts, splits the work into tasks, and keeps the memory, on your own model key.</p> |
| 259 | 261 | )} |
| 260 | 262 | </section> |
| 263 | + {others.length ? ( | |
| 264 | + <section id="coordinators"> | |
| 265 | + <h3>Other coordinators here</h3> | |
| 266 | + {others.map((x) => ( | |
| 267 | + <p class="muted small"> | |
| 268 | + {x.agent.login} for <b>{x.person.login}</b>, with {runtimeLabel(x.runtime, x)}, since <Time at={x.optedInAt} />. | |
| 269 | + </p> | |
| 270 | + ))} | |
| 271 | + <p class="faint small">Each handles only its person's posts, and never spends your key.</p> | |
| 272 | + </section> | |
| 273 | + ) : null} | |
| 261 | 274 | <section> |
| 262 | 275 | <div class="row"> |
| 263 | 276 | <h3 class="grow">Memory</h3> |
modifiedapps/diffinery/src/agentic/projects.ts+3−0View file
| @@ -235,6 +235,9 @@ | ||
| 235 | 235 | export const coordinatorJson = (s: CoordinatorSettings | null) => |
| 236 | 236 | s ? { enabled: true, agent: { login: s.agent.login }, runtime: s.runtime, command: s.command, provider: s.via, model: s.model, tokens_per_run: s.tokensPerRun, tokens_per_task: s.tokensPerTask, opted_in_at: s.optedInAt, updated_at: s.updatedAt } : { enabled: false }; |
| 237 | 237 | |
| 238 | +/** Someone's coordinator as every member sees it: whose it is, its agent and model. Never its budgets or custom command. */ | |
| 239 | +export const coordinatorSummaryJson = (s: CoordinatorSettings) => ({ person: { login: s.person.login }, agent: { login: s.agent.login }, runtime: s.runtime, provider: s.via, model: s.model, opted_in_at: s.optedInAt }); | |
| 240 | + | |
| 238 | 241 | export const memoryJson = (m: MemoryRow | null) => (m ? { body: m.body, version: m.n, updated_by: { login: m.by_login, type: m.by_kind === "person" ? "User" : "Bot" }, updated_at: m.at } : { body: "", version: null, updated_by: null, updated_at: null }); |
| 239 | 242 | |
| 240 | 243 | export const settingsJson = (s: ProjectSettings) => ({ max_running: s.maxRunning, ...(s.orgMaxRunning !== null ? { org_max_running: s.orgMaxRunning } : {}), instructions: s.instructions, plan_first: s.planFirst }); |
modifiedapps/diffinery/src/docs/api-meta.ts+5−0View file
| @@ -1798,6 +1798,11 @@ | ||
| 1798 | 1798 | description: "Your coordinator's settings in this project: `enabled`, `agent`, `runtime`, `command`, `provider`, `model`, `tokens_per_run`, `tokens_per_task`, `opted_in_at`, `updated_at`. Needs write access.", |
| 1799 | 1799 | access: "write", |
| 1800 | 1800 | }, |
| 1801 | + "GET /repos/{owner}/{repo}/diffinery/project/coordinators": { | |
| 1802 | + summary: "List the coordinators in a project", | |
| 1803 | + description: "Every person's coordinator in this project, oldest first: `person`, `agent`, `runtime`, `provider`, `model`, `opted_in_at`. Never their token budgets or custom command. Needs write access. See /docs/agents/projects#coordinator.", | |
| 1804 | + access: "write", | |
| 1805 | + }, | |
| 1801 | 1806 | "PUT /repos/{owner}/{repo}/diffinery/project/coordinator": { |
| 1802 | 1807 | summary: "Turn on or change your coordinator", |
| 1803 | 1808 | description: "Your agent then reads your posts and turns them into tasks, on your model key. Each coordinator starts at most 5 tasks per post and 20 per 24 hours in the project. A person in a browser session only. Audited as `project.coordinator`. See /docs/agents/projects#coordinator.", |
modifiedapps/diffinery/test/e2e/tasks.test.mjs+10−0View file
| @@ -541,6 +541,15 @@ | ||
| 541 | 541 | assert.equal(saved.json.agent.login, "task-bot"); |
| 542 | 542 | assert.equal(saved.json.tokens_per_task, 150000); |
| 543 | 543 | assert.equal(saved.json.tokens_per_run, 1000000); |
| 544 | + // Everyone with write access sees whose coordinators are on, and with which agent and model; never their budgets or command. | |
| 545 | + const viewer = (await asPerson("POST", "/diffinery/agents/task-bot/tokens", { name: "coordinators" })).json.token; | |
| 546 | + const listed = await api("GET", `${P}/coordinators`, undefined, viewer); | |
| 547 | + assert.equal(listed.status, 200, JSON.stringify(listed.json)); | |
| 548 | + assert.equal(listed.json.length, 1, JSON.stringify(listed.json)); | |
| 549 | + assert.deepEqual(listed.json[0], { person: { login: owner }, agent: { login: "task-bot" }, runtime: "custom", provider: null, model: null, opted_in_at: listed.json[0].opted_in_at }); | |
| 550 | + // Without write access (the rival, signed in in a browser), the list doesn't exist. | |
| 551 | + const rivalList = await fetch(`${base}/api/v3${P}/coordinators`, { headers: { cookie: `__Host-dfy_session=${RIVAL_SESSION}` } }); | |
| 552 | + assert.equal(rivalList.status, 404, "people without write access see nothing"); | |
| 544 | 553 | |
| 545 | 554 | const coordinatorRun = async (post) => |
| 546 | 555 | until(`the coordinator to handle post #${post}`, async () => { |
| @@ -656,6 +665,7 @@ | ||
| 656 | 665 | assert.match(page, /The change to the memory/); |
| 657 | 666 | assert.match(page, /<tr class="add"><td class="ln">\+<\/td><td class="code">MEMORY-BY-COORD/); |
| 658 | 667 | assert.match(page, /Hand this to my coordinator/); |
| 668 | + assert.doesNotMatch(page, /Other coordinators here/, "your own coordinator isn't listed again"); | |
| 659 | 669 | assert.equal((await fetch(`${base}/${owner}/tasks-demo/project/coordinator`)).status, 200); |
| 660 | 670 | const taskPage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${a}`)).text(); |
| 661 | 671 | assert.match(taskPage, /coordinator started this task while handling/); |