Skip to content

Commit 910f0df

Browse files
committed
address skill bundle review feedback
1 parent 6eec4ca commit 910f0df

6 files changed

Lines changed: 185 additions & 53 deletions

File tree

packages/api-client/src/loops.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,10 @@ export namespace LoopSchemas {
204204
updated_at: string;
205205
triggers: Array<LoopTrigger>;
206206
/** Skill bundles attached to this loop, seeded into every fired run's sandbox.
207-
* Replaced wholesale via `replaceLoopSkillBundles`, never through the loop write. */
208-
skill_bundles: Array<LoopSkillBundle>;
207+
* Replaced wholesale via `replaceLoopSkillBundles`, never through the loop write.
208+
* Optional because a backend that predates skill bundles omits the field; treat
209+
* absence as an empty list. */
210+
skill_bundles?: Array<LoopSkillBundle>;
209211
};
210212

211213
/** A skill bundle attached to a loop. `content_sha256` is the stored snapshot's

packages/ui/src/features/loops/components/LoopDetailView.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import {
4747
nextScheduleRun,
4848
summarizeNotificationDestinations,
4949
} from "../loopDisplay";
50-
import { primaryLoopSkillBundle } from "../loopSkill";
50+
import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill";
5151
import { LoopLoadError } from "./LoopFallbacks";
5252
import { LoopRunRow } from "./LoopRunRow";
5353

@@ -322,7 +322,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
322322
.join(" · ")}
323323
</SummaryRow>
324324

325-
{loop.skill_bundles.length > 0 ? (
325+
{loopSkillBundles(loop).length > 0 ? (
326326
<SummaryRow label="Skill">
327327
<LoopSkillSummary loop={loop} />
328328
</SummaryRow>
@@ -374,18 +374,26 @@ function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
374374

375375
const primary = primaryLoopSkillBundle(loop);
376376
if (!primary) return null;
377-
const dependencyCount = loop.skill_bundles.length - 1;
378-
379-
const localMatch = (skillsQuery.data ?? []).find(
377+
const dependencyCount = loopSkillBundles(loop).length - 1;
378+
379+
// The one-click refresh must be unambiguous about which skill it snapshots: it
380+
// requires exactly one local skill matching the stored name AND source, so a
381+
// same-named skill from another source (say, an opened repo) can never silently
382+
// replace the loop's snapshot. Ambiguous cases go through the edit form, where
383+
// the picker shows each candidate.
384+
const candidates = (skillsQuery.data ?? []).filter(
380385
(skill) =>
381386
skill.name === primary.skill_name &&
382-
isUploadableSkillSource(skill.source),
387+
skill.source === primary.skill_source,
383388
);
389+
const localMatch = candidates.length === 1 ? candidates[0] : undefined;
384390
const updateDisabledReason = !localWorkspaces
385391
? "updating the snapshot needs the desktop app"
386392
: localMatch
387393
? null
388-
: `no local skill named ${primary.skill_name} was found on this machine`;
394+
: candidates.length > 1
395+
? `several local skills are named ${primary.skill_name}; pick the right one from the edit form`
396+
: `no local ${primary.skill_source} skill named ${primary.skill_name} was found on this machine`;
389397

390398
const handleUpdate = () => {
391399
if (!localMatch || !isUploadableSkillSource(localMatch.source)) return;

packages/ui/src/features/loops/components/LoopForm.tsx

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@ import {
1616
import { Box, Flex, Text, TextField } from "@radix-ui/themes";
1717
import { type ReactNode, useEffect, useState } from "react";
1818
import { useAuthStateValue } from "../../auth/store";
19-
import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations";
20-
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
19+
import {
20+
useCreateLoop,
21+
useDeleteLoop,
22+
useUpdateLoop,
23+
} from "../hooks/useLoopMutations";
24+
import {
25+
useBundleLocalSkill,
26+
useReplaceLoopSkillBundles,
27+
} from "../hooks/useLoopSkillBundles";
2128
import { summarizeTrigger } from "../loopDisplay";
2229
import { useLoopDraftStore } from "../loopDraftStore";
2330
import {
@@ -31,7 +38,7 @@ import {
3138
loopToFormValues,
3239
normalizeLoopFormValues,
3340
} from "../loopFormTypes";
34-
import { buildSkillInstructions } from "../loopSkill";
41+
import { buildSkillInstructions, loopSkillBundles } from "../loopSkill";
3542
import { LoopBehaviorFields } from "./LoopBehaviorFields";
3643
import { LoopContextFields } from "./LoopContextFields";
3744
import { Field } from "./LoopFormPrimitives";
@@ -88,10 +95,14 @@ export function LoopForm({ loop }: LoopFormProps) {
8895

8996
const createLoop = useCreateLoop();
9097
const updateLoop = useUpdateLoop(loop?.id ?? "");
91-
const syncSkillBundles = useSyncLoopSkillBundles();
98+
const deleteLoop = useDeleteLoop();
99+
const bundleSkill = useBundleLocalSkill();
100+
const replaceSkillBundles = useReplaceLoopSkillBundles();
92101
const isSubmitting =
93102
(isEdit ? updateLoop.isPending : createLoop.isPending) ||
94-
syncSkillBundles.isPending;
103+
bundleSkill.isPending ||
104+
replaceSkillBundles.isPending ||
105+
deleteLoop.isPending;
95106
const canSubmit = isLoopFormValid(values) && !isSubmitting;
96107

97108
// Per-step gate for the Next button. The final Create button is gated on the
@@ -130,26 +141,52 @@ export function LoopForm({ loop }: LoopFormProps) {
130141
const handleSubmit = async () => {
131142
if (!canSubmit) return;
132143
const body = formValuesToLoopWrite(values);
144+
145+
// Bundling runs before anything is persisted: a missing or broken local
146+
// skill fails here with no partial state, instead of leaving a saved loop
147+
// whose `/skill-name` instructions have no matching bundle.
148+
let uploads: LoopSchemas.LoopSkillBundleUpload[] | null = null;
149+
if (values.skill?.kind === "local") {
150+
try {
151+
uploads = await bundleSkill.mutateAsync(values.skill);
152+
} catch (error) {
153+
toast.error("Failed to bundle the skill", {
154+
description: error instanceof Error ? error.message : undefined,
155+
});
156+
return;
157+
}
158+
}
159+
133160
try {
134161
const saved = isEdit
135162
? await updateLoop.mutateAsync(body)
136163
: await createLoop.mutateAsync(body);
137-
// The skill snapshot rides separately from the loop write: a locally
138-
// picked skill is bundled and uploaded, removing the skill detaches the
139-
// stored bundles, and an unchanged attached snapshot is left alone.
140-
const skillSync =
141-
values.skill?.kind === "local"
142-
? { loopId: saved.id, skill: values.skill }
143-
: values.skill === null && saved.skill_bundles.length > 0
144-
? { loopId: saved.id, skill: null }
145-
: null;
146-
if (skillSync) {
164+
const needsDetach =
165+
values.skill === null && loopSkillBundles(saved).length > 0;
166+
if (uploads || needsDetach) {
147167
try {
148-
await syncSkillBundles.mutateAsync(skillSync);
168+
await replaceSkillBundles.mutateAsync({
169+
loopId: saved.id,
170+
uploads: uploads ?? [],
171+
});
149172
} catch (error) {
173+
const description =
174+
error instanceof Error ? error.message : undefined;
175+
if (!isEdit) {
176+
// Roll the just-created loop back rather than leaving one that
177+
// fires `/skill-name` with no bundle behind it.
178+
await deleteLoop.mutateAsync(saved.id).catch(() => {});
179+
toast.error("Failed to create loop", { description });
180+
return;
181+
}
182+
// Keep the form open with its state intact: saving again retries
183+
// both the loop write and the skill upload.
150184
toast.error("Loop saved, but updating its skill failed", {
151-
description: error instanceof Error ? error.message : undefined,
185+
description: [description, "Save again to retry."]
186+
.filter(Boolean)
187+
.join(" "),
152188
});
189+
return;
153190
}
154191
}
155192
navigateToLoopDetail(saved.id);

packages/ui/src/features/loops/components/LoopSkillFields.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ interface PickableSkill {
1414
name: string;
1515
source: LoopSkillDraft["source"];
1616
path: string;
17+
repoName?: string;
18+
}
19+
20+
/** Same-named skills from different sources must be tellable apart in the picker,
21+
* or the user can't know which one the loop will snapshot. */
22+
function pickableSkillLabel(
23+
skill: PickableSkill,
24+
duplicatedNames: Set<string>,
25+
): string {
26+
if (!duplicatedNames.has(skill.name)) return skill.name;
27+
const origin = skill.repoName
28+
? `${skill.source}: ${skill.repoName}`
29+
: skill.source;
30+
return `${skill.name} (${origin})`;
1731
}
1832

