paseo-follow-up 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Follow-up keeps PR stacks, remaining tasks and explicit agent completion attached to a Paseo workspace. It groups workspaces by whether you or an agent can act next and retains settled PR history.
4
4
 
5
- Requires Paseo 0.14+, Node 22+ and authenticated gh on the daemon host. Graphite CLI authentication is used for readiness and explicit Merge. The optional Send to Paseo browser companion enables Graphite's Merge when ready setting; it is not needed for tracking, recovery or follow-up.
5
+ Requires Paseo 0.14+, Node 22+ and authenticated gh on the daemon host. Graphite CLI authentication is used for readiness and explicit Merge. The optional Send to Paseo browser companion enables Graphite's Merge when ready setting after separate machine/repository consent in Chrome; it is not needed for tracking, recovery or follow-up.
6
6
 
7
7
  Open the sidebar or Command-K, then Track this workspace. Project settings can enroll existing and new workspaces with original main-agent ownership; Auto always starts off. The retained development default enables canonical Git repositories named hero-poc, while other projects require opt-in. Disable that project default to stop further enrollment without losing records.
8
8
 
@@ -12,4 +12,4 @@ Repair batches go to the original main agent after a click, or after explicit pe
12
12
 
13
13
  This trusted unsandboxed plugin reads selected workspace metadata, saved agent history/configuration, GitHub status and existing CLI authentication. State and private recovery evidence remain in plugin-data/follow-through. Scoped MCP registration and reversible provider hooks support tracked main agents; Codex's trust review remains in force. Prompts and repair context are sent to your configured agent/provider. Site-specific reviewer and Cloud Run adapters only apply to the configured repository; unsupported deployments remain unknown.
14
14
 
15
- Merge, Merge when ready and reviewer requests require explicit developer controls. Auto and agent tools cannot authorize them. Follow-up has no Slack posting identity or background channel scanner. Stakeholder replies require the developer's authorization, original destination and a verified sent-message receipt from the agent.
15
+ Merge, Merge when ready and reviewer requests require explicit developer controls. Optional direct browser grants trust that machine to change the PR toggle for the allowed repository, including later pushes; read-only grants require confirmation in Chrome. Auto and agent tools cannot authorize them. Follow-up has no Slack posting identity or background channel scanner. Stakeholder replies require the developer's authorization, original destination and a verified sent-message receipt from the agent.
package/README.md CHANGED
@@ -40,6 +40,22 @@ Tasks default to manual. Explicit Now, After merge or After deploy triggers can
40
40
 
41
41
  ![Follow-up overview with sample workspaces](docs/screenshots/follow-up-overview.png)
42
42
 
43
+ ## Send to Paseo and Graphite
44
+
45
+ With Send to Paseo installed on the same daemon, its composer can return to the agent that
46
+ registered a PR here. New registrations record that agent; older records retain the workspace’s
47
+ main agent. You can still choose a fresh agent in Send to Paseo. Tracking and follow-up remain
48
+ usable without the companion.
49
+
50
+ To control Graphite’s **Merge when ready** here, install the Send to Paseo plugin and Chrome
51
+ extension, pair them, and keep Graphite open in Chrome. In extension settings → **Graphite browser
52
+ access**, find this machine and allow an exact repository. Check **Allow Follow Up to change Merge
53
+ when ready directly** to use this plugin’s toggle without another Chrome prompt. Leave it unchecked
54
+ to confirm each change in Chrome. Revoke access in those settings at any time.
55
+
56
+ This is a PR setting, including later pushes. Send pairing alone grants no Graphite access.
57
+ See the [integration and security contract](https://github.com/tomgrin10/send-to-paseo/blob/main/docs/follow-up-integration.md).
58
+
43
59
  ## Data and permissions
44
60
 
45
61
  This is a trusted, unsandboxed plugin. It reads selected workspace metadata, agent history/configuration and GitHub PR status using your existing host credentials. Local state and private recovery evidence live under `$PASEO_HOME/plugin-data/follow-through`; credentials and provider configuration are not published or transferred.
package/client/panel.tsx CHANGED
@@ -55,7 +55,7 @@ export function WorkspaceView({ theme, layout, workspace, navigation, loadError
55
55
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "600", flex: 1 }}>Follow-up</Text><Pressable accessibilityRole="switch" aria-checked={workspace.enable_auto_sends} accessibilityLabel="Auto" accessibilityHint="Allow automatic sends in this workspace" accessibilityState={{ checked: workspace.enable_auto_sends, disabled: change.isPending }} disabled={change.isPending} onPress={() => change.mutate({ workspaceId: workspace.id, enable_auto_sends: !workspace.enable_auto_sends })} style={{ minHeight: compact ? 44 : 36, paddingHorizontal: 10, borderRadius: 6, borderWidth: 1, borderColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.border, backgroundColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.surface0, flexDirection: "row", alignItems: "center", gap: 5, opacity: change.isPending ? 0.5 : 1 }}><Icon name={workspace.enable_auto_sends ? "Check" : "Circle"} size={13} color={workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foregroundMuted} /><Text style={{ fontSize: 13, color: workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foreground }}>Auto</Text></Pressable><Button title={update.isPending ? "Refreshing…" : "Refresh"} icon="RefreshCw" theme={theme} compact={compact} disabled={update.isPending} onPress={() => update.mutate()} /></View>
56
56
  <View style={{ marginTop: 8, gap: 6 }}><StatusLabel workspace={workspace} theme={theme} />{(state.status === "needs-you" || state.status === "done") && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{state.reason}</Text>}{workspace.hold && <Button title="Resume follow-up" theme={theme} compact={compact} onPress={() => change.mutate({ workspaceId: workspace.id, clearHold: true })} disabled={change.isPending} />}</View>
57
57
  {error && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger, fontSize: 12, marginTop: 8 }}>{error}</Text>}
