Skip to content

Commit 6e305f1

Browse files
cl 9781 redeploy (#1090)
* test(web): add provider-change redeploy detection and input tests (CL-9781) * feat(web): offer re-deploy on the worker page when the inference provider changes (CL-9781) * test(e2e): prove edit-and-redeploy preserves agent config (CL-9781) Deploy an agent via deployAgentSource, grow the workspace offering chain, then redeployAgentForProviderChange: the redeploy mints a new deployment on the same source asset (slug stable), keeps the definition mail trigger, system prompt, and MCP bindings, and adopts the current offering. Roles intentionally unasserted: role assignment lives on PR 1093 (CL-9780), still open; a TODO in the test names the post-merge carry assertion. The e2e project gains the web client's jsx/paths/DOM settings plus ambient CSS/?raw declarations so it can typecheck the client sources it exercises.
1 parent 32eb057 commit 6e305f1

7 files changed

Lines changed: 659 additions & 1 deletion

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import {
4+
buildAgentRedeployInput,
5+
needsAgentRedeployForProviderChange,
6+
} from "./agent-model-redeploy";
7+
import type { Grant } from "./settings/tenancy-api";
8+
9+
function grant(id: string, resource: string, effect: Grant["effect"]): Grant {
10+
return {
11+
id,
12+
tenantId: "bench",
13+
principalId: "principal",
14+
resource,
15+
action: "invoke",
16+
effect,
17+
origin: "invoker",
18+
createdAt: "2026-01-01T00:00:00.000Z",
19+
updatedAt: "2026-01-01T00:00:00.000Z",
20+
};
21+
}
22+
23+
describe("needsAgentRedeployForProviderChange", () => {
24+
test("stays quiet when the agent already declares the current offering", () => {
25+
expect(
26+
needsAgentRedeployForProviderChange(
27+
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
28+
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
29+
),
30+
).toBe(false);
31+
});
32+
33+
test("flags a provider change", () => {
34+
expect(
35+
needsAgentRedeployForProviderChange(
36+
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
37+
[{ provider: "openai", model: "gpt-5" }],
38+
),
39+
).toBe(true);
40+
});
41+
42+
test("flags a model change on the same provider", () => {
43+
expect(
44+
needsAgentRedeployForProviderChange(
45+
[{ provider: "openai", model: "gpt-5" }],
46+
[{ provider: "openai", model: "gpt-5.5" }],
47+
),
48+
).toBe(true);
49+
});
50+
51+
test("flags a changed fallback chain", () => {
52+
expect(
53+
needsAgentRedeployForProviderChange(
54+
[
55+
{ provider: "anthropic", model: "claude-sonnet-4-5" },
56+
{ provider: "openai", model: "gpt-5" },
57+
],
58+
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
59+
),
60+
).toBe(true);
61+
});
62+
63+
test("stays quiet when no provider is connected yet", () => {
64+
expect(
65+
needsAgentRedeployForProviderChange(
66+
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
67+
null,
68+
),
69+
).toBe(false);
70+
});
71+
});
72+
73+
describe("buildAgentRedeployInput", () => {
74+
test("preserves the agent's config and marks a redeploy", () => {
75+
expect(
76+
buildAgentRedeployInput({
77+
name: "Research Buddy",
78+
slug: "research-buddy",
79+
systemPrompt: "You are helpful.",
80+
mcpHandles: ["exa"],
81+
toolGrants: [],
82+
}),
83+
).toEqual({
84+
name: "Research Buddy",
85+
slug: "research-buddy",
86+
systemPrompt: "You are helpful.",
87+
mcpHandles: ["exa"],
88+
toolEffects: [],
89+
redeploy: true,
90+
});
91+
});
92+
93+
test("carries allow and deny while dropping ask-gated permissions", () => {
94+
const input = buildAgentRedeployInput({
95+
name: "Research Buddy",
96+
slug: "research-buddy",
97+
systemPrompt: "You are helpful.",
98+
mcpHandles: [],
99+
toolGrants: [
100+
grant("allow-row", "tool:web_search", "allow"),
101+
grant("ask-row", "tool:send_mail", "ask"),
102+
grant("deny-row", "tool:delete_all", "deny"),
103+
],
104+
});
105+
expect(input.toolEffects).toEqual([
106+
{ resource: "tool:web_search", effect: "allow" },
107+
{ resource: "tool:delete_all", effect: "deny" },
108+
]);
109+
expect(input.redeploy).toBe(true);
110+
});
111+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Re-deploys a hand-authored agent onto the workspace's current inference
2+
// offering after the provider changed since its last deploy — the same
3+
// `deployAgentSource` pipeline that created it, with its config carried
4+
// over, so nothing the person set is lost in the move.
5+
import {
6+
agentSlugFromSourceAssetName,
7+
deployAgentSource,
8+
type DeployedAgent,
9+
type NewAgentInput,
10+
} from "./agent-deploy";
11+
import { readAgentMcpHandles, readAgentSource } from "./agent-source-read";
12+
import type { ChatAgent } from "./chat/threads-api";
13+
import { resolveExistingOffering, type DeclaredSource } from "./onboarding/provider-connect-step";
14+
import type { Grant } from "./settings/tenancy-api";
15+
import { readWorkerToolGrants, toolEffectsToCarry } from "./worker-tool-grants";
16+
17+
export class AgentRedeployError extends Error {}
18+
19+
/** An inference source as either side of the comparison carries it: the
20+
* agent's deployed declaration (plain strings off the source tree) and the
21+
* workspace's current offering (plugin-typed) alike. */
22+
export type InferenceSource = {
23+
readonly provider: string;
24+
readonly model: string;
25+
};
26+
27+
/** True when the agent's declared inference sources no longer match the
28+
* workspace's current offering — its runs still pin the old provider, so a
29+
* redeploy is the only way onto the new one. Quiet when no provider is
30+
* connected yet, since there is nothing to move onto. */
31+
export function needsAgentRedeployForProviderChange(
32+
declared: readonly InferenceSource[],
33+
current: readonly InferenceSource[] | null,
34+
): boolean {
35+
if (current === null) return false;
36+
return (
37+
declared.length !== current.length ||
38+
declared.some(
39+
(source, index) =>
40+
source.provider !== current[index]?.provider || source.model !== current[index]?.model,
41+
)
42+
);
43+
}
44+
45+
/** The redeploy input that keeps the agent's config: its name and address
46+
* slug, its instructions, its bound servers, and its non-ask permissions —
47+
* ask-gated tools stay ask-gated by falling back to the deploy default. */
48+
export function buildAgentRedeployInput(args: {
49+
readonly name: string;
50+
readonly slug: string;
51+
readonly systemPrompt: string;
52+
readonly mcpHandles: readonly string[];
53+
readonly toolGrants: readonly Grant[];
54+
}): NewAgentInput {
55+
return {
56+
name: args.name,
57+
slug: args.slug,
58+
systemPrompt: args.systemPrompt,
59+
mcpHandles: args.mcpHandles,
60+
toolEffects: toolEffectsToCarry(args.toolGrants),
61+
redeploy: true,
62+
};
63+
}
64+
65+
/** The workspace's current offering, or null when no provider is connected. */
66+
export function readCurrentOfferingSources(
67+
tenantId: string,
68+
fetchImpl: typeof fetch = fetch,
69+
): Promise<readonly DeclaredSource[] | null> {
70+
return resolveExistingOffering(tenantId, fetchImpl).then(
71+
(offering) => offering?.declaredSources ?? null,
72+
);
73+
}
74+
75+
/**
76+
* Re-deploys an existing agent onto the current offering, preserving its
77+
* config. Fails closed for the default worker and anything this pipeline
78+
* didn't deploy (no slug to keep the address stable under), and when no
79+
* provider is connected yet (`deployAgentSource` throws there).
80+
*/
81+
export async function redeployAgentForProviderChange(
82+
args: { readonly tenantId: string; readonly agent: ChatAgent },
83+
fetchImpl: typeof fetch = fetch,
84+
): Promise<DeployedAgent> {
85+
const slug = agentSlugFromSourceAssetName(args.agent.assetName);
86+
if (slug === null) {
87+
throw new AgentRedeployError(`${args.agent.assetName} was not deployed by this workbench`);
88+
}
89+
const [source, mcpHandles, grants] = await Promise.all([
90+
readAgentSource(args.tenantId, args.agent.id, args.agent.assetName, fetchImpl),
91+
readAgentMcpHandles(args.tenantId, args.agent.id, args.agent.assetName, fetchImpl),
92+
readWorkerToolGrants(args.tenantId, args.agent),
93+
]);
94+
return deployAgentSource(
95+
{
96+
tenantId: args.tenantId,
97+
input: buildAgentRedeployInput({
98+
name: args.agent.name,
99+
slug,
100+
systemPrompt: source.systemPrompt,
101+
mcpHandles,
102+
toolGrants: grants?.tools ?? [],
103+
}),
104+
},
105+
fetchImpl,
106+
);
107+
}

‎apps/web/src/pages/worker-page.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@
243243
color: var(--danger-ink);
244244
}
245245

246+
.wp-redeploy {
247+
margin-top: 0.75rem;
248+
border-top: 1px solid var(--line);
249+
padding-top: 0.25rem;
250+
}
251+
246252
.wp-hint {
247253
font-size: 12.5px;
248254
color: var(--ink-3);

‎apps/web/src/pages/worker-page.tsx‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import { PageLayout } from "../shell/page-layout";
2020
import { useWorkerRole } from "../worker-role-query";
2121
import { updateGrant, type Grant } from "../settings/tenancy-api";
2222
import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy";
23+
import {
24+
needsAgentRedeployForProviderChange,
25+
readCurrentOfferingSources,
26+
redeployAgentForProviderChange,
27+
} from "../agent-model-redeploy";
2328
import { readAgentMcpHandles, readAgentSource } from "../agent-source-read";
2429
import {
2530
TOOL_PREFIX,
@@ -263,6 +268,12 @@ function approvalsLabel(tally: ReturnType<typeof useApprovalTally>): string {
263268
return `${once + always + denied + waiting} asked · ${once + always} allowed`;
264269
}
265270

271+
function formatSources(
272+
sources: readonly { readonly provider: string; readonly model: string }[],
273+
): string {
274+
return sources.map((source) => `${source.provider} · ${source.model}`).join(", ");
275+
}
276+
266277
function DetailsCard({
267278
tenantId,
268279
agent,
@@ -280,6 +291,39 @@ function DetailsCard({
280291
const thisWeek = (own ?? []).filter((run) => Date.parse(run.createdAt) >= weekStart);
281292
const tally = useApprovalTally(tenantId, thisWeek.slice(0, 50));
282293
const created = (own ?? []).map((run) => Date.parse(run.createdAt)).sort((a, b) => a - b)[0];
294+
295+
// Only agents this pipeline deployed can be re-deployed from here — only
296+
// they carry a slug to keep the address stable under. The default worker's
297+
// provider moves are handled where the provider is edited (Settings).
298+
const queryClient = useQueryClient();
299+
const redeployable = agentSlugFromSourceAssetName(agent.assetName) !== null;
300+
const offering = useQuery({
301+
queryKey: [...tenantKeys.agents(tenantId), "offering"],
302+
queryFn: () => readCurrentOfferingSources(tenantId),
303+
enabled: redeployable,
304+
});
305+
const declared = source.data?.declaredSources;
306+
const current = offering.data;
307+
const providerChanged =
308+
redeployable &&
309+
declared !== undefined &&
310+
current !== undefined &&
311+
needsAgentRedeployForProviderChange(declared, current);
312+
313+
// Re-deploying preserves the agent's config (instructions, servers,
314+
// permissions) — `redeployAgentForProviderChange` carries it over, so the
315+
// move onto the current provider loses nothing the person set.
316+
const redeploy = useMutation({
317+
mutationFn: () => redeployAgentForProviderChange({ tenantId, agent }),
318+
onSuccess: async () => {
319+
toast("Worker re-deployed onto the current provider");
320+
await queryClient.invalidateQueries({ queryKey: tenantKeys.agents(tenantId) });
321+
},
322+
onError: (cause) => {
323+
reportError(cause, { operation: "worker_provider_redeploy", tenantId });
324+
},
325+
});
326+
283327
return (
284328
<section className="wp-card">
285329
<h2 className="wp-h2--small">Details</h2>
@@ -303,6 +347,36 @@ function DetailsCard({
303347
</>
304348
)}
305349
</dl>
350+
{!providerChanged ? null : (
351+
<div className="wp-redeploy">
352+
<p className="wp-note">
353+
The inference provider changed since this worker was deployed — it still runs on{" "}
354+
{formatSources(declared ?? [])}, while the workspace now offers{" "}
355+
{formatSources(current ?? [])}. Re-deploying moves it over and keeps its instructions,
356+
servers, and permissions.
357+
</p>
358+
{redeploy.isError ? (
359+
<p role="alert" className="wp-note wp-note-error">
360+
{describeApiError(redeploy.error, "re-deploy this worker")}
361+
</p>
362+
) : null}
363+
<div className="wp-save-row">
364+
<ConfirmButton
365+
size="sm"
366+
disabled={redeploy.isPending}
367+
confirmLabel={`Re-deploy onto ${formatSources(current ?? [])}`}
368+
onConfirm={() => redeploy.mutate()}
369+
>
370+
{redeploy.isPending ? "Re-deploying…" : "Re-deploy"}
371+
</ConfirmButton>
372+
<span className="wp-hint">
373+
{redeploy.isPending
374+
? "Moving onto the current provider"
375+
: "Re-deploys and restarts this worker"}
376+
</span>
377+
</div>
378+
</div>
379+
)}
306380
</section>
307381
);
308382
}

0 commit comments

Comments
 (0)