Skip to content

Commit c40e35a

Browse files
committed
let loops run a skill instead of instructions
1 parent bd0a351 commit c40e35a

12 files changed

Lines changed: 1102 additions & 25 deletions

File tree

packages/api-client/src/loops.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export namespace LoopSchemas {
5050
| "failed"
5151
| "cancelled";
5252
export type LoopRunEnvironmentEnum = "local" | "cloud";
53+
export type LoopSkillSourceEnum = "user" | "repo" | "marketplace" | "codex";
5354

5455
export type LoopRepositoryEntry = {
5556
github_integration_id: number;
@@ -205,6 +206,32 @@ export namespace LoopSchemas {
205206
created_at: string;
206207
updated_at: string;
207208
triggers: Array<LoopTrigger>;
209+
/** Skill bundles attached to this loop, seeded into every fired run's sandbox.
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>;
214+
};
215+
216+
/** A skill bundle attached to a loop. `content_sha256` is the stored snapshot's
217+
* digest, so a client can detect drift from the local copy of the skill. */
218+
export type LoopSkillBundle = {
219+
id: string;
220+
skill_name: string;
221+
skill_source: LoopSkillSourceEnum;
222+
size: number;
223+
content_sha256: string;
224+
uploaded_at: string;
225+
};
226+
227+
/** One zipped local skill in a skill-bundle replace request. */
228+
export type LoopSkillBundleUpload = {
229+
file_name: string;
230+
skill_name: string;
231+
skill_source: LoopSkillSourceEnum;
232+
content_sha256: string;
233+
bundle_format: "zip";
234+
content_base64: string;
208235
};
209236

210237
/** Request body for create (all required fields present) and partial_update
@@ -392,6 +419,8 @@ const loopRunsPath = (projectId: string, loopId: string): string =>
392419
`/api/projects/${projectId}/loops/${loopId}/runs/`;
393420
const loopPreviewPath = (projectId: string, loopId: string): string =>
394421
`/api/projects/${projectId}/loops/${loopId}/preview/`;
422+
const loopSkillBundlesPath = (projectId: string, loopId: string): string =>
423+
`/api/projects/${projectId}/loops/${loopId}/skill_bundles/`;
395424

396425
function idempotencyHeader(
397426
idempotencyKey: string | undefined,
@@ -582,6 +611,17 @@ export async function listLoopRuns(
582611
});
583612
}
584613

614+
export async function replaceLoopSkillBundles(
615+
client: ApiClient,
616+
projectId: string,
617+
loopId: string,
618+
bundles: Array<LoopSchemas.LoopSkillBundleUpload>,
619+
): Promise<LoopSchemas.Loop> {
620+
return loopsRequest(client, "put", loopSkillBundlesPath(projectId, loopId), {
621+
body: { bundles },
622+
});
623+
}
624+
585625
export async function previewLoop(
586626
client: ApiClient,
587627
projectId: string,

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ArrowLeftIcon, RepeatIcon } from "@phosphor-icons/react";
22
import type { LoopSchemas } from "@posthog/api-client/loops";
3+
import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags";
4+
import { useHostTRPC } from "@posthog/host-router/react";
35
import {
46
AlertDialog,
57
AlertDialogClose,
@@ -20,6 +22,7 @@ import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore
2022
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
2123
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
2224
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
25+
import { Button as ActionButton } from "@posthog/ui/primitives/Button";
2326
import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp";
2427
import { systemTimezone } from "@posthog/ui/primitives/timezone";
2528
import { toast } from "@posthog/ui/primitives/toast";
@@ -28,7 +31,9 @@ import {
2831
navigateToLoops,
2932
} from "@posthog/ui/router/navigationBridge";
3033
import { track } from "@posthog/ui/shell/analytics";
34+
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
3135
import { Flex, Text } from "@radix-ui/themes";
36+
import { useQuery } from "@tanstack/react-query";
3237
import { useEffect, useRef, useState } from "react";
3338
import { useLoop } from "../hooks/useLoop";
3439
import {
@@ -37,6 +42,7 @@ import {
3742
useUpdateLoop,
3843
} from "../hooks/useLoopMutations";
3944
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
45+
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
4046
import {
4147
buildLoopEnabledToggledProps,
4248
buildLoopViewedProps,
@@ -51,6 +57,7 @@ import {
5157
summarizeNotificationDestinations,
5258
} from "../loopDisplay";
5359
import { formatLoopModel } from "../loopModels";
60+
import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill";
5461
import { LoopLoadError } from "./LoopFallbacks";
5562
import { LoopRunRow } from "./LoopRunRow";
5663

@@ -430,6 +437,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
430437
.join(" · ")}
431438
</SummaryRow>
432439

440+
{loopSkillBundles(loop).length > 0 ? (
441+
<SummaryRow label="Skill">
442+
<LoopSkillSummary loop={loop} />
443+
</SummaryRow>
444+
) : null}
445+
433446
<SummaryRow label="Repository">
434447
{loop.repositories.length > 0
435448
? loop.repositories.map((repo) => repo.full_name).join(", ")
@@ -465,8 +478,91 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
465478
);
466479
}
467480

481+
function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
482+
const { localWorkspaces } = useHostCapabilities();
483+
const trpc = useHostTRPC();
484+
const { data: localSkillData } = useQuery({
485+
...trpc.skills.list.queryOptions(),
486+
enabled: localWorkspaces,
487+
});
488+
const syncSkillBundles = useSyncLoopSkillBundles();
489+
490+
const primary = primaryLoopSkillBundle(loop);
491+
if (!primary) return null;
492+
const dependencyCount = loopSkillBundles(loop).length - 1;
493+
494+
// The one-click refresh must be unambiguous about which skill it snapshots: it
495+
// requires exactly one local skill matching the stored name AND source, so a
496+
// same-named skill from another source (say, an opened repo) can never silently
497+
// replace the loop's snapshot. Ambiguous cases go through the edit form, where
498+
// the picker shows each candidate.
499+
const candidates = (localSkillData ?? []).filter(
500+
(skill) =>
501+
skill.name === primary.skill_name &&
502+
skill.source === primary.skill_source,
503+
);
504+
const localMatch = candidates.length === 1 ? candidates[0] : undefined;
505+
const updateDisabledReason = !localWorkspaces
506+
? "updating the snapshot needs the desktop app"
507+
: localMatch
508+
? null
509+
: candidates.length > 1
510+
? `several local skills are named ${primary.skill_name}; pick the right one from the edit form`
511+
: `no local ${primary.skill_source} skill named ${primary.skill_name} was found on this machine`;
512+
513+
const handleUpdate = () => {
514+
if (!localMatch || !isUploadableSkillSource(localMatch.source)) return;
515+
syncSkillBundles.mutate(
516+
{
517+
loopId: loop.id,
518+
skill: {
519+
name: localMatch.name,
520+
source: localMatch.source,
521+
path: localMatch.path,
522+
},
523+
},
524+
{
525+
onSuccess: () => toast.success("Skill snapshot updated"),
526+
onError: (error) =>
527+
toast.error("Failed to update the skill snapshot", {
528+
description: error.message,
529+
}),
530+
},
531+
);
532+
};
533+
534+
return (
535+
<Flex align="center" gap="2" wrap="wrap">
536+
<Text className="text-[12.5px] text-gray-12">
537+
{primary.skill_name}
538+
{dependencyCount > 0
539+
? ` (+${dependencyCount} ${dependencyCount === 1 ? "dependency" : "dependencies"})`
540+
: ""}
541+
</Text>
542+
<Text
543+
className="text-[11px] text-gray-10"
544+
title={new Date(primary.uploaded_at).toLocaleString()}
545+
>
546+
Snapshot {primary.content_sha256.slice(0, 8)}
547+
</Text>
548+
<ActionButton
549+
variant="soft"
550+
color="gray"
551+
size="1"
552+
loading={syncSkillBundles.isPending}
553+
disabled={syncSkillBundles.isPending || !!updateDisabledReason}
554+
disabledReason={updateDisabledReason}
555+
onClick={handleUpdate}
556+
>
557+
Update from local skill
558+
</ActionButton>
559+
</Flex>
560+
);
561+
}
562+
468563
function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
469564
const updateLoop = useUpdateLoop(loop.id);
565+
const primarySkill = primaryLoopSkillBundle(loop);
470566
const [draft, setDraft] = useState<string | null>(null);
471567
// Escape reverts and blurs; skip the resulting onBlur save.
472568
const skipCommit = useRef(false);
@@ -528,6 +624,13 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
528624
}
529625
}}
530626
/>
627+
{primarySkill ? (
628+
<Text className="text-[11px] text-gray-10 leading-snug">
629+
This loop runs the {primarySkill.skill_name} skill: the leading /
630+
{primarySkill.skill_name} line invokes its attached snapshot. Editing
631+
here changes only the text; use Edit to change or detach the skill.
632+
</Text>
633+
) : null}
531634
</Flex>
532635
);
533636
}

0 commit comments

Comments
 (0)