Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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
test-bot authored 1 parent 1e9b201commit b79522c9667f13a86441e4f02ce583bd74559fbeBrowse files
7 files changed+50−3
Files (7)
modifiedapps/diffinery/docs/agents/projects.md+2−1View file
@@ -48,7 +48,7 @@
4848
4949 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**.
5050
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.
5252
5353 ### Limits
5454
@@ -112,6 +112,7 @@
112112 | `PUT .../diffinery/project/memory` | Save `{ "body": "…" }`, or restore `{ "restore": 4 }` | Write access; browser session only |
113113 | `PUT .../diffinery/project/settings` | Set `{ "max_running": 2, "instructions": "…", "plan_first": "default" }` (`plan_first` is `off`, `default` or `required`) | Admin; browser session only |
114114 | `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 |
115116 | `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 |
116117 | `DELETE .../diffinery/project/coordinator` | Turn yours off | Write access; browser session only |
117118 | `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 @@
513513 return row ? (JSON.parse(row.json) as CoordinatorSettings) : null;
514514 }
515515
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+
516525 /** Turning a coordinator off stops its run in progress; tasks it started keep running. */
517526 async setCoordinator(personId: string, settings: CoordinatorSettings | null): Promise<void> {
518527 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 @@
99 import { gatherToday, todayJson } from "./today.js";
1010 import { orgUsageReport, orgUsageReportJson, usageDays, usageReport, usageReportJson } from "./ledger.js";
1111 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";
1313
1414 async function jsonBody<T>(c: AppContext): Promise<T> {
1515 try {
@@ -298,6 +298,12 @@
298298 return c.json(coordinatorJson(await projectStub(c.env, l.repo.id).coordinator(me.id)));
299299 });
300300
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+
301307 agenticApi.put("/repos/:owner/:repo/diffinery/project/coordinator", async (c) => {
302308 const l = await repoOf(c);
303309 requireTaskReader(l);
modifiedapps/diffinery/src/agentic/project-web.tsx+14−1View file
@@ -167,7 +167,9 @@
167167 const tk = csrfToken(c);
168168 const me = requireAccount(c);
169169 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);
171173 // The memory changes coordinators made, with the version before each, for their diffs.
172174 const changes = new Map<number, MemoryChange>();
173175 for (const p of posts.filter((x) => x.memory_version).slice(-10)) {
@@ -258,6 +260,17 @@
258260 <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>
259261 )}
260262 </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}
261274 <section>
262275 <div class="row">
263276 <h3 class="grow">Memory</h3>
modifiedapps/diffinery/src/agentic/projects.ts+3−0View file
@@ -235,6 +235,9 @@
235235 export const coordinatorJson = (s: CoordinatorSettings | null) =>
236236 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 };
237237
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+
238241 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 });
239242
240243 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 @@
17981798 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.",
17991799 access: "write",
18001800 },
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+ },
18011806 "PUT /repos/{owner}/{repo}/diffinery/project/coordinator": {
18021807 summary: "Turn on or change your coordinator",
18031808 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 @@
541541 assert.equal(saved.json.agent.login, "task-bot");
542542 assert.equal(saved.json.tokens_per_task, 150000);
543543 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");
544553
545554 const coordinatorRun = async (post) =>
546555 until(`the coordinator to handle post #${post}`, async () => {
@@ -656,6 +665,7 @@
656665 assert.match(page, /The change to the memory/);
657666 assert.match(page, /<tr class="add"><td class="ln">\+<\/td><td class="code">MEMORY-BY-COORD/);
658667 assert.match(page, /Hand this to my coordinator/);
668+ assert.doesNotMatch(page, /Other coordinators here/, "your own coordinator isn't listed again");
659669 assert.equal((await fetch(`${base}/${owner}/tasks-demo/project/coordinator`)).status, 200);
660670 const taskPage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${a}`)).text();
661671 assert.match(taskPage, /coordinator started this task while handling/);