1933
export function LoopInstructionsFields({
@@ -34,9 +48,24 @@ export function LoopInstructionsFields({
3448
const localSkills: PickableSkill[] = (skillsQuery.data ?? []).flatMap(
3549
(skill) =>
3650
isUploadableSkillSource(skill.source)
37-
? [{ name: skill.name, source: skill.source, path: skill.path }]
51+
? [
52+
{
53+
name: skill.name,
54+
source: skill.source,
55+
path: skill.path,
56+
repoName: skill.repoName,
57+
},
58+
]
3859
: [],
3960
);
61+
const duplicatedNames = new Set(
62+
localSkills
63+
.filter(
64+
(skill, _, all) =>
65+
all.filter((other) => other.name === skill.name).length > 1,
66+
)
67+
.map((skill) => skill.name),
68+
);
4069

4170
const skill = values.skill;
4271
const skillUnavailableHint = localWorkspaces
@@ -59,8 +88,8 @@ export function LoopInstructionsFields({
5988
value: local.path,
6089
label:
6190
skill?.kind === "attached" && local.name === skill.name
62-
? `${local.name} (pick again to refresh the snapshot)`
63-
: local.name,
91+
? `${pickableSkillLabel(local, duplicatedNames)} (pick again to refresh the snapshot)`
92+
: pickableSkillLabel(local, duplicatedNames),
6493
})),
6594
];
6695

packages/ui/src/features/loops/hooks/useLoopSkillBundles.ts

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,78 @@ export interface LoopLocalSkillRef {
1313
path: string;
1414
}
1515

16+
type HostClient = ReturnType<typeof useHostTRPCClient>;
17+
18+
async function buildSkillUploads(
19+
hostClient: HostClient,
20+
skill: LoopLocalSkillRef,
21+
): Promise<LoopSchemas.LoopSkillBundleUpload[]> {
22+
const refs = await hostClient.skills.resolveDependencies.query([skill]);
23+
const ordered = [skill, ...refs.filter((ref) => ref.name !== skill.name)];
24+
const bundles = await Promise.all(
25+
ordered.map((ref) => hostClient.skills.bundleLocal.query(ref)),
26+
);
27+
return bundles.map((bundle) => ({
28+
file_name: bundle.fileName,
29+
skill_name: bundle.name,
30+
skill_source: bundle.source,
31+
content_sha256: bundle.contentSha256,
32+
bundle_format: "zip" as const,
33+
content_base64: bundle.contentBase64,
34+
}));
35+
}
36+
37+
/**
38+
* Bundles a local skill plus its skill dependencies via workspace-server into
39+
* upload payloads. Host-only: nothing is persisted anywhere, so callers can run
40+
* it before writing the loop and fail fast with no partial state.
41+
*/
42+
export function useBundleLocalSkill() {
43+
const hostClient = useHostTRPCClient();
44+
return useMutation<
45+
LoopSchemas.LoopSkillBundleUpload[],
46+
Error,
47+
LoopLocalSkillRef
48+
>({
49+
mutationFn: (skill) => buildSkillUploads(hostClient, skill),
50+
});
51+
}
52+
53+
/** Replaces a loop's stored bundles wholesale; an empty list detaches them all. */
54+
export function useReplaceLoopSkillBundles() {
55+
const loopsClient = useLoopsClient();
56+
const queryClient = useQueryClient();
57+
58+
return useMutation<
59+
LoopSchemas.Loop,
60+
Error,
61+
{ loopId: string; uploads: LoopSchemas.LoopSkillBundleUpload[] }
62+
>({
63+
mutationFn: async ({ loopId, uploads }) => {
64+
if (!loopsClient) throw new Error("Not authenticated");
65+
return await replaceLoopSkillBundles(
66+
loopsClient.client,
67+
loopsClient.projectId,
68+
loopId,
69+
uploads,
70+
);
71+
},
72+
onSuccess: (loop) => {
73+
queryClient.setQueryData(
74+
loopsKeys.detail(loopsClient?.projectId ?? null, loop.id),
75+
loop,
76+
);
77+
void queryClient.invalidateQueries({
78+
queryKey: loopsKeys.list(loopsClient?.projectId ?? null),
79+
});
80+
},
81+
});
82+
}
83+
1684
/**
17-
* Replaces a loop's attached skill bundles: bundles the given local skill plus
18-
* its skill dependencies via workspace-server and uploads the set wholesale, or
19-
* detaches every bundle when `skill` is null. Detaching never touches the host,
20-
* so it also works on hosts without a local filesystem.
85+
* Bundle-and-replace in one step, for refreshing an existing loop's snapshot
86+
* from the detail page. Detaching (null skill) never touches the host, so it
87+
* also works on hosts without a local filesystem.
2188
*/
2289
export function useSyncLoopSkillBundles() {
2390
const loopsClient = useLoopsClient();
@@ -31,25 +98,7 @@ export function useSyncLoopSkillBundles() {
3198
>({
3299
mutationFn: async ({ loopId, skill }) => {
33100
if (!loopsClient) throw new Error("Not authenticated");
34-
let uploads: LoopSchemas.LoopSkillBundleUpload[] = [];
35-
if (skill) {
36-
const refs = await hostClient.skills.resolveDependencies.query([skill]);
37-
const ordered = [
38-
skill,
39-
...refs.filter((ref) => ref.name !== skill.name),
40-
];
41-
const bundles = await Promise.all(
42-
ordered.map((ref) => hostClient.skills.bundleLocal.query(ref)),
43-
);
44-
uploads = bundles.map((bundle) => ({
45-
file_name: bundle.fileName,
46-
skill_name: bundle.name,
47-
skill_source: bundle.source,
48-
content_sha256: bundle.contentSha256,
49-
bundle_format: "zip" as const,
50-
content_base64: bundle.contentBase64,
51-
}));
52-
}
101+
const uploads = skill ? await buildSkillUploads(hostClient, skill) : [];
53102
return await replaceLoopSkillBundles(
54103
loopsClient.client,
55104
loopsClient.projectId,

packages/ui/src/features/loops/loopSkill.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,16 @@ export function parseSkillContext(
4545
return trimmed;
4646
}
4747

48+
/** The loop's attached bundles, tolerating a backend that predates the field. */
49+
export function loopSkillBundles(
50+
loop: LoopSchemas.Loop,
51+
): LoopSchemas.LoopSkillBundle[] {
52+
return loop.skill_bundles ?? [];
53+
}
54+
4855
/** The bundle whose skill the loop's instructions invoke; dependency bundles follow it. */
4956
export function primaryLoopSkillBundle(
5057
loop: LoopSchemas.Loop,
5158
): LoopSchemas.LoopSkillBundle | null {
52-
return loop.skill_bundles[0] ?? null;
59+
return loopSkillBundles(loop)[0] ?? null;
5360
}

0 commit comments

Comments
 (0)