58
- <Section title="Pull requests" count={workspace.prs.length} theme={theme} compact={compact}>{workspace.prs.length === 0 && <Text style={labelStyle}>No PRs recorded.</Text>}{workspace.prActions.filter(a => ["sending", "uncertain"].includes(a.state)).map(a => <View key={a.id} style={{ gap: 7 }}><Text style={{ ...labelStyle, color: theme.colors.statusWarning, lineHeight: 18 }}>{a.result}</Text>{a.state === "uncertain" && <Button title={inspectAction.isPending ? "Checking…" : "Check state"} theme={theme} compact={compact} disabled={inspectAction.isPending} onPress={() => inspectAction.mutate(a.id)} />}</View>)}{inspectAction.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusDanger }}>{inspectAction.error.message}</Text>}{activeStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}{settledStacks.length > 0 && <Section title="Settled" count={settledStacks.reduce((count, stack) => count + stack.prs.length, 0)} theme={theme} compact={compact} initiallyExpanded={false}>{settledStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}</Section>}{activeStacks.length > 0 && browser.data && !browser.data.ready && <Section title="Optional merge when ready" theme={theme} compact={compact} initiallyExpanded={false}><Text style={[labelStyle, { lineHeight: 18 }]}>Connect Chrome for Graphite’s When ready toggle.</Text><Text style={[labelStyle, { lineHeight: 18 }]}>Requires the paired Send to Paseo extension and an open Graphite tab.</Text><Button title="Connect Graphite" theme={theme} compact={compact} onPress={() => open("https://app.graphite.com")} /></Section>}</Section>
58
+ <Section title="Pull requests" count={workspace.prs.length} theme={theme} compact={compact}>{workspace.prs.length === 0 && <Text style={labelStyle}>No PRs recorded.</Text>}{workspace.prActions.filter(a => ["sending", "uncertain"].includes(a.state)).map(a => <View key={a.id} style={{ gap: 7 }}><Text style={{ ...labelStyle, color: theme.colors.statusWarning, lineHeight: 18 }}>{a.result}</Text>{a.state === "uncertain" && <Button title={inspectAction.isPending ? "Checking…" : "Check state"} theme={theme} compact={compact} disabled={inspectAction.isPending} onPress={() => inspectAction.mutate(a.id)} />}</View>)}{inspectAction.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusDanger }}>{inspectAction.error.message}</Text>}{activeStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}{settledStacks.length > 0 && <Section title="Settled" count={settledStacks.reduce((count, stack) => count + stack.prs.length, 0)} theme={theme} compact={compact} initiallyExpanded={false}>{settledStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}</Section>}{activeStacks.length > 0 && browser.data && !browser.data.ready && <Section title="Optional merge when ready" theme={theme} compact={compact} initiallyExpanded={false}><Text style={[labelStyle, { lineHeight: 18 }]}>Connect Chrome for Graphite’s When ready toggle.</Text><Text style={[labelStyle, { lineHeight: 18 }]}>Open Graphite in Chrome, then allow this machine and repository in Send to Paseo extension settings. Each toggle also needs confirmation in Chrome.</Text><Button title="Connect Graphite" theme={theme} compact={compact} onPress={() => open("https://app.graphite.com")} /></Section>}</Section>
59
59
  <Section title="Tasks" count={outstanding.length} theme={theme} compact={compact}>
