Skip to content

Commit 3d8e904

Browse files
committed
address skill bundle review feedback
1 parent c18972c commit 3d8e904

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
@@ -207,8 +207,10 @@ export namespace LoopSchemas {
207207
updated_at: string;
208208
triggers: Array<LoopTrigger>;
209209
/** Skill bundles attached to this loop, seeded into every fired run's sandbox.
210-
* Replaced wholesale via `replaceLoopSkillBundles`, never through the loop write. */
211-
skill_bundles: Array<LoopSkillBundle>;
210+
* Replaced wholesale via `replaceLoopSkillBundles`, never through the loop write.
211+
* Optional because a backend that predates skill bundles omits the field; treat
212+
* absence as an empty list. */
213+
skill_bundles?: Array<LoopSkillBundle>;
212214
};
213215

214216
/** 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
@@ -51,7 +51,7 @@ import {
5151
nextScheduleRun,
5252
summarizeNotificationDestinations,
5353
} from "../loopDisplay";
54-
import { primaryLoopSkillBundle } from "../loopSkill";
54+
import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill";
5555
import { LoopLoadError } from "./LoopFallbacks";
5656
import { LoopRunRow } from "./LoopRunRow";
5757

@@ -377,7 +377,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
377377
.join(" · ")}
378378
</SummaryRow>
379379

380-
{loop.skill_bundles.length > 0 ? (
380+
{loopSkillBundles(loop).length > 0 ? (
381381
<SummaryRow label="Skill">
382382
<LoopSkillSummary loop={loop} />
383383
</SummaryRow>
@@ -429,18 +429,26 @@ function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
429429

430430
const primary = primaryLoopSkillBundle(loop);
431431
if (!primary) return null;
432-
const dependencyCount = loop.skill_bundles.length - 1;
433-
434-
const localMatch = (skillsQuery.data ?? []).find(
432+
const dependencyCount = loopSkillBundles(loop).length - 1;
433+
434+
// The one-click refresh must be unambiguous about which skill it snapshots: it
435+
// requires exactly one local skill matching the stored name AND source, so a
436+
// same-named skill from another source (say, an opened repo) can never silently
437+
// replace the loop's snapshot. Ambiguous cases go through the edit form, where
438+
// the picker shows each candidate.
439+
const candidates = (skillsQuery.data ?? []).filter(
435440
(skill) =>
436441
skill.name === primary.skill_name &&
437-
isUploadableSkillSource(skill.source),
442+
skill.source === primary.skill_source,
438443
);
444+
const localMatch = candidates.length === 1 ? candidates[0] : undefined;
439445
const updateDisabledReason = !localWorkspaces
440446
? "updating the snapshot needs the desktop app"
441447
: localMatch
442448
? null
443-
: `no local skill named ${primary.skill_name} was found on this machine`;
449+
: candidates.length > 1
450+
? `several local skills are named ${primary.skill_name}; pick the right one from the edit form`
451+
: `no local ${primary.skill_source} skill named ${primary.skill_name} was found on this machine`;
444452

445453
const handleUpdate = () => {
446454
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
@@ -19,8 +19,15 @@ import {
1919
import { Box, Flex, Text, TextField } from "@radix-ui/themes";
2020
import { type ReactNode, useEffect, useState } from "react";
2121
import { useAuthStateValue } from "../../auth/store";
22-
import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations";
23-
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
22+
import {
23+
useCreateLoop,
24+
useDeleteLoop,
25+
useUpdateLoop,
26+
} from "../hooks/useLoopMutations";
27+
import {
28+
useBundleLocalSkill,
29+
useReplaceLoopSkillBundles,
30+
} from "../hooks/useLoopSkillBundles";
2431
import { summarizeTrigger } from "../loopDisplay";
2532
import { useLoopDraftStore } from "../loopDraftStore";
2633
import {
@@ -34,7 +41,7 @@ import {
3441
loopToFormValues,
3542
normalizeLoopFormValues,
3643
} from "../loopFormTypes";
37-
import { buildSkillInstructions } from "../loopSkill";
44+
import { buildSkillInstructions, loopSkillBundles } from "../loopSkill";
3845
import { LoopBehaviorFields } from "./LoopBehaviorFields";
3946
import { LoopContextFields } from "./LoopContextFields";
4047
import { Field } from "./LoopFormPrimitives";
@@ -102,10 +109,14 @@ export function LoopForm({ loop }: LoopFormProps) {
102109

103110
const createLoop = useCreateLoop();
104111
const updateLoop = useUpdateLoop(loop?.id ?? "");
105-
const syncSkillBundles = useSyncLoopSkillBundles();
112+
const deleteLoop = useDeleteLoop();
113+
const bundleSkill = useBundleLocalSkill();
114+
const replaceSkillBundles = useReplaceLoopSkillBundles();
106115
const isSubmitting =
107116
(isEdit ? updateLoop.isPending : createLoop.isPending) ||
108-
syncSkillBundles.isPending;
117+
bundleSkill.isPending ||
118+
replaceSkillBundles.isPending ||
119+
deleteLoop.isPending;
109120
const canSubmit = isLoopFormValid(values) && !isSubmitting;
110121

111122
// Per-step gate for the Next button. The final Create button is gated on the
@@ -144,26 +155,52 @@ export function LoopForm({ loop }: LoopFormProps) {
144155
const handleSubmit = async () => {
145156
if (!canSubmit) return;
146157
const body = formValuesToLoopWrite(values);
158+
159+
// Bundling runs before anything is persisted: a missing or broken local
160+
// skill fails here with no partial state, instead of leaving a saved loop
161+
// whose `/skill-name` instructions have no matching bundle.
162+
let uploads: LoopSchemas.LoopSkillBundleUpload[] | null = null;
163+
if (values.skill?.kind === "local") {
164+
try {
165+
uploads = await bundleSkill.mutateAsync(values.skill);
166+
} catch (error) {
167+
toast.error("Failed to bundle the skill", {
168+
description: error instanceof Error ? error.message : undefined,
169+
});
170+
return;
171+
}
172+
}
173+
147174
try {
148175
const saved = isEdit
149176
? await updateLoop.mutateAsync(body)
150177
: await createLoop.mutateAsync(body);
151-
// The skill snapshot rides separately from the loop write: a locally
152-
// picked skill is bundled and uploaded, removing the skill detaches the
153-
// stored bundles, and an unchanged attached snapshot is left alone.
154-
const skillSync =
155-
values.skill?.kind === "local"
156-
? { loopId: saved.id, skill: values.skill }
157-
: values.skill === null && saved.skill_bundles.length > 0
158-
? { loopId: saved.id, skill: null }
159-
: null;
160-
if (skillSync) {
178+
const needsDetach =
179+
values.skill === null && loopSkillBundles(saved).length > 0;
180+
if (uploads || needsDetach) {
161181
try {
162-
await syncSkillBundles.mutateAsync(skillSync);
182+
await replaceSkillBundles.mutateAsync({
183+
loopId: saved.id,
184+
uploads: uploads ?? [],
185+
});
163186
} catch (error) {
187+
const description =
188+
error instanceof Error ? error.message : undefined;
189+
if (!isEdit) {
190+
// Roll the just-created loop back rather than leaving one that
191+
// fires `/skill-name` with no bundle behind it.
192+
await deleteLoop.mutateAsync(saved.id).catch(() => {});
193+
toast.error("Failed to create loop", { description });
194+
return;
195+
}
196+
// Keep the form open with its state intact: saving again retries
197+
// both the loop write and the skill upload.
164198
toast.error("Loop saved, but updating its skill failed", {
165-
description: error instanceof Error ? error.message : undefined,
199+
description: [description, "Save again to retry."]
200+
.filter(Boolean)
201+
.join(" "),
166202
});
203+
return;
167204
}
168205
}
169206
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)