Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/gh-source-auto-sync-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-web": minor
---

Surface automatic GitHub source-sync status in the UI (#1178): the skill detail page now shows a passive badge driven by the source's drift state — "Auto-synced", "Update in progress", "Upstream changed — version not bumped" (warning), or "Source unavailable" (error) — next to the existing "Synced from GitHub" chip and in the advanced GitHub-link panel, so owners see what auto-sync did without polling. The `skill.auto_synced` / `skill.auto_sync_failed` / `skill.source_broken` notifications render with proper labels, and opening a skill opportunistically refreshes a stale drift state once (no polling loop). The GET skill response now includes the drift fields that drive this (ornn-api).
13 changes: 13 additions & 0 deletions ornn-api/src/domains/skills/crud/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2204,6 +2204,19 @@ export class SkillService {
...(typeof skill.source.lastSyncedCommit === "string" && skill.source.lastSyncedCommit
? { lastSyncedCommit: skill.source.lastSyncedCommit }
: {}),
// Drift-detection state (#1176/#1177) — surfaced on GET so the
// frontend renders the auto-sync badge (#1178) from the last
// scheduled check without a bespoke endpoint. `etag` stays
// internal (a conditional-request cache detail, not client-facing).
...(typeof skill.source.upstreamHeadSha === "string" && skill.source.upstreamHeadSha
? { upstreamHeadSha: skill.source.upstreamHeadSha }
: {}),
...(skill.source.lastCheckedAt instanceof Date
? { lastCheckedAt: skill.source.lastCheckedAt.toISOString() }
: {}),
...(typeof skill.source.driftState === "string"
? { driftState: skill.source.driftState }
: {}),
}
: undefined,
agentsealScan: effectiveOverlay?.agentsealScan ?? null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const CATEGORY_LABEL: Record<NotificationCategory, string> = {
"quota.credits_granted": "Quota",
"launchPromo.codeDelivered": "Promo",
"skillset.member_unreadable": "Skillset",
"skill.source_broken": "Source",
"skill.auto_synced": "Auto-sync",
"skill.auto_sync_failed": "Auto-sync",
};

export interface NotificationDetailModalProps {
Expand Down
3 changes: 3 additions & 0 deletions ornn-web/src/components/skill/AdvancedOptionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { useToastStore } from "@/stores/toastStore";
import type { RefreshPreviewResponse } from "@/services/skillApi";
import type { SkillDetail } from "@/types/domain";
import { SourceDriftBadge } from "./SourceDriftBadge";
import { translateError } from "@/utils/translateError";

type AdvancedSettingId = "nyxid-service-binding" | "github-link";
Expand Down Expand Up @@ -571,6 +572,8 @@ function GithubLinkPanel({ skill, onClose }: { skill: SkillDetail; onClose: () =
})
: t("githubLink.neverSynced", "Linked but never synced.")}
</p>
{/* Auto-sync drift status (#1178) — same passive pill as the chip. */}
<SourceDriftBadge source={skill.source} className="mt-2" />
</div>
)}
</div>
Expand Down
66 changes: 66 additions & 0 deletions ornn-web/src/components/skill/GitHubOriginChip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* GitHubOriginChip tests (#1178) — the auto-sync badge shows next to the
* "Synced from GitHub" label, and the manual "Refresh from GitHub" override
* button remains available alongside it.
*
* @module components/skill/GitHubOriginChip.test
*/
import { describe, expect, it, afterEach, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { GitHubOriginChip } from "./GitHubOriginChip";
import type { SkillSource } from "@/types/domain";

afterEach(cleanup);

function gh(driftState?: SkillSource["driftState"]): SkillSource {
return {
type: "github",
repo: "o/r",
ref: "main",
path: "",
lastSyncedCommit: "abcdef1234567",
...(driftState ? { driftState } : {}),
};
}

describe("GitHubOriginChip", () => {
it("renders the drift badge AND keeps the manual refresh button", () => {
render(
<GitHubOriginChip
source={gh("changed_unversioned")}
canRefresh
isRefreshing={false}
onRefresh={() => {}}
/>,
);
// Passive badge from driftState…
expect(screen.getByText(/version not bumped/i)).toBeInTheDocument();
// …and the manual override is still there.
expect(screen.getByText("Refresh from GitHub")).toBeInTheDocument();
});

it("broken source shows the danger badge", () => {
render(
<GitHubOriginChip source={gh("broken")} canRefresh isRefreshing={false} onRefresh={() => {}} />,
);
expect(screen.getByText("Source unavailable")).toBeInTheDocument();
});

it("no badge before the first drift check, but the chip still renders", () => {
render(
<GitHubOriginChip source={gh()} canRefresh isRefreshing={false} onRefresh={() => {}} />,
);
expect(screen.getByText("Synced from GitHub")).toBeInTheDocument();
expect(screen.queryByText("Source unavailable")).not.toBeInTheDocument();
expect(screen.queryByText(/Auto-synced/)).not.toBeInTheDocument();
});

it("fires onRefresh when the manual button is clicked", () => {
const onRefresh = vi.fn();
render(
<GitHubOriginChip source={gh("in_sync")} canRefresh isRefreshing={false} onRefresh={onRefresh} />,
);
screen.getByText("Refresh from GitHub").click();
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});
4 changes: 4 additions & 0 deletions ornn-web/src/components/skill/GitHubOriginChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { useTranslation } from "react-i18next";
import type { SkillSource } from "@/types/domain";
import { SourceDriftBadge } from "./SourceDriftBadge";

interface GitHubOriginChipProps {
source: SkillSource | undefined;
Expand Down Expand Up @@ -79,6 +80,9 @@ export function GitHubOriginChip({
<span className="font-display text-xs uppercase tracking-wider text-meta">
{t("githubOrigin.label", "Synced from GitHub")}
</span>
{/* Passive auto-sync status (#1178) — read-only; the refresh button below
remains the manual override. */}
<SourceDriftBadge source={source} />
<a
href={repoUrl}
target="_blank"
Expand Down
65 changes: 65 additions & 0 deletions ornn-web/src/components/skill/SourceDriftBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* SourceDriftBadge tests (#1178) — each driftState renders the right copy +
* DESIGN.md state-token tone, and nothing renders for the no-drift / non-github
* cases so legacy skills look unchanged. react-i18next is globally mocked and
* resolves keys from en.json (with {{when}} interpolation).
*
* @module components/skill/SourceDriftBadge.test
*/
import { describe, expect, it, afterEach } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { SourceDriftBadge } from "./SourceDriftBadge";
import type { SkillSource } from "@/types/domain";

afterEach(cleanup);

function gh(
driftState?: SkillSource["driftState"],
extra: Partial<Extract<SkillSource, { type: "github" }>> = {},
): SkillSource {
return {
type: "github",
repo: "o/r",
ref: "main",
path: "",
...(driftState ? { driftState } : {}),
...extra,
};
}

describe("SourceDriftBadge", () => {
it("renders nothing before the first drift check (no driftState)", () => {
const { container } = render(<SourceDriftBadge source={gh()} />);
expect(container.firstChild).toBeNull();
});

it("renders nothing for an undefined source", () => {
const { container } = render(<SourceDriftBadge source={undefined} />);
expect(container.firstChild).toBeNull();
});

it("in_sync → 'Auto-synced' with success tone", () => {
render(<SourceDriftBadge source={gh("in_sync", { lastSyncedAt: new Date().toISOString() })} />);
const el = screen.getByText(/Auto-synced/);
expect(el.className).toContain("text-success");
});

it("drifted → 'Update in progress' with info tone", () => {
render(<SourceDriftBadge source={gh("drifted")} />);
const el = screen.getByText("Update in progress");
expect(el.className).toContain("text-info");
});

it("changed_unversioned → warning tone + explanatory copy", () => {
render(<SourceDriftBadge source={gh("changed_unversioned")} />);
const el = screen.getByText(/version not bumped/i);
expect(el.className).toContain("text-warning");
expect(el.getAttribute("title")).toMatch(/bump the version/i);
});

it("broken → danger tone", () => {
render(<SourceDriftBadge source={gh("broken")} />);
const el = screen.getByText("Source unavailable");
expect(el.className).toContain("text-danger");
});
});
93 changes: 93 additions & 0 deletions ornn-web/src/components/skill/SourceDriftBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Passive auto-sync status pill (#1178), driven by `source.driftState`.
*
* Read-only — it reflects what the scheduled drift check / auto-publish
* (#1176/#1177) recorded on the skill's GitHub source. It is NOT an action;
* the manual "Refresh from GitHub" button remains the override.
*
* Renders nothing until the first drift check has run (no `driftState`), or
* for non-GitHub sources — so legacy skills look unchanged.
*
* @module components/skill/SourceDriftBadge
*/

import { useTranslation } from "react-i18next";
import type { SkillSource } from "@/types/domain";

/** Compact relative-time ("5m ago", "2h ago", "3d ago", "just now"). */
function relativeTime(iso: string | undefined): string | null {
if (!iso) return null;
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return null;
const deltaSec = Math.round((then - Date.now()) / 1000);
const abs = Math.abs(deltaSec);
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
if (abs < 60) return rtf.format(Math.round(deltaSec), "second");
if (abs < 3600) return rtf.format(Math.round(deltaSec / 60), "minute");
if (abs < 86400) return rtf.format(Math.round(deltaSec / 3600), "hour");
return rtf.format(Math.round(deltaSec / 86400), "day");
}

export function SourceDriftBadge({
source,
className,
}: {
source: SkillSource | undefined;
className?: string;
}) {
const { t } = useTranslation();
if (!source || source.type !== "github" || !source.driftState) return null;

// Tone classes are DESIGN.md semantic state tokens only (no invented palette).
// State color is never the sole signal — each variant pairs copy + border.
let tone: string;
let label: string;
let title: string | undefined;

switch (source.driftState) {
case "in_sync": {
const when = relativeTime(source.lastSyncedAt);
tone = "text-success bg-success-soft border-success/40";
label = when
? t("sourceDrift.inSyncAt", "Auto-synced {{when}}", { when })
: t("sourceDrift.inSync", "Auto-synced");
title = t("sourceDrift.inSyncTitle", "Automatically kept in sync with the GitHub source.");
break;
}
case "drifted":
tone = "text-info bg-info-soft border-info/40";
label = t("sourceDrift.drifted", "Update in progress");
title = t(
"sourceDrift.driftedTitle",
"Upstream changed — a new version is being published automatically.",
);
break;
case "changed_unversioned":
tone = "text-warning bg-warning-soft border-warning/40";
label = t("sourceDrift.changedUnversioned", "Upstream changed — version not bumped");
title = t(
"sourceDrift.changedUnversionedTitle",
"The GitHub source changed but its SKILL.md version was not increased, so no new version was published. Bump the version upstream to resume auto-sync.",
);
break;
case "broken":
tone = "text-danger bg-danger-soft border-danger/30";
label = t("sourceDrift.broken", "Source unavailable");
title = t(
"sourceDrift.brokenTitle",
"The GitHub source could not be reached (deleted, made private, or the branch/tag was removed). Re-link the skill to resume auto-sync.",
);
break;
default:
return null;
}

return (
<span
className={`inline-flex items-center rounded border px-2 py-0.5 font-text text-xs ${tone} ${className ?? ""}`}
title={title}
>
{label}
</span>
);
}
5 changes: 5 additions & 0 deletions ornn-web/src/hooks/useSkillDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
useRefreshSkillFromSource,
} from "@/hooks/useSkills";
import { useSkillPackage } from "@/hooks/useSkillPackage";
import { useSourceDriftProbe } from "@/hooks/useSourceDriftProbe";
import {
useStartAudit,
useAuditSummaryByVersion,
Expand Down Expand Up @@ -94,6 +95,10 @@ export function useSkillDetail(idOrName: string | undefined) {
const refreshMutation = useRefreshSkillFromSource(idOrName ?? "");
const startAuditMutation = useStartAudit();

// Lazy on-view drift freshening (#1178) — re-reads the detail once when the
// github source's last drift check is stale. See useSourceDriftProbe.
useSourceDriftProbe(skill?.source, refetch);

// 7-day pulls totals — feeds the hero "↓ N pulls · 7d" status pill.
const last7d = useMemo(rangeLast7d, []);
const { data: pulls7d = [] } = useSkillPulls(skill?.name || skill?.guid, {
Expand Down
62 changes: 62 additions & 0 deletions ornn-web/src/hooks/useSourceDriftProbe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* useSourceDriftProbe tests (#1178) — the lazy on-view drift refetch fires at
* most once, only when stale, only for github sources, and never via a timer.
*
* @module hooks/useSourceDriftProbe.test
*/
import { describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useSourceDriftProbe, SOURCE_DRIFT_STALE_MS } from "./useSourceDriftProbe";
import type { SkillSource } from "@/types/domain";

function gh(lastCheckedAt?: string): SkillSource {
return {
type: "github",
repo: "o/r",
ref: "main",
path: "",
...(lastCheckedAt ? { lastCheckedAt } : {}),
};
}

const staleIso = () => new Date(Date.now() - SOURCE_DRIFT_STALE_MS - 60_000).toISOString();
const freshIso = () => new Date().toISOString();

describe("useSourceDriftProbe", () => {
it("refetches once when lastCheckedAt is stale", () => {
const refetch = vi.fn();
const source = gh(staleIso());
renderHook(() => useSourceDriftProbe(source, refetch));
expect(refetch).toHaveBeenCalledTimes(1);
});

it("refetches when the source was never checked (no lastCheckedAt)", () => {
const refetch = vi.fn();
renderHook(() => useSourceDriftProbe(gh(), refetch));
expect(refetch).toHaveBeenCalledTimes(1);
});

it("does NOT refetch when the last check is fresh", () => {
const refetch = vi.fn();
renderHook(() => useSourceDriftProbe(gh(freshIso()), refetch));
expect(refetch).not.toHaveBeenCalled();
});

it("does NOT refetch for a non-github / undefined source", () => {
const refetch = vi.fn();
renderHook(() => useSourceDriftProbe(undefined, refetch));
expect(refetch).not.toHaveBeenCalled();
});

it("fires at most once even across re-renders with a new source object", () => {
const refetch = vi.fn();
const { rerender } = renderHook(({ s }) => useSourceDriftProbe(s, refetch), {
initialProps: { s: gh(staleIso()) },
});
expect(refetch).toHaveBeenCalledTimes(1);
// New (still-stale) object → deps change, but the ref guard prevents a re-fire.
rerender({ s: gh(staleIso()) });
rerender({ s: gh(staleIso()) });
expect(refetch).toHaveBeenCalledTimes(1);
});
});
Loading