60
60
  {!outstanding.length && <Text style={labelStyle}>No follow-through tasks.</Text>}
61
61
  {outstanding.map(o => <FollowTask key={o.id} obligation={o} workspace={workspace} theme={theme} compact={compact} />)}
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from "react-native";
3
3
  import { useRpc } from "@getpaseo/plugin/client";
4
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { confirmPrActionRpc, preparePrActionRpc, prControlStatusRpc, requestReviewersRpc } from "../shared/contracts";
6
- import type { ActionPlan, PrActionKind } from "../shared/pr-actions";
6
+ import { prActionBlocks, type ActionPlan, type PrActionKind } from "../shared/pr-actions";
7
7
  import type { Pr, Workspace } from "../shared/model";
8
8
  import { Button, type Theme } from "./components";
9
9
  import { defaultReviewers, noReviewers } from "../shared/reviewers";
@@ -21,7 +21,7 @@ export function PrControls({ pr, workspace, theme, compact, browserReady }: { br
21
21
  if (result.kind !== "merge" && result.prs.length === 1 && !result.blockers.length && !result.prs[0].blockers.length) submit.mutate({ planId: result.id, scope: "target" });
22
22
  else setPlan(result);
23
23
  } });
24
- const pendingAction = workspace.prActions.some(a => ["sending", "uncertain"].includes(a.state));
24
+ const pendingAction = workspace.prActions.some(a => prActionBlocks(a, [pr.key]));
25
25
  const busy = checking.isPending || submit.isPending;
26
26
  const actual = browserReady ? status.data?.pr : null;
27
27
  const known = !!actual && !status.error && !status.data?.error;
package/index.server.ts CHANGED
@@ -4,6 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import * as rpc from "./shared/contracts";
7
+ import { prOwner } from "./server/pr-owner";
7
8
  import { type Workspace } from "./shared/model";
8
9
  import { Store } from "./server/store";
9
10
  import { Engine, agentWorkspace, findWorkspace, interruptionHold } from "./server/engine";
@@ -93,6 +94,14 @@ export default function contribute(server: PluginServerContext) {
93
94
  void engine.tick();
94
95
  return { projects: await gateway.projects(await store.snapshot()) };
95
96
  });
97
+ server.handle(rpc.prOwnerRpc, async ({ repo, number }, { paseo }) => {
98
+ await requireReady();
99
+ const owner = prOwner(await store.snapshot(), repo, number);
100
+ if (!owner) return { owner: null };
101
+ const agent = (await paseo.agents.ref(owner.agentId).refresh())?.agent;
102
+ if (!agent || agent.archivedAt || agent.workspaceId !== owner.workspaceId) return { owner: null };
103
+ return { owner };
104
+ });
96
105
  server.handle(rpc.listRpc, async () => { await ready; return { workspaces: (await store.snapshot()).workspaces.filter(w => w.enable_tracking), error: loadError ?? hookError ?? engine.error }; });
97
106
  server.handle(rpc.getRpc, async ({ workspaceId }) => { await ready; return { workspace: (await store.snapshot()).workspaces.find(w => w.id === workspaceId && w.enable_tracking) ?? null, error: loadError ?? hookError ?? engine.error }; });
98
107
  server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
@@ -145,8 +154,8 @@ export default function contribute(server: PluginServerContext) {
145
154
  seen.add(ancestor); ancestor = w.prs.find(p => p.key === ancestor)?.parent ?? null;
146
155
  }
147
156
  const altered = !existing || existing.stack !== stack || existing.parent !== parent || disposition !== undefined && existing.disposition !== disposition;
