Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/api-client/src/loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export namespace LoopSchemas {
| "failed"
| "cancelled";
export type LoopRunEnvironmentEnum = "local" | "cloud";
export type LoopSkillSourceEnum = "user" | "repo" | "marketplace" | "codex";

export type LoopRepositoryEntry = {
github_integration_id: number;
Expand Down Expand Up @@ -202,6 +203,32 @@ export namespace LoopSchemas {
created_at: string;
updated_at: string;
triggers: Array<LoopTrigger>;
/** Skill bundles attached to this loop, seeded into every fired run's sandbox.
* Replaced wholesale via `replaceLoopSkillBundles`, never through the loop write.
* Optional because a backend that predates skill bundles omits the field; treat
* absence as an empty list. */
skill_bundles?: Array<LoopSkillBundle>;
};

/** A skill bundle attached to a loop. `content_sha256` is the stored snapshot's
* digest, so a client can detect drift from the local copy of the skill. */
export type LoopSkillBundle = {
id: string;
skill_name: string;
skill_source: LoopSkillSourceEnum;
size: number;
content_sha256: string;
uploaded_at: string;
};

/** One zipped local skill in a skill-bundle replace request. */
export type LoopSkillBundleUpload = {
file_name: string;
skill_name: string;
skill_source: LoopSkillSourceEnum;
content_sha256: string;
bundle_format: "zip";
content_base64: string;
};

/** Request body for create (all required fields present) and partial_update
Expand Down Expand Up @@ -389,6 +416,8 @@ const loopRunsPath = (projectId: string, loopId: string): string =>
`/api/projects/${projectId}/loops/${loopId}/runs/`;
const loopPreviewPath = (projectId: string, loopId: string): string =>
`/api/projects/${projectId}/loops/${loopId}/preview/`;
const loopSkillBundlesPath = (projectId: string, loopId: string): string =>
`/api/projects/${projectId}/loops/${loopId}/skill_bundles/`;

function idempotencyHeader(
idempotencyKey: string | undefined,
Expand Down Expand Up @@ -579,6 +608,17 @@ export async function listLoopRuns(
});
}

export async function replaceLoopSkillBundles(
client: ApiClient,
projectId: string,
loopId: string,
bundles: Array<LoopSchemas.LoopSkillBundleUpload>,
): Promise<LoopSchemas.Loop> {
return loopsRequest(client, "put", loopSkillBundlesPath(projectId, loopId), {
body: { bundles },
});
}

export async function previewLoop(
client: ApiClient,
projectId: string,
Expand Down
103 changes: 103 additions & 0 deletions packages/ui/src/features/loops/components/LoopDetailView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ArrowLeftIcon, RepeatIcon } from "@phosphor-icons/react";
import type { LoopSchemas } from "@posthog/api-client/loops";
import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags";
import { useHostTRPC } from "@posthog/host-router/react";
import {
AlertDialog,
AlertDialogClose,
Expand All @@ -17,14 +19,17 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
import { Button as ActionButton } from "@posthog/ui/primitives/Button";
import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp";
import { systemTimezone } from "@posthog/ui/primitives/timezone";
import { toast } from "@posthog/ui/primitives/toast";
import {
navigateToEditLoop,
navigateToLoops,
} from "@posthog/ui/router/navigationBridge";
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
import { Flex, Text } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useLoop } from "../hooks/useLoop";
import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel";
Expand All @@ -34,13 +39,15 @@ import {
useUpdateLoop,
} from "../hooks/useLoopMutations";
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
import {
describeTrigger,
loopStatusColor,
loopStatusLabel,
nextScheduleRun,
summarizeNotificationDestinations,
} from "../loopDisplay";
import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill";
import { LoopLoadError } from "./LoopFallbacks";
import { LoopRunRow } from "./LoopRunRow";

Expand Down Expand Up @@ -323,6 +330,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
.join(" · ")}
</SummaryRow>

{loopSkillBundles(loop).length > 0 ? (
<SummaryRow label="Skill">
<LoopSkillSummary loop={loop} />
</SummaryRow>
) : null}

<SummaryRow label="Repository">
{loop.repositories.length > 0
? loop.repositories.map((repo) => repo.full_name).join(", ")
Expand Down Expand Up @@ -358,8 +371,91 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
);
}

function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
const { localWorkspaces } = useHostCapabilities();
const trpc = useHostTRPC();
const { data: localSkillData } = useQuery({
...trpc.skills.list.queryOptions(),
enabled: localWorkspaces,
});
const syncSkillBundles = useSyncLoopSkillBundles();

const primary = primaryLoopSkillBundle(loop);
if (!primary) return null;
const dependencyCount = loopSkillBundles(loop).length - 1;

// The one-click refresh must be unambiguous about which skill it snapshots: it
// requires exactly one local skill matching the stored name AND source, so a
// same-named skill from another source (say, an opened repo) can never silently
// replace the loop's snapshot. Ambiguous cases go through the edit form, where
// the picker shows each candidate.
const candidates = (localSkillData ?? []).filter(
(skill) =>
skill.name === primary.skill_name &&
skill.source === primary.skill_source,
);
const localMatch = candidates.length === 1 ? candidates[0] : undefined;
const updateDisabledReason = !localWorkspaces
? "updating the snapshot needs the desktop app"
: localMatch
? null
: candidates.length > 1
? `several local skills are named ${primary.skill_name}; pick the right one from the edit form`
: `no local ${primary.skill_source} skill named ${primary.skill_name} was found on this machine`;

const handleUpdate = () => {
if (!localMatch || !isUploadableSkillSource(localMatch.source)) return;
syncSkillBundles.mutate(
{
loopId: loop.id,
skill: {
name: localMatch.name,
source: localMatch.source,
path: localMatch.path,
},
},
{
onSuccess: () => toast.success("Skill snapshot updated"),
onError: (error) =>
toast.error("Failed to update the skill snapshot", {
description: error.message,
}),
},
);
};

return (
<Flex align="center" gap="2" wrap="wrap">
<Text className="text-[12.5px] text-gray-12">
{primary.skill_name}
{dependencyCount > 0
? ` (+${dependencyCount} ${dependencyCount === 1 ? "dependency" : "dependencies"})`
: ""}
</Text>
<Text
className="text-[11px] text-gray-10"
title={new Date(primary.uploaded_at).toLocaleString()}
>
Snapshot {primary.content_sha256.slice(0, 8)}
</Text>
<ActionButton
variant="soft"
color="gray"
size="1"
loading={syncSkillBundles.isPending}
disabled={syncSkillBundles.isPending || !!updateDisabledReason}
disabledReason={updateDisabledReason}
onClick={handleUpdate}
>
Update from local skill
</ActionButton>
</Flex>
);
}

function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
const updateLoop = useUpdateLoop(loop.id);
const primarySkill = primaryLoopSkillBundle(loop);
const [draft, setDraft] = useState<string | null>(null);
// Escape reverts and blurs; skip the resulting onBlur save.
const skipCommit = useRef(false);
Expand Down Expand Up @@ -421,6 +517,13 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
}
}}
/>
{primarySkill ? (
<Text className="text-[11px] text-gray-10 leading-snug">
This loop runs the {primarySkill.skill_name} skill: the leading /
{primarySkill.skill_name} line invokes its attached snapshot. Editing
here changes only the text; use Edit to change or detach the skill.
</Text>
) : null}
</Flex>
);
}
Expand Down
112 changes: 92 additions & 20 deletions packages/ui/src/features/loops/components/LoopForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,18 @@ import {
navigateToLoopDetail,
navigateToLoops,
} from "@posthog/ui/router/navigationBridge";
import { Box, Flex, Text, TextArea, TextField } from "@radix-ui/themes";
import { Box, Flex, Text, TextField } from "@radix-ui/themes";
import { type ReactNode, useEffect, useState } from "react";
import { useAuthStateValue } from "../../auth/store";
import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations";
import {
useCreateLoop,
useDeleteLoop,
useUpdateLoop,
} from "../hooks/useLoopMutations";
import {
useBundleLocalSkill,
useReplaceLoopSkillBundles,
} from "../hooks/useLoopSkillBundles";
import { summarizeTrigger } from "../loopDisplay";
import { useLoopDraftStore } from "../loopDraftStore";
import {
Expand All @@ -33,12 +41,14 @@ import {
loopToFormValues,
normalizeLoopFormValues,
} from "../loopFormTypes";
import { buildSkillInstructions, loopSkillBundles } from "../loopSkill";
import { LoopBehaviorFields } from "./LoopBehaviorFields";
import { LoopContextFields } from "./LoopContextFields";
import { Field } from "./LoopFormPrimitives";
import { LoopModelFields } from "./LoopModelFields";
import { LoopNotificationsFields } from "./LoopNotificationsFields";
import { LoopRepositoryPicker } from "./LoopRepositoryPicker";
import { LoopInstructionsFields } from "./LoopSkillFields";
import { LoopTriggerEditor } from "./LoopTriggerEditor";

const VISIBILITY_OPTIONS: {
Expand Down Expand Up @@ -99,13 +109,21 @@ export function LoopForm({ loop }: LoopFormProps) {

const createLoop = useCreateLoop();
const updateLoop = useUpdateLoop(loop?.id ?? "");
const isSubmitting = isEdit ? updateLoop.isPending : createLoop.isPending;
const deleteLoop = useDeleteLoop();
const bundleSkill = useBundleLocalSkill();
const replaceSkillBundles = useReplaceLoopSkillBundles();
const isSubmitting =
(isEdit ? updateLoop.isPending : createLoop.isPending) ||
bundleSkill.isPending ||
replaceSkillBundles.isPending ||
deleteLoop.isPending;
const canSubmit = isLoopFormValid(values) && !isSubmitting;

// Per-step gate for the Next button. The final Create button is gated on the
// whole form being valid, so jumping between steps can't submit a bad loop.
const stepComplete = [
!!values.name.trim() && !!values.instructions.trim(),
!!values.name.trim() &&
(values.skill !== null || !!values.instructions.trim()),
values.triggers.every(isTriggerDraftValid),
true,
isLoopFormValid(values),
Expand Down Expand Up @@ -137,14 +155,68 @@ export function LoopForm({ loop }: LoopFormProps) {
const handleSubmit = async () => {
if (!canSubmit) return;
const body = formValuesToLoopWrite(values);

// Bundling runs before anything is persisted: a missing or broken local
// skill fails here with no partial state, instead of leaving a saved loop
// whose `/skill-name` instructions have no matching bundle.
let uploads: LoopSchemas.LoopSkillBundleUpload[] | null = null;
if (values.skill?.kind === "local") {
try {
uploads = await bundleSkill.mutateAsync(values.skill);
} catch (error) {
toast.error("Failed to bundle the skill", {
description: error instanceof Error ? error.message : undefined,
});
return;
}
}

try {
if (isEdit) {
const updated = await updateLoop.mutateAsync(body);
navigateToLoopDetail(updated.id);
} else {
const created = await createLoop.mutateAsync(body);
navigateToLoopDetail(created.id);
const saved = isEdit
? await updateLoop.mutateAsync(body)
: await createLoop.mutateAsync(body);
const needsDetach =
values.skill === null && loopSkillBundles(saved).length > 0;
if (uploads || needsDetach) {
try {
await replaceSkillBundles.mutateAsync({
loopId: saved.id,
uploads: uploads ?? [],
});
} catch (error) {
const description =
error instanceof Error ? error.message : undefined;
if (!isEdit) {
// Roll the just-created loop back rather than leaving one that
// fires `/skill-name` with no bundle behind it. If the rollback
// itself fails, an orphaned loop exists — say so instead of
// pretending nothing was created.
try {
await deleteLoop.mutateAsync(saved.id);
toast.error("Failed to create loop", { description });
} catch {
toast.error("Loop created, but attaching its skill failed", {
description: [
description,
`Delete "${saved.name}" or re-save it from Edit.`,
]
.filter(Boolean)
.join(" "),
});
}
return;
}
// Keep the form open with its state intact: saving again retries
// both the loop write and the skill upload.
toast.error("Loop saved, but updating its skill failed", {
description: [description, "Save again to retry."]
.filter(Boolean)
.join(" "),
});
return;
}
}
navigateToLoopDetail(saved.id);
} catch (error) {
const safetyLimit =
error instanceof LoopsApiError ? error.safetyLimit : null;
Expand Down Expand Up @@ -202,15 +274,11 @@ export function LoopForm({ loop }: LoopFormProps) {
onChange={(e) => patch({ description: e.target.value })}
/>
</Field>
<Field label="Instructions" required>
<TextArea
value={values.instructions}
placeholder="Summarize failing CI runs from the last 24 hours and post the summary to #eng-standup."
disabled={isSubmitting}
className="min-h-[220px] text-[13px] leading-relaxed"
onChange={(e) => patch({ instructions: e.target.value })}
/>
</Field>
<LoopInstructionsFields
values={values}
disabled={isSubmitting}
onPatch={patch}
/>
</Step>
) : null}

Expand Down Expand Up @@ -540,7 +608,11 @@ function ReviewList({
/>
<ReviewRow
label="Prompt"
value={values.instructions.trim() || "No prompt"}
value={
values.skill
? buildSkillInstructions(values.skill.name, values.skillContext)
: values.instructions.trim() || "No prompt"
}
multiline
/>
<ReviewRow
Expand Down
Loading
Loading