paseo-follow-up 0.1.2 → 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 +2 -2
- package/README.md +28 -2
- package/client/panel.tsx +1 -1
- package/client/pr-controls.tsx +2 -2
- package/index.server.ts +24 -13
- package/package.json +3 -3
- package/paseo-plugin.json +1 -1
- package/server/browser-bridge.ts +28 -15
- package/server/pr-actions.ts +13 -8
- package/server/pr-owner.ts +9 -0
- package/server/session-mcp.ts +15 -0
- package/shared/contracts.ts +3 -0
- package/shared/model.ts +1 -0
- package/shared/pr-actions.ts +8 -2
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.
|
|
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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/paseo-follow-up)
|
|
4
4
|
[](https://www.npmjs.com/package/paseo-follow-up)
|
|
5
|
-
[](https://paseo.sh)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
Keep long-lived Paseo workspaces moving through review, deployment, and completion. Follow-up retains PR stacks and remaining tasks, batches repairs for the existing main agent, and records the agent's explicit Done declaration.
|
|
@@ -13,7 +13,7 @@ Screenshots use synthetic workspaces and PRs.
|
|
|
13
13
|
|
|
14
14
|
## Requirements
|
|
15
15
|
|
|
16
|
-
- Paseo 0.
|
|
16
|
+
- Paseo 0.14.0 or newer and Node 22 or newer.
|
|
17
17
|
- Authenticated GitHub CLI (`gh`) on the daemon host.
|
|
18
18
|
- Saved Graphite CLI authentication for Graphite readiness and explicit Merge controls.
|
|
19
19
|
- Optional Send to Paseo browser companion for Graphite's actual Merge when ready switch. Core tracking and follow-up work without it.
|
|
@@ -40,6 +40,22 @@ Tasks default to manual. Explicit Now, After merge or After deploy triggers can
|
|
|
40
40
|
|
|
41
41
|

|
|
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.
|
|
@@ -63,6 +79,16 @@ Integration checks use a separate Paseo home, synthetic agents and fixtures. Nev
|
|
|
63
79
|
|
|
64
80
|
Release tags (`vX.Y.Z`) run `.github/workflows/publish-npm.yml`. GitHub Actions verifies the plugin and packed installation, publishes through npm Trusted Publishing, verifies registry integrity, then creates the GitHub release. No npm token is stored in repository secrets.
|
|
65
81
|
|
|
82
|
+
## Paseo 0.14 agent integration
|
|
83
|
+
|
|
84
|
+
The Follow-up MCP attaches when a tracked main or active recovery agent opens a Claude or Codex session. Normal startup no longer writes global Claude/Codex MCP settings. Existing global entries are preserved. Turn-ended discovery now fetches recent history through the SDK because Paseo 0.13+ sends an empty timeline in lifecycle events.
|
|
85
|
+
|
|
86
|
+
Requires Paseo **0.14.0 or newer**. Agent tools are scoped to interactive Claude
|
|
87
|
+
and Codex sessions; history-only opens receive none. They become available on
|
|
88
|
+
the next idle session open after install or reload. The agent label
|
|
89
|
+
`plugin-tools/enabled: "false"` opts a session out. Existing plugin data and
|
|
90
|
+
settings are retained.
|
|
91
|
+
|
|
66
92
|
## Other plugins
|
|
67
93
|
|
|
68
94
|
Also available from [Tom Gringauz](https://github.com/tomgrin10):
|
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 }]}>
|
|
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} />)}
|
package/client/pr-controls.tsx
CHANGED
|
@@ -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
|
|
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 =>
|
|
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";
|
|
@@ -18,7 +19,7 @@ import { applyHistory, applyDiscovery, extractHistory } from "./server/discovery
|
|
|
18
19
|
import { lifecycleOnly, recoveryBatch } from "./shared/follow-up";
|
|
19
20
|
import { Deployments } from "./server/deployments";
|
|
20
21
|
import { AgentHooks } from "./server/agent-hooks";
|
|
21
|
-
import {
|
|
22
|
+
import { withSessionMcp } from "./server/session-mcp";
|
|
22
23
|
import { updateObligation } from "./server/obligations";
|
|
23
24
|
|
|
24
25
|
export default function contribute(server: PluginServerContext) {
|
|
@@ -27,7 +28,6 @@ export default function contribute(server: PluginServerContext) {
|
|
|
27
28
|
const bridge = join(directory, "mcp.mjs");
|
|
28
29
|
const store = new Store(directory);
|
|
29
30
|
const agentHooks = new AgentHooks(directory, clientEntry());
|
|
30
|
-
const agentMcp = new AgentMcp(directory, clientEntry(), paseoHome);
|
|
31
31
|
const gateway = new PaseoGateway();
|
|
32
32
|
const graphite = new Graphite();
|
|
33
33
|
const github = new Github(undefined, graphite, new Deployments());
|
|
@@ -48,8 +48,6 @@ export default function contribute(server: PluginServerContext) {
|
|
|
48
48
|
await engine.recover();
|
|
49
49
|
hooksStarted = true;
|
|
50
50
|
await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; tracking is still available."; });
|
|
51
|
-
const mcp = await agentMcp.start();
|
|
52
|
-
if ([mcp.claude, mcp.codex].some(status => ["unavailable", "name-conflict"].includes(status))) hookError = "Some agent MCP connections could not be registered. Existing CLI access remains available.";
|
|
53
51
|
hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
|
|
54
52
|
})().catch(() => { loadError = "Follow-through could not start. Check its saved state and worker lock."; });
|
|
55
53
|
const requireReady = async () => { await ready; if (loadError || stopped) throw new Error(loadError ?? "Follow-through is stopping"); };
|
|
@@ -96,6 +94,14 @@ export default function contribute(server: PluginServerContext) {
|
|
|
96
94
|
void engine.tick();
|
|
97
95
|
return { projects: await gateway.projects(await store.snapshot()) };
|
|
98
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
|
+
});
|
|
99
105
|
server.handle(rpc.listRpc, async () => { await ready; return { workspaces: (await store.snapshot()).workspaces.filter(w => w.enable_tracking), error: loadError ?? hookError ?? engine.error }; });
|
|
100
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 }; });
|
|
101
107
|
server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
|
|
@@ -148,8 +154,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
148
154
|
seen.add(ancestor); ancestor = w.prs.find(p => p.key === ancestor)?.parent ?? null;
|
|
149
155
|
}
|
|
150
156
|
const altered = !existing || existing.stack !== stack || existing.parent !== parent || disposition !== undefined && existing.disposition !== disposition;
|
|
151
|
-
if (existing) { existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
|
|
152
|
-
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 });
|
|
153
159
|
if (altered) changed(w); return w;
|
|
154
160
|
});
|
|
155
161
|
void engine.tick(); return result;
|
|
@@ -238,7 +244,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
238
244
|
void engine.tick(); return result;
|
|
239
245
|
});
|
|
240
246
|
server.handle(rpc.browserStatusRpc, async () => { await requireReady(); return browser.status(); });
|
|
241
|
-
server.handle(rpc.browserExchangeRpc, async ({ clientId, ready: browserReady, acceptJobs, results }) => { await requireReady(); return
|
|
247
|
+
server.handle(rpc.browserExchangeRpc, async ({ clientId, ready: browserReady, acceptJobs, results, repositories }) => { await requireReady(); return browser.exchange(clientId, browserReady, results, acceptJobs, repositories); });
|
|
242
248
|
server.handle(rpc.prControlStatusRpc, async ({ workspaceId, prKey }) => { await requireReady(); return actions.status(workspaceId, prKey); });
|
|
243
249
|
server.handle(rpc.preparePrActionRpc, async ({ workspaceId, prKey, kind }) => { await requireReady(); return actions.prepare(workspaceId, prKey, kind); });
|
|
244
250
|
server.handle(rpc.confirmPrActionRpc, async ({ planId, scope }) => { await requireReady(); const result = await actions.confirm(planId, scope); void engine.tick(); return result; });
|
|
@@ -252,9 +258,9 @@ export default function contribute(server: PluginServerContext) {
|
|
|
252
258
|
}),
|
|
253
259
|
server.before("agent.session_open", async ({ request }) => {
|
|
254
260
|
await ready;
|
|
255
|
-
if (loadError || stopped) return request;
|
|
261
|
+
if (loadError || stopped || request.purpose !== "interactive") return request;
|
|
256
262
|
const saved = await store.snapshot();
|
|
257
|
-
let w = saved.workspaces.find(w => w.enable_tracking && (w.mainAgentId === request.agentId || w.batches.some(b => b.recipientAgentId === request.agentId)));
|
|
263
|
+
let w = saved.workspaces.find(w => w.enable_tracking && !w.archived && (w.mainAgentId === request.agentId || w.batches.some(b => b.recipientAgentId === request.agentId && !b.settledAt && b.tasks.every(t => t.kind === "inventory") && ["sending", "sent", "uncertain", "blocked"].includes(b.state))));
|
|
258
264
|
if (!w && request.workspaceId && !saved.workspaces.some(w => w.id === request.workspaceId && (w.archived || w.mainAgentId !== request.agentId))) {
|
|
259
265
|
const project = await gateway.projectForWorkspace(saved, request.workspaceId).catch(() => null);
|
|
260
266
|
if (project?.enable_default_tracking) {
|
|
@@ -267,9 +273,10 @@ export default function contribute(server: PluginServerContext) {
|
|
|
267
273
|
}
|
|
268
274
|
}
|
|
269
275
|
if (!w) return request;
|
|
270
|
-
|
|
276
|
+
const recovery = w.mainAgentId !== request.agentId;
|
|
277
|
+
return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}) } }, directory, clientEntry(), paseoHome, recovery);
|
|
271
278
|
}),
|
|
272
|
-
server.on("agent.turn_ended", async event => {
|
|
279
|
+
server.on("agent.turn_ended", async (event, { paseo, signal }) => {
|
|
273
280
|
await ready;
|
|
274
281
|
if (stopped || loadError) return;
|
|
275
282
|
const interruption = event.outcome.kind === "canceled" || event.outcome.kind === "failed" ? { turnId: event.turnId, at: new Date().toISOString(), kind: event.outcome.kind } : null;
|
|
@@ -281,9 +288,13 @@ export default function contribute(server: PluginServerContext) {
|
|
|
281
288
|
}
|
|
282
289
|
if (!(await store.snapshot()).workspaces.some(w => w.enable_tracking && w.mainAgentId === event.agent.id)) return;
|
|
283
290
|
gateway.markDirty(event.agent.id);
|
|
284
|
-
|
|
291
|
+
// Since Paseo 0.13 the lifecycle payload deliberately contains no history.
|
|
292
|
+
// Read a bounded tail through the plugin-owned API, outside the state lock.
|
|
293
|
+
const history = await paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 100, projection: "canonical" }).catch(() => null);
|
|
294
|
+
if (signal.aborted || stopped) return;
|
|
295
|
+
if (history) await store.mutate(state => {
|
|
285
296
|
const w = state.workspaces.find(w => w.enable_tracking && w.mainAgentId === event.agent.id);
|
|
286
|
-
if (w) applyHistory(w, extractHistory(
|
|
297
|
+
if (w) applyHistory(w, extractHistory(history.entries.map(entry => entry.item), w.inventory?.historyComplete ?? false, w.inventory?.historyError ?? null), null);
|
|
287
298
|
});
|
|
288
299
|
const guarded = interruption && await sessionGuardOwnsInterruption(event.agent.id, interruption).catch(() => false);
|
|
289
300
|
if (["canceled", "failed"].includes(event.outcome.kind) && !guarded) await store.mutate(state => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-follow-up",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "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": [
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"prepare": "node scripts/build.mjs"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@getpaseo/client": "0.
|
|
32
|
-
"@getpaseo/plugin": "0.
|
|
31
|
+
"@getpaseo/client": "0.14.0",
|
|
32
|
+
"@getpaseo/plugin": "0.14.0",
|
|
33
33
|
"@tanstack/react-query": "^5.90.11",
|
|
34
34
|
"@types/node": "^22.10.2",
|
|
35
35
|
"@types/react": "~19.2.0",
|
package/paseo-plugin.json
CHANGED
package/server/browser-bridge.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
}
|
package/server/pr-actions.ts
CHANGED
|
@@ -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 =>
|
|
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
|
|
87
|
-
|
|
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 =>
|
|
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 =>
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
|
2
|
+
import { mcpDefinition } from "./agent-mcp";
|
|
3
|
+
|
|
4
|
+
/** The bridge and caller identity belong to this launch, never global CLI config. */
|
|
5
|
+
export function withSessionMcp(request: PluginSessionOpenRequest, directory: string, clientEntry: string, home: string, recovery: boolean, env: NodeJS.ProcessEnv = process.env): PluginSessionOpenRequest {
|
|
6
|
+
if (request.purpose !== "interactive" || !["claude", "codex"].includes(request.provider) || request.labels["plugin-tools/enabled"] === "false") return request;
|
|
7
|
+
const definition = mcpDefinition(directory, clientEntry, home);
|
|
8
|
+
return { ...request, mcpServers: { ...request.mcpServers, "follow-through": { ...definition, env: {
|
|
9
|
+
...definition.env, PASEO_AGENT_ID: request.agentId,
|
|
10
|
+
...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}),
|
|
11
|
+
...(env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: env.PASEO_DAEMON_URL } : {}),
|
|
12
|
+
...(env.PASEO_PASSWORD ? { PASEO_PASSWORD: env.PASEO_PASSWORD } : {}),
|
|
13
|
+
...(env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: env.PASEO_PASSWORD_FILE } : {}),
|
|
14
|
+
} } } };
|
|
15
|
+
}
|
package/shared/contracts.ts
CHANGED
|
@@ -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({
|
package/shared/pr-actions.ts
CHANGED
|
@@ -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
|
+
}
|