148
- if (existing) { existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
149
- else w.prs.push({ key, repo, number, stack, parent, disposition: disposition ?? null, snapshot: null, error: null });
157
+ if (existing) { existing.registeredAgentId = agentId; existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
158
+ else w.prs.push({ registeredAgentId: agentId, key, repo, number, stack, parent, disposition: disposition ?? null, snapshot: null, error: null });
150
159
  if (altered) changed(w); return w;
151
160
  });
152
161
  void engine.tick(); return result;
@@ -235,7 +244,7 @@ export default function contribute(server: PluginServerContext) {
235
244
  void engine.tick(); return result;
236
245
  });
237
246
  server.handle(rpc.browserStatusRpc, async () => { await requireReady(); return browser.status(); });
238
- server.handle(rpc.browserExchangeRpc, async ({ clientId, ready: browserReady, acceptJobs, results }) => { await requireReady(); return { jobs: browser.exchange(clientId, browserReady, results, acceptJobs), accepted: results.map(r => r.id) }; });
247
+ server.handle(rpc.browserExchangeRpc, async ({ clientId, ready: browserReady, acceptJobs, results, repositories }) => { await requireReady(); return browser.exchange(clientId, browserReady, results, acceptJobs, repositories); });
239
248
  server.handle(rpc.prControlStatusRpc, async ({ workspaceId, prKey }) => { await requireReady(); return actions.status(workspaceId, prKey); });
240
249
  server.handle(rpc.preparePrActionRpc, async ({ workspaceId, prKey, kind }) => { await requireReady(); return actions.prepare(workspaceId, prKey, kind); });
241
250
  server.handle(rpc.confirmPrActionRpc, async ({ planId, scope }) => { await requireReady(); const result = await actions.confirm(planId, scope); void engine.tick(); return result; });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-follow-up",
3
3
  "private": false,
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "Track workspace pull requests, queue follow-up work, and verify completion in Paseo.",
6
6
  "type": "module",
7
7
  "files": [
@@ -3,46 +3,59 @@ import { browserCommandSchema, type BrowserCommand, type BrowserPr, type Browser
3
3
 
4
4
  type Pending = { command: BrowserCommand; clientId: string | null; timer: ReturnType<typeof setTimeout>; resolve: (r: BrowserResult) => void };
5
5
  export class BrowserBridge {
6
- private clients = new Map<string, { at: number; ready: boolean }>();
6
+ private clients = new Map<string, { at: number; ready: boolean; repositories: string[] }>();
7
7
  private pending = new Map<string, Pending>();
8
8
  private observations = new Map<string, { at: number; pr: BrowserPr }>();
9
- status() {
10
- const ready = [...this.clients.values()].some(c => c.ready && Date.now() - c.at < 15_000);
11
- return { ready, reason: ready ? null : "Open Graphite in Chrome with the updated Send to Paseo extension to connect merge controls." };
9
+ status(repo?: string) {
10
+ const ready = [...this.clients.values()].some(c => c.ready && c.repositories.length > 0 && (!repo || c.repositories.includes(repo.toLowerCase())) && Date.now() - c.at < 15_000);
11
+ return { ready, reason: ready ? null : "Open Graphite in Chrome and grant this machine access to the repository in Send to Paseo settings." };
12
12
  }
13
13
  cached(repo: string, number: number, head: string): BrowserPr | null {
14
14
  const saved = this.observations.get(`${repo}#${number}`);
15
15
  return saved && Date.now() - saved.at < 15_000 && saved.pr.head === head ? saved.pr : null;
16
16
  }
17
- exchange(clientId: string, ready: boolean, results: BrowserResult[], acceptJobs = true): BrowserCommand[] {
18
- this.clients.set(clientId, { at: Date.now(), ready });
17
+ exchange(clientId: string, ready: boolean, results: BrowserResult[], acceptJobs = true, repositories: string[] = []): { jobs: BrowserCommand[]; accepted: string[] } {
18
+ const accepted: string[] = [];
19
+ for (const [id, client] of this.clients) if (Date.now() - client.at > 60_000) this.clients.delete(id);
20
+ if (!this.clients.has(clientId) && this.clients.size >= 100) return { jobs: [], accepted };
21
+ const scopes = repositories.map(r => r.toLowerCase());
22
+ this.clients.set(clientId, { at: Date.now(), ready, repositories: scopes });
19
23
  for (const result of results) {
20
24
  const job = this.pending.get(result.id);
21
- if (!job || job.clientId !== clientId || job.command.nonce !== result.nonce) continue;
25
+ if (!job || job.clientId !== clientId || job.command.nonce !== result.nonce || Date.parse(job.command.expiresAt) <= Date.now()) continue;
26
+ if (!scopes.includes(job.command.repo.toLowerCase()) && !(result.outcome === "failed" && !result.prs.length && !result.applied.length)) continue;
27
+ if (new Set(result.prs.map(p => p.number)).size !== result.prs.length || new Set(result.applied).size !== result.applied.length) continue;
28
+ if (result.outcome === "completed" && (result.prs.length !== job.command.prs.length || result.prs.some(p => {
29
+ const target = job.command.prs.find(t => t.number === p.number);
30
+ return !target || p.head !== target.head || job.command.op !== "read" && (p.state !== "OPEN" || p.mergeWhenReady !== (job.command.op === "enable_mwr"));
31
+ }) || job.command.op !== "read" && result.applied.length !== job.command.prs.length)) continue;
22
32
  if (result.prs.some(p => p.repo !== job.command.repo || !job.command.prs.some(t => t.number === p.number))) continue;
23
33
  if (result.applied.some(n => !job.command.prs.some(p => p.number === n))) continue;
24
34
  clearTimeout(job.timer); this.pending.delete(result.id);
25
35
  for (const pr of result.prs) this.observations.set(`${pr.repo}#${pr.number}`, { at: Date.now(), pr });
26
- job.resolve(result);
36
+ while (this.observations.size > 1000) this.observations.delete(this.observations.keys().next().value!);
37
+ job.resolve(result); accepted.push(result.id);
27
38
  }
28
- if (!ready || !acceptJobs) return [];
29
- const jobs = [...this.pending.values()].filter(p => !p.clientId && Date.parse(p.command.expiresAt) > Date.now()).slice(0, 1);
39
+ if (!ready || !acceptJobs) return { jobs: [], accepted };
40
+ const jobs = [...this.pending.values()].filter(p => !p.clientId && scopes.includes(p.command.repo.toLowerCase()) && Date.parse(p.command.expiresAt) > Date.now()).slice(0, 1);
30
41
  for (const job of jobs) job.clientId = clientId;
31
- return jobs.map(j => j.command);
42
+ return { jobs: jobs.map(j => j.command), accepted };
32
43
  }
33
44
  request(op: BrowserCommand["op"], repo: string, prs: BrowserCommand["prs"]): Promise<BrowserResult> {
34
- if (!this.status().ready) return Promise.reject(new Error(this.status().reason!));
35
- const command = browserCommandSchema.parse({ id: randomUUID(), nonce: randomUUID(), expiresAt: new Date(Date.now() + 20_000).toISOString(), op, repo, prs });
45
+ if (!this.status(repo).ready) return Promise.reject(new Error(this.status(repo).reason!));
46
+ if (this.pending.size >= 50) return Promise.reject(new Error("Too many browser requests are pending."));
47
+ const budget = op === "read" ? 20_000 : 120_000;
48
+ const command = browserCommandSchema.parse({ id: randomUUID(), nonce: randomUUID(), expiresAt: new Date(Date.now() + budget).toISOString(), op, repo, prs });
36
49
  return new Promise(resolve => {
37
50
  const timer = setTimeout(() => {
38
51
  const leased = !!this.pending.get(command.id)?.clientId; this.pending.delete(command.id);
39
52
  resolve({ id: command.id, nonce: command.nonce, outcome: op !== "read" && leased ? "uncertain" : "failed", prs: [], applied: [], error: leased ? "Graphite did not confirm the result. Check state before trying again." : "The browser disconnected before this request was delivered." });
40
- }, 20_000);
53
+ }, budget);
41
54
  this.pending.set(command.id, { command, clientId: null, timer, resolve });
42
55
  });
43
56
  }
44
57
  close() {
45
58
  for (const job of this.pending.values()) { clearTimeout(job.timer); job.resolve({ id: job.command.id, nonce: job.command.nonce, outcome: job.clientId && job.command.op !== "read" ? "uncertain" : "failed", prs: [], applied: [], error: "Follow-through reloaded before the request was confirmed. Check state." }); }
46
- this.pending.clear();
59
+ this.pending.clear(); this.clients.clear(); this.observations.clear();
47
60
  }
48
61
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { downstack, mergeBlockers, type ActionPlan, type BrowserPr, type PrActionKind } from "../shared/pr-actions";
2
+ import { downstack, mergeBlockers, prActionBlocks, type ActionPlan, type BrowserPr, type PrActionKind } from "../shared/pr-actions";
3
3
  import type { Workspace, Pr } from "../shared/model";
4
4
  import { findWorkspace } from "./engine";
5
5
  import { Store } from "./store";
@@ -19,6 +19,7 @@ export class PrActions {
19
19
  private plans = new Map<string, { plan: ActionPlan; identity: string }>();
20
20
  private reads = new Map<string, Promise<BrowserPr[]>>();
21
21
  private running = new Set<Promise<unknown>>();
22
+ private reviewerRequests = new Map<string, Promise<{ state: "submitted" | "failed" | "uncertain"; result: string }>>();
22
23
  constructor(private store: Store, private github: Pick<Github, "observe"> & Partial<Pick<Github, "requestReviewers">>, private graphite: Pick<Graphite, "merge" | "invalidate">, readonly browser: BrowserBridge, private now = Date.now, private reviewersFor = defaultReviewers) {}
23
24
 
24
25
  private async fresh(workspaceId: string): Promise<Workspace> {
@@ -44,7 +45,7 @@ export class PrActions {
44
45
  const w = findWorkspace(await this.store.snapshot(), workspaceId); active(w);
45
46
  const p = w.prs.find(p => p.key === key);
46
47
  if (!p) throw new Error("This PR is outside the workspace's inventory.");
47
- const browser = this.browser.status();
48
+ const browser = this.browser.status(p.repo);
48
49
  if (!browser.ready || p.error || !p.snapshot?.complete || p.snapshot.state !== "OPEN") return { browser, pr: null, error: null };
49
50
  const cached = this.browser.cached(p.repo, p.number, p.snapshot.head);
50
51
  if (cached) return { browser, pr: cached, error: null };
@@ -59,7 +60,7 @@ export class PrActions {
59
60
  const prs = downstack(w.prs, key);
60
61
  if (!prs.length || prs.at(-1)?.key !== key) throw new Error("This PR is already merged.");
61
62
  const blockers: string[] = [];
62
- if (w.prActions.some(a => ["sending", "uncertain"].includes(a.state))) blockers.push("Check the previous PR action before sending another.");
63
+ if (w.prActions.some(a => prActionBlocks(a, prs.map(p => p.key)))) blockers.push("Check the previous PR action before sending another.");
63
64
  if (kind === "merge" && w.prActions.some(a => a.kind === "merge" && a.state === "submitted" && prs.some(p => a.heads[p.key] === p.snapshot?.head) && this.now() - Date.parse(a.createdAt) < 30_000)) blockers.push("A merge was just submitted for this revision. Wait for Graphite's result.");
64
65
  const rows = prs.map(p => ({ key: p.key, repo: p.repo, number: p.number, title: p.snapshot?.title ?? p.stack, head: p.snapshot?.head ?? "", parent: p.parent, base: p.snapshot?.base ?? "", trunk: p.snapshot?.trunk ?? "", blockers: kind === "merge" ? mergeBlockers(p) : p.error || !p.snapshot?.complete || p.snapshot.state !== "OPEN" ? ["Refresh an open PR"] : kind === "enable_mwr" && p.snapshot.draft ? ["Draft"] : [] }));
65
66
  if (kind === "merge") {
@@ -83,8 +84,12 @@ export class PrActions {
83
84
  return work;
84
85
  }
85
86
  requestReviewers(workspaceId: string, key: string) {
86
- const work = this.executeReviewers(workspaceId, key); this.running.add(work);
87
- void work.finally(() => this.running.delete(work)).catch(() => undefined);
87
+ const requestKey = JSON.stringify([workspaceId, key]);
88
+ const pending = this.reviewerRequests.get(requestKey);
89
+ if (pending) return pending;
90
+ const work = this.executeReviewers(workspaceId, key);
91
+ this.reviewerRequests.set(requestKey, work); this.running.add(work);
92
+ void work.finally(() => { this.reviewerRequests.delete(requestKey); this.running.delete(work); }).catch(() => undefined);
88
93
  return work;
89
94
  }
90
95
  private async executeReviewers(workspaceId: string, key: string) {
@@ -101,7 +106,7 @@ export class PrActions {
101
106
  const id = randomUUID();
102
107
  await this.store.mutate(state => {
103
108
  const current = findWorkspace(state, workspaceId); active(current);
104
- if (current.prActions.some(a => ["sending", "uncertain"].includes(a.state))) throw new Error("Check the previous PR action before sending another.");
109
+ if (current.prActions.some(a => prActionBlocks(a, [key]))) throw new Error("Check the previous PR action before sending another.");
105
110
  const latest = current.prs.find(p => p.key === key);
106
111
  if (!latest || latest.snapshot?.head !== pr.snapshot!.head || !noReviewers(latest)) throw new Error("PR status changed. Refresh before requesting reviews.");
107
112
  current.prActions.push({ id, kind: "request_reviewers", reviewers: reviewers.map(r => r.login), prs: [key], heads: { [key]: pr.snapshot!.head }, createdAt: new Date(this.now()).toISOString(), state: "sending", result: "Waiting for GitHub to confirm the review request." });
@@ -145,10 +150,10 @@ export class PrActions {
145
150
  const id = randomUUID();
146
151
  await this.store.mutate(state => {
147
152
  const current = findWorkspace(state, w.id); active(current);
148
- if (current.prActions.some(a => ["sending", "uncertain"].includes(a.state))) throw new Error("A PR action is already in progress or needs checking.");
153
+ if (current.prActions.some(a => prActionBlocks(a, prs.map(p => p.key)))) throw new Error("A PR action is already in progress or needs checking.");
149
154
  if (plan.kind === "merge" && current.prActions.some(a => a.kind === "merge" && a.state === "submitted" && prs.some(p => a.heads[p.key] === p.snapshot?.head) && this.now() - Date.parse(a.createdAt) < 30_000)) throw new Error("A merge was just submitted for this revision. Wait for Graphite's result.");
150
155
  if (identity(downstack(current.prs, plan.target)) !== saved.identity) throw new Error("The stack changed before submission.");
151
- current.prActions.push({ id, kind: plan.kind, prs: prs.map(p => p.key), heads: Object.fromEntries(prs.map(p => [p.key, p.snapshot!.head])), createdAt: new Date(this.now()).toISOString(), state: "sending", result: "Waiting for Graphite to confirm." });
156
+ current.prActions.push({ id, kind: plan.kind, prs: prs.map(p => p.key), heads: Object.fromEntries(prs.map(p => [p.key, p.snapshot!.head])), createdAt: new Date(this.now()).toISOString(), state: "sending", result: plan.kind === "merge" ? "Waiting for Graphite to confirm." : "Confirm this PR setting change in the Send to Paseo extension in Chrome." });
152
157
  current.completion = null;
153
158
  });
154
159
  let state: "submitted" | "failed" | "uncertain" = "submitted";
@@ -0,0 +1,9 @@
1
+ import type { State } from "../shared/model";
2
+
3
+ /** Duplicate claims are ambiguous. Never route by recency or by PR title. */
4
+ export function prOwner(state: State, repo: string, number: number) {
5
+ const claims = state.workspaces.filter(w => w.enable_tracking && !w.archived)
6
+ .flatMap(w => w.prs.filter(p => p.repo.toLowerCase() === repo.toLowerCase() && p.number === number)
7
+ .map(p => ({ workspaceId: w.id, agentId: p.registeredAgentId ?? w.mainAgentId })));
8
+ return claims.length === 1 ? claims[0] : null;
9
+ }
@@ -41,3 +41,6 @@ export const projectSettingsRpc = defineRpc({ name: "follow.project-settings", i
41
41
  export const setProjectTrackingRpc = defineRpc({ name: "follow.set-project-tracking", input: z.object({ projectId: idSchema, enable_default_tracking: z.boolean() }), output: z.object({ projects: z.array(projectSchema) }) });
42
42
  export const refineTasksRpc = defineRpc({ name: "follow.refine-tasks", input: z.object({ workspaceId: idSchema, tasks: z.array(z.object({ id: idSchema, title: textSchema, afterPrs: z.array(idSchema), dependencyStage: z.enum(["merged", "deployed"]), enable_follow_up: z.boolean(), communication: communicationSchema.optional(), trigger: taskTriggerSchema.nullable().optional() })).max(50) }), output: workspaceSchema });
43
43
  export const taskTriggerRpc = defineRpc({ name: "follow.set-task-trigger", input: z.object({ workspaceId: idSchema, obligationId: idSchema, expectedRevision: z.number().int().nonnegative(), trigger: taskTriggerSchema.nullable(), afterPrs: z.array(idSchema) }), output: workspaceSchema });
44
+
45
+ // Narrow integration: no inventory, messages, paths, or credentials cross this RPC.
46
+ export const prOwnerRpc = defineRpc({ name: "follow.pr-owner", input: z.strictObject({ repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/), number: z.number().int().positive() }), output: z.object({ owner: z.object({ workspaceId: idSchema, agentId: idSchema }).nullable() }) });
package/shared/model.ts CHANGED
@@ -26,6 +26,7 @@ export const prSchema = z.object({
26
26
  key: idSchema, repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/), number: z.number().int().positive(),
27
27
  stack: textSchema, parent: idSchema.nullable(), disposition: z.string().nullable(),
28
28
  snapshot: snapshotSchema.nullable(), error: z.string().nullable(),
29
+ registeredAgentId: idSchema.optional(),
29
30
  deploymentAnchor: z.object({ mergeCommit: z.string(), at: z.string() }).optional(),
30
31
  });
31
32
  export const obligationSchema = z.object({
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { idSchema, textSchema, type Pr } from "./model";
2
+ import { idSchema, textSchema, type Pr, type Workspace } from "./model";
3
3
 
4
4
  export const prActionKind = z.enum(["merge", "enable_mwr", "disable_mwr"]);
5
5
  export const actionPrSchema = z.object({ key: idSchema, repo: z.string(), number: z.number(), title: z.string(), head: z.string(), parent: idSchema.nullable(), base: z.string(), trunk: z.string(), blockers: z.array(z.string()) });
@@ -13,7 +13,7 @@ export const browserCommandSchema = z.object({ id: idSchema, nonce: idSchema, ex
13
13
  export type BrowserCommand = z.infer<typeof browserCommandSchema>;
14
14
  export const browserResultSchema = z.object({ id: idSchema, nonce: idSchema, outcome: z.enum(["completed", "failed", "uncertain"]), prs: z.array(browserPrSchema).max(50).default([]), applied: z.array(z.number().int().positive()).max(50).default([]), error: z.string().max(500).default("") });
15
15
  export type BrowserResult = z.infer<typeof browserResultSchema>;
16
- export const browserExchangeSchema = z.object({ clientId: idSchema, ready: z.boolean(), acceptJobs: z.boolean().default(true), results: z.array(browserResultSchema).max(10).default([]) });
16
+ export const browserExchangeSchema = z.object({ clientId: idSchema, ready: z.boolean(), acceptJobs: z.boolean().default(true), results: z.array(browserResultSchema).max(10).default([]), repositories: z.array(browserPrSchema.shape.repo).max(100).default([]) });
17
17
  export const browserStatusSchema = z.object({ ready: z.boolean(), reason: z.string().nullable() });
18
18
  export const actionResultSchema = z.object({ state: z.enum(["submitted", "failed", "uncertain"]), result: textSchema });
19
19
 
@@ -45,3 +45,9 @@ export function mergeBlockers(pr: Pr): string[] {
45
45
  if (!["READY_TO_MERGE", "READY_TO_MERGE_AS_STACK", "READY_TO_MERGE_WITHOUT_CI", "READY_TO_MERGE_AS_STACK_WITHOUT_CI"].includes(s.graphiteMergeStatus ?? "")) blockers.push("Graphite is not ready to merge");
46
46
  return blockers;
47
47
  }
48
+
49
+ /** Reviewer requests on other PRs do not own this PR's action slot. */
50
+ export function prActionBlocks(action: Pick<Workspace["prActions"][number], "state" | "kind" | "prs">, keys: readonly string[]): boolean {
51
+ return ["sending", "uncertain"].includes(action.state) &&
52
+ (action.kind !== "request_reviewers" || action.prs.some(key => keys.includes(key)));
53
+ }