orionfold-relay 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli.js +9 -4
  2. package/package.json +1 -1
  3. package/src/app/api/data/clear/route.ts +8 -1
  4. package/src/app/api/data/seed/route.ts +8 -1
  5. package/src/app/profiles/page.tsx +7 -1
  6. package/src/app/settings/page.tsx +3 -1
  7. package/src/app/tables/page.tsx +10 -1
  8. package/src/components/apps/kit-view/slots/header.tsx +9 -3
  9. package/src/components/apps/run-now-button.tsx +7 -3
  10. package/src/components/apps/run-now-sheet.tsx +6 -2
  11. package/src/components/apps/run-now-toast.ts +27 -0
  12. package/src/components/profiles/profile-browser.tsx +36 -1
  13. package/src/components/profiles/profile-card.tsx +9 -2
  14. package/src/components/schedules/schedule-list.tsx +30 -1
  15. package/src/components/settings/data-management-section.tsx +56 -9
  16. package/src/components/shared/pack-pill.tsx +39 -0
  17. package/src/components/tables/table-browser.tsx +25 -1
  18. package/src/components/tables/table-grid.tsx +16 -4
  19. package/src/components/tables/table-list-table.tsx +12 -1
  20. package/src/components/workflows/blueprint-gallery.tsx +68 -4
  21. package/src/lib/apps/pack-of.ts +83 -0
  22. package/src/lib/apps/view-kits/data.ts +12 -1
  23. package/src/lib/apps/view-kits/header-status.ts +14 -0
  24. package/src/lib/apps/view-kits/kits/coach.ts +2 -1
  25. package/src/lib/apps/view-kits/kits/inbox.ts +2 -1
  26. package/src/lib/apps/view-kits/kits/ledger.ts +2 -1
  27. package/src/lib/apps/view-kits/kits/placeholder.ts +2 -1
  28. package/src/lib/apps/view-kits/kits/research.ts +2 -1
  29. package/src/lib/apps/view-kits/kits/tracker.ts +2 -1
  30. package/src/lib/apps/view-kits/kits/workflow-hub.ts +2 -1
  31. package/src/lib/apps/view-kits/types.ts +7 -1
  32. package/src/lib/chat/files/search.ts +5 -0
  33. package/src/lib/constants/status-families.ts +13 -1
  34. package/src/lib/data/seed-data/installed-packs.ts +97 -0
  35. package/src/lib/data/seed.ts +24 -0
  36. package/src/lib/environment/git-manager.ts +5 -0
  37. package/src/lib/packs/install.ts +19 -0
  38. package/src/lib/packs/templates/relay-agency-pro/base/manifest.yaml +8 -2
  39. package/src/lib/packs/templates/relay-agency-pro/base/seed/tables/engagements.json +28 -0
  40. package/src/lib/packs/templates/relay-agency-pro/pack.yaml +5 -1
  41. package/src/lib/plugins/examples/echo-server/plugin.yaml +1 -1
  42. package/src/lib/plugins/examples/finance-pack/plugin.yaml +1 -1
  43. package/src/lib/plugins/examples/reading-radar/plugin.yaml +1 -1
  44. package/src/lib/plugins/registry.ts +1 -1
  45. package/src/lib/plugins/sdk/types.ts +1 -1
package/dist/cli.js CHANGED
@@ -1186,7 +1186,7 @@ var CURRENT_PLUGIN_API_VERSION, CAPABILITY_VALUES, ORIGIN_VALUES, PrimitivesBund
1186
1186
  var init_types = __esm({
1187
1187
  "src/lib/plugins/sdk/types.ts"() {
1188
1188
  "use strict";
1189
- CURRENT_PLUGIN_API_VERSION = "0.25";
1189
+ CURRENT_PLUGIN_API_VERSION = "0.27";
1190
1190
  CAPABILITY_VALUES = ["fs", "net", "child_process", "env"];
1191
1191
  ORIGIN_VALUES = ["ainative-internal", "third-party"];
1192
1192
  PrimitivesBundleManifestSchema = z.object({
@@ -12982,7 +12982,7 @@ var init_registry6 = __esm({
12982
12982
  init_registry5();
12983
12983
  init_installer();
12984
12984
  init_schedule_spec();
12985
- SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.24"]);
12985
+ SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.26"]);
12986
12986
  pluginCache = null;
12987
12987
  lastLoadedPluginIds = /* @__PURE__ */ new Set();
12988
12988
  PluginTableSchema = z16.object({
@@ -25913,8 +25913,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25913
25913
  import yaml12 from "js-yaml";
25914
25914
  import semver from "semver";
25915
25915
  function relayCoreVersion() {
25916
- if (semver.valid("0.25.1")) {
25917
- return "0.25.1";
25916
+ if (semver.valid("0.27.0")) {
25917
+ return "0.27.0";
25918
25918
  }
25919
25919
  try {
25920
25920
  const root = getAppRoot(import.meta.dirname, 3);
@@ -26092,6 +26092,11 @@ async function installPack(source, options = {}) {
26092
26092
  const { reloadBlueprints: reloadBlueprints2 } = await Promise.resolve().then(() => (init_registry3(), registry_exports3));
26093
26093
  reloadBlueprints2();
26094
26094
  }
26095
+ try {
26096
+ const { revalidateTag } = await import("next/cache");
26097
+ revalidateTag(`app-runtime:${pack.meta.id}`, { expire: 0 });
26098
+ } catch {
26099
+ }
26095
26100
  return {
26096
26101
  packId: pack.meta.id,
26097
26102
  packVersion: pack.meta.version,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "description": "Orionfold Relay — a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.",
5
5
  "keywords": [
6
6
  "ai",
@@ -4,7 +4,14 @@ import { isDataOpsAllowed } from "@/lib/data/staging-gate";
4
4
 
5
5
  export async function POST() {
6
6
  if (!isDataOpsAllowed()) {
7
- return NextResponse.json(null, { status: 404 });
7
+ return NextResponse.json(
8
+ {
9
+ success: false,
10
+ error:
11
+ "Clearing data is a staging-only tool and is disabled on this build.",
12
+ },
13
+ { status: 403 }
14
+ );
8
15
  }
9
16
 
10
17
  try {
@@ -4,7 +4,14 @@ import { isDataOpsAllowed } from "@/lib/data/staging-gate";
4
4
 
5
5
  export async function POST() {
6
6
  if (!isDataOpsAllowed()) {
7
- return NextResponse.json(null, { status: 404 });
7
+ return NextResponse.json(
8
+ {
9
+ success: false,
10
+ error:
11
+ "Sample data seeding is a staging-only tool and is disabled on this build.",
12
+ },
13
+ { status: 403 }
14
+ );
8
15
  }
9
16
 
10
17
  try {
@@ -1,5 +1,6 @@
1
1
  import { listProfiles, isBuiltin } from "@/lib/agents/profiles/registry";
2
2
  import { sortProfilesByName } from "@/lib/agents/profiles/sort";
3
+ import { listApps } from "@/lib/apps/registry";
3
4
  import { ProfileBrowser } from "@/components/profiles/profile-browser";
4
5
  import { PageShell } from "@/components/shared/page-shell";
5
6
 
@@ -13,12 +14,17 @@ export default async function ProfilesPage() {
13
14
  }))
14
15
  );
15
16
 
17
+ // Installed packs — the source-of-truth set the client resolves each
18
+ // profile's pack provenance against (FEAT-8 pill / FEAT-7 filter via
19
+ // packOf). Just {id, name}; the pill renders the name, packOf gates on the id.
20
+ const installedPacks = listApps().map((a) => ({ id: a.id, name: a.name }));
21
+
16
22
  return (
17
23
  <PageShell
18
24
  title="Profiles"
19
25
  description="Browse and inspect agent profiles without blur-heavy detail surfaces."
20
26
  >
21
- <ProfileBrowser initialProfiles={profiles} />
27
+ <ProfileBrowser initialProfiles={profiles} installedPacks={installedPacks} />
22
28
  </PageShell>
23
29
  );
24
30
  }
@@ -14,10 +14,12 @@ import { ChannelsSection } from "@/components/settings/channels-section";
14
14
  import { InstanceSection } from "@/components/instance/instance-section";
15
15
  import { LicenseSection } from "@/components/settings/license-section";
16
16
  import { PageShell } from "@/components/shared/page-shell";
17
+ import { isDataOpsAllowed } from "@/lib/data/staging-gate";
17
18
 
18
19
  export const dynamic = "force-dynamic";
19
20
 
20
21
  export default function SettingsPage() {
22
+ const dataOpsAllowed = isDataOpsAllowed();
21
23
  return (
22
24
  <PageShell
23
25
  title="Settings"
@@ -38,7 +40,7 @@ export default function SettingsPage() {
38
40
  <BudgetGuardrailsSection />
39
41
  <PermissionsSections />
40
42
  <DatabaseSnapshotsSection />
41
- <DataManagementSection />
43
+ <DataManagementSection allowed={dataOpsAllowed} />
42
44
  </div>
43
45
  </PageShell>
44
46
  );
@@ -1,6 +1,7 @@
1
1
  import { listTables } from "@/lib/data/tables";
2
2
  import { db } from "@/lib/db";
3
3
  import { projects } from "@/lib/db/schema";
4
+ import { listApps } from "@/lib/apps/registry";
4
5
  import { TableBrowser } from "@/components/tables/table-browser";
5
6
  import { PageShell } from "@/components/shared/page-shell";
6
7
 
@@ -13,9 +14,17 @@ export default async function TablesPage() {
13
14
  .select({ id: projects.id, name: projects.name })
14
15
  .from(projects);
15
16
 
17
+ // Installed packs — mark tables whose projectId is a pack with a pack pill
18
+ // (FEAT-8) instead of the plain project name.
19
+ const installedPacks = listApps().map((a) => ({ id: a.id, name: a.name }));
20
+
16
21
  return (
17
22
  <PageShell title="Tables">
18
- <TableBrowser initialTables={tables} projects={projectList} />
23
+ <TableBrowser
24
+ initialTables={tables}
25
+ projects={projectList}
26
+ installedPacks={installedPacks}
27
+ />
19
28
  </PageShell>
20
29
  );
21
30
  }
@@ -22,8 +22,8 @@ interface HeaderSlotProps {
22
22
  export function HeaderSlotView({ slot, manifestPane }: HeaderSlotProps) {
23
23
  const { title, description, status, actions, cadenceChip, runNowBlueprintId, runNowVariables, periodChip, triggerSourceChip } = slot;
24
24
  return (
25
- <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
26
- <div className="min-w-0 flex-1">
25
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-x-4 sm:gap-y-2">
26
+ <div className="min-w-0 flex-1 sm:min-w-[16rem]">
27
27
  <h1 className="text-xl font-semibold tracking-tight line-clamp-2" title={title}>
28
28
  {title}
29
29
  </h1>
@@ -33,7 +33,13 @@ export function HeaderSlotView({ slot, manifestPane }: HeaderSlotProps) {
33
33
  </p>
34
34
  )}
35
35
  </div>
36
- <div className="flex flex-wrap items-center gap-2 mt-2 sm:mt-0 sm:flex-nowrap sm:shrink-0">
36
+ {/*
37
+ * Action group stays a single non-wrapping row (chips + Run + manifest
38
+ * never split across lines). When the title can't yield enough inline
39
+ * space, the PARENT's flex-wrap drops this whole group to its own row —
40
+ * an intentional stack, not an accidental two-line break (FEAT-4).
41
+ */}
42
+ <div className="flex flex-nowrap items-center gap-2 sm:shrink-0">
37
43
  {status && <StatusChip status={status} size="md" />}
38
44
  {cadenceChip && (
39
45
  <ScheduleCadenceChip
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
5
5
  import { Play } from "lucide-react";
6
6
  import { toast } from "sonner";
7
7
  import { RunNowSheet } from "./run-now-sheet";
8
+ import { toastDraftCreated } from "./run-now-toast";
8
9
  import type { BlueprintVariable } from "@/lib/workflows/blueprints/types";
9
10
 
10
11
  interface RunNowButtonProps {
@@ -63,12 +64,15 @@ export function RunNowButton({
63
64
  });
64
65
  if (!res.ok) {
65
66
  const err = (await res.json().catch(() => ({}))) as { error?: string };
66
- toast.error(err.error ?? `Failed to start (${res.status})`);
67
+ toast.error(err.error ?? `Failed to create draft (${res.status})`);
67
68
  return;
68
69
  }
69
- toast.success("Run started");
70
+ const body = (await res.json().catch(() => ({}))) as {
71
+ workflowId?: string;
72
+ };
73
+ toastDraftCreated(body.workflowId);
70
74
  } catch (err) {
71
- toast.error(err instanceof Error ? err.message : "Run failed");
75
+ toast.error(err instanceof Error ? err.message : "Could not create draft");
72
76
  } finally {
73
77
  setPending(false);
74
78
  }
@@ -13,6 +13,7 @@ import {
13
13
  } from "@/components/ui/sheet";
14
14
  import { VariableInput } from "@/components/workflows/variable-input";
15
15
  import { validateVariables } from "@/lib/workflows/blueprints/validate-variables";
16
+ import { toastDraftCreated } from "./run-now-toast";
16
17
  import type { BlueprintVariable } from "@/lib/workflows/blueprints/types";
17
18
 
18
19
  interface RunNowSheetProps {
@@ -74,10 +75,13 @@ export function RunNowSheet({
74
75
  }
75
76
  return;
76
77
  }
77
- toast.success("Run started");
78
+ const body = (await res.json().catch(() => ({}))) as {
79
+ workflowId?: string;
80
+ };
81
+ toastDraftCreated(body.workflowId);
78
82
  setOpen(false);
79
83
  } catch (err) {
80
- toast.error(err instanceof Error ? err.message : "Run failed");
84
+ toast.error(err instanceof Error ? err.message : "Could not create draft");
81
85
  } finally {
82
86
  setPending(false);
83
87
  }
@@ -0,0 +1,27 @@
1
+ import { toast } from "sonner";
2
+
3
+ /**
4
+ * BUG-4: "Run now" POSTs the blueprint instantiate endpoint, which creates a
5
+ * `draft` workflow and does NOT dispatch it. The old toast said "Run started",
6
+ * which is a lie — nothing runs until the user hits Execute on the workflow
7
+ * detail page. Surface the truth: a draft was created, and deep-link to where
8
+ * it can be executed.
9
+ *
10
+ * Shared by `run-now-button.tsx` (direct POST) and `run-now-sheet.tsx`
11
+ * (variable path) so the honest copy can't drift between the two.
12
+ */
13
+ export function toastDraftCreated(workflowId: string | undefined): void {
14
+ if (!workflowId) {
15
+ // No id to link to — still don't claim it started.
16
+ toast.success("Draft created. Open it in Workflows to Execute.");
17
+ return;
18
+ }
19
+ toast.success("Draft created. Open it in Workflows to Execute.", {
20
+ action: {
21
+ label: "Open workflow",
22
+ onClick: () => {
23
+ window.location.assign(`/workflows/${workflowId}`);
24
+ },
25
+ },
26
+ });
27
+ }
@@ -17,18 +17,52 @@ import { ProfileCard } from "@/components/profiles/profile-card";
17
17
  import { ProfileImportDialog } from "@/components/profiles/profile-import-dialog";
18
18
  import { RepoImportWizard } from "@/components/profiles/repo-import-wizard";
19
19
  import type { AgentProfile } from "@/lib/agents/profiles/types";
20
+ import { packOf } from "@/lib/apps/pack-of";
20
21
 
21
22
  interface ProfileWithBuiltin extends AgentProfile {
22
23
  isBuiltin?: boolean;
23
24
  }
24
25
 
26
+ /** The installed-pack identity a profile carries (FEAT-8). {id, name} only. */
27
+ export interface InstalledPackRef {
28
+ id: string;
29
+ name: string;
30
+ }
31
+
25
32
  interface ProfileBrowserProps {
26
33
  initialProfiles: AgentProfile[];
34
+ /** Installed packs — resolves each profile's pack provenance (FEAT-8). */
35
+ installedPacks?: InstalledPackRef[];
27
36
  }
28
37
 
29
- export function ProfileBrowser({ initialProfiles }: ProfileBrowserProps) {
38
+ export function ProfileBrowser({
39
+ initialProfiles,
40
+ installedPacks = [],
41
+ }: ProfileBrowserProps) {
30
42
  const router = useRouter();
31
43
  const [profiles, setProfiles] = useState<ProfileWithBuiltin[]>(initialProfiles);
44
+
45
+ // Stable {id → display name} lookup + the gated id-set for packOf. Rebuilt
46
+ // only when the installed packs change (never on a profile refresh), so the
47
+ // pill survives refreshProfiles — which recomputes from the refreshed id.
48
+ const packNameById = useMemo(
49
+ () => new Map(installedPacks.map((p) => [p.id, p.name])),
50
+ [installedPacks]
51
+ );
52
+ const installedPackIds = useMemo(
53
+ () => new Set(installedPacks.map((p) => p.id)),
54
+ [installedPacks]
55
+ );
56
+ const packNameFor = useCallback(
57
+ (profile: AgentProfile): string | null => {
58
+ const packId = packOf(
59
+ { kind: "profile", id: profile.id },
60
+ installedPackIds
61
+ );
62
+ return packId ? packNameById.get(packId) ?? null : null;
63
+ },
64
+ [installedPackIds, packNameById]
65
+ );
32
66
  const [search, setSearch] = useState("");
33
67
  const [domainFilter, setDomainFilter] = useState<
34
68
  "all" | "work" | "personal"
@@ -201,6 +235,7 @@ export function ProfileBrowser({ initialProfiles }: ProfileBrowserProps) {
201
235
  key={profile.id}
202
236
  profile={profile}
203
237
  isBuiltin={profile.isBuiltin}
238
+ packName={packNameFor(profile)}
204
239
  onClick={() => router.push(`/profiles/${profile.id}`)}
205
240
  />
206
241
  ))}
@@ -6,11 +6,14 @@ import { Download } from "lucide-react";
6
6
  import type { AgentRuntimeId } from "@/lib/agents/runtime/catalog";
7
7
  import { getSupportedRuntimes } from "@/lib/agents/profiles/compatibility";
8
8
  import { IconCircle, getProfileIcon, getDomainColors } from "@/lib/constants/card-icons";
9
+ import { PackPill } from "@/components/shared/pack-pill";
9
10
  import type { AgentProfile } from "@/lib/agents/profiles/types";
10
11
 
11
12
  interface ProfileCardProps {
12
13
  profile: AgentProfile;
13
14
  isBuiltin?: boolean;
15
+ /** Display name of the pack that installed this profile, or null (FEAT-8). */
16
+ packName?: string | null;
14
17
  onClick: () => void;
15
18
  }
16
19
 
@@ -30,7 +33,7 @@ const RUNTIME_SHORT_LABEL: Record<AgentRuntimeId, string> = {
30
33
  ollama: "Ollama (Local)",
31
34
  };
32
35
 
33
- export function ProfileCard({ profile, isBuiltin = false, onClick }: ProfileCardProps) {
36
+ export function ProfileCard({ profile, isBuiltin = false, packName = null, onClick }: ProfileCardProps) {
34
37
 
35
38
  return (
36
39
  <Card
@@ -83,7 +86,11 @@ export function ProfileCard({ profile, isBuiltin = false, onClick }: ProfileCard
83
86
  </div>
84
87
 
85
88
  <div className="flex items-center gap-3 text-xs text-muted-foreground">
86
- {profile.importMeta ? (
89
+ {/* Pack provenance outranks every other origin: a pack-installed
90
+ profile is never "Custom"/"Discovered" — it belongs to its pack. */}
91
+ {packName ? (
92
+ <PackPill packName={packName} />
93
+ ) : profile.importMeta ? (
87
94
  <span className="flex items-center gap-1.5">
88
95
  <Badge variant="outline" className="border-purple-200 text-purple-600 dark:border-purple-800 dark:text-purple-400">
89
96
  <Download className="mr-1 h-3 w-3" />
@@ -11,6 +11,8 @@ import { ScheduleStatusBadge } from "./schedule-status-badge";
11
11
  import { ConfirmDialog } from "@/components/shared/confirm-dialog";
12
12
  import { EmptyState } from "@/components/shared/empty-state";
13
13
  import { describeCron } from "@/lib/schedules/interval-parser";
14
+ import { PackPill } from "@/components/shared/pack-pill";
15
+ import { packOf } from "@/lib/apps/pack-of";
14
16
  import { Clock, Heart, Pause, Play, Trash2 } from "lucide-react";
15
17
  import { toast } from "sonner";
16
18
 
@@ -39,6 +41,9 @@ interface ScheduleListProps {
39
41
 
40
42
  export function ScheduleList({ projects, initialSelectedId }: ScheduleListProps) {
41
43
  const [schedules, setSchedules] = useState<Schedule[]>([]);
44
+ const [installedPacks, setInstalledPacks] = useState<
45
+ { id: string; name: string }[]
46
+ >([]);
42
47
  const [loaded, setLoaded] = useState(false);
43
48
  const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
44
49
  const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(
@@ -57,6 +62,24 @@ export function ScheduleList({ projects, initialSelectedId }: ScheduleListProps)
57
62
  refresh();
58
63
  }, [refresh]);
59
64
 
65
+ // Installed packs — a pack schedule's id is `app:<packId>:<sid>`, so packOf
66
+ // resolves provenance from the id alone (FEAT-8).
67
+ useEffect(() => {
68
+ fetch("/api/apps")
69
+ .then((r) => (r.ok ? r.json() : []))
70
+ .then((apps: Array<{ id: string; name: string }>) =>
71
+ setInstalledPacks(apps.map((a) => ({ id: a.id, name: a.name })))
72
+ )
73
+ .catch(() => {});
74
+ }, []);
75
+
76
+ const installedPackIds = new Set(installedPacks.map((p) => p.id));
77
+ const packNameById = new Map(installedPacks.map((p) => [p.id, p.name]));
78
+ const packNameForSchedule = (id: string): string | null => {
79
+ const packId = packOf({ kind: "schedule", id }, installedPackIds);
80
+ return packId ? packNameById.get(packId) ?? null : null;
81
+ };
82
+
60
83
  async function handlePauseResume(id: string, currentStatus: string) {
61
84
  const newStatus = currentStatus === "active" ? "paused" : "active";
62
85
  const res = await fetch(`/api/schedules/${id}`, {
@@ -163,7 +186,13 @@ export function ScheduleList({ projects, initialSelectedId }: ScheduleListProps)
163
186
  )}
164
187
  {sched.name}
165
188
  </CardTitle>
166
- <ScheduleStatusBadge status={sched.status} />
189
+ <div className="flex shrink-0 items-center gap-1.5">
190
+ {(() => {
191
+ const packName = packNameForSchedule(sched.id);
192
+ return packName ? <PackPill packName={packName} /> : null;
193
+ })()}
194
+ <ScheduleStatusBadge status={sched.status} />
195
+ </div>
167
196
  </div>
168
197
  </CardHeader>
169
198
  <CardContent>
@@ -15,7 +15,35 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
15
15
  import { toast } from "sonner";
16
16
  import { Loader2, Trash2, Database } from "lucide-react";
17
17
 
18
- export function DataManagementSection() {
18
+ /**
19
+ * Reads a JSON body defensively. A gated route returns an explanatory
20
+ * `{success:false,error}` body with a non-2xx status; a genuine network/parse
21
+ * failure has no parseable body. Distinguishing the two is what stops a
22
+ * deliberate gate (403) from being disguised as "Network error" (BUG-5).
23
+ */
24
+ async function readResult(
25
+ res: Response
26
+ ): Promise<{ ok: boolean; body: Record<string, unknown> | null }> {
27
+ let body: Record<string, unknown> | null = null;
28
+ try {
29
+ body = (await res.json()) as Record<string, unknown> | null;
30
+ } catch {
31
+ body = null;
32
+ }
33
+ return { ok: res.ok, body };
34
+ }
35
+
36
+ /** Prefer the route's explanatory reason; fall back to the HTTP status. */
37
+ function failureMessage(
38
+ res: Response,
39
+ body: Record<string, unknown> | null,
40
+ fallback: string
41
+ ): string {
42
+ if (body && typeof body.error === "string") return body.error;
43
+ return `${fallback} (HTTP ${res.status})`;
44
+ }
45
+
46
+ export function DataManagementSection({ allowed = true }: { allowed?: boolean }) {
19
47
  const [clearOpen, setClearOpen] = useState(false);
20
48
  const [seedOpen, setSeedOpen] = useState(false);
21
49
  const [loading, setLoading] = useState(false);
@@ -24,14 +52,14 @@ export function DataManagementSection() {
24
52
  setLoading(true);
25
53
  try {
26
54
  const res = await fetch("/api/data/clear", { method: "POST" });
27
- const data = await res.json();
28
- if (data.success) {
29
- const d = data.deleted;
55
+ const { ok, body } = await readResult(res);
56
+ if (ok && body?.success) {
57
+ const d = body.deleted as Record<string, number>;
30
58
  toast.success(
31
59
  `Cleared ${d.projects} projects, ${d.tasks} tasks, ${d.workflows} workflows, ${d.schedules} schedules, ${d.documents} documents, ${d.conversations} conversations, ${d.chatMessages} messages, ${d.learnedContext} learned context, ${d.views} views, ${d.usageLedger} usage entries, ${d.agentLogs} logs, ${d.notifications} notifications, ${d.sampleProfiles} sample profiles, ${d.files} files`
32
60
  );
33
61
  } else {
34
- toast.error(`Clear failed: ${data.error}`);
62
+ toast.error(failureMessage(res, body, "Clear failed"));
35
63
  }
36
64
  } catch {
37
65
  toast.error("Clear failed. Network error");
@@ -44,14 +72,14 @@ export function DataManagementSection() {
44
72
  setLoading(true);
45
73
  try {
46
74
  const res = await fetch("/api/data/seed", { method: "POST" });
47
- const data = await res.json();
48
- if (data.success) {
49
- const s = data.seeded;
75
+ const { ok, body } = await readResult(res);
76
+ if (ok && body?.success) {
77
+ const s = body.seeded as Record<string, number>;
50
78
  toast.success(
51
79
  `Seeded ${s.profiles} profiles, ${s.projects} projects, ${s.tasks} tasks, ${s.workflows} workflows, ${s.schedules} schedules, ${s.documents} documents, ${s.userTables} tables (${s.userTableRows} rows, ${s.tableViews} views, ${s.tableTriggers} triggers, ${s.tableRelationships} links), ${s.conversations} conversations, ${s.chatMessages} messages, ${s.agentMemory} memories, ${s.agentMessages} handoffs, ${s.channelConfigs} channels (${s.channelBindings} bindings), ${s.environmentScans} scans (${s.environmentArtifacts} artifacts, ${s.environmentCheckpoints} checkpoints, ${s.environmentTemplates} templates), ${s.workflowExecutionStats} workflow-stats, ${s.scheduleFiringMetrics} firing-metrics, ${s.usageLedger} usage entries, ${s.learnedContext} learned context, ${s.views} views, ${s.profileTestResults} test results, ${s.repoImports} repo imports, ${s.agentLogs} logs, ${s.notifications} notifications`
52
80
  );
53
81
  } else {
54
- toast.error(`Seed failed: ${data.error}`);
82
+ toast.error(failureMessage(res, body, "Seed failed"));
55
83
  }
56
84
  } catch {
57
85
  toast.error("Seed failed. Network error");
@@ -60,6 +88,25 @@ export function DataManagementSection() {
60
88
  }
61
89
  }
62
90
 
91
+ if (!allowed) {
92
+ return (
93
+ <Card className="surface-card">
94
+ <CardHeader>
95
+ <CardTitle>Data Management</CardTitle>
96
+ <CardDescription>
97
+ Reset or populate your Orionfold Relay instance
98
+ </CardDescription>
99
+ </CardHeader>
100
+ <CardContent>
101
+ <p className="text-sm text-muted-foreground">
102
+ Seeding and clearing sample data are staging-only tools. They are
103
+ turned off on this build so your real data stays safe.
104
+ </p>
105
+ </CardContent>
106
+ </Card>
107
+ );
108
+ }
109
+
63
110
  return (
64
111
  <>
65
112
  <Card className="surface-card">
@@ -0,0 +1,39 @@
1
+ import { Package } from "lucide-react";
2
+ import { Badge } from "@/components/ui/badge";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ interface PackPillProps {
6
+ /** The installed pack's display name (e.g. "Relay Agency"), from its manifest. */
7
+ packName: string;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * PackPill — provenance label marking a primitive (profile, blueprint, table,
13
+ * schedule) as installed by a pack (FEAT-8, spec:
14
+ * features/fix-app-shell-activation-redesign.md).
15
+ *
16
+ * Deliberately NOT a StatusChip / status-family: pack provenance is an open-set
17
+ * identity ("which pack"), not one of the 5 fixed status dimensions. It shares
18
+ * the visual system by building on the same `Badge` primitive StatusChip uses,
19
+ * with its own icon (package) and a distinct amber color family so it reads as
20
+ * a provenance marker — the sibling of the card's existing Imported/Discovered/
21
+ * Built-in badges, and outranks the "Custom" fallback (a pack-installed
22
+ * primitive is never "Custom").
23
+ */
24
+ export function PackPill({ packName, className }: PackPillProps) {
25
+ return (
26
+ <Badge
27
+ data-testid="pack-pill"
28
+ variant="outline"
29
+ title={`Installed by the ${packName} pack`}
30
+ className={cn(
31
+ "border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-400",
32
+ className
33
+ )}
34
+ >
35
+ <Package className="mr-1 h-3 w-3" />
36
+ {packName}
37
+ </Badge>
38
+ );
39
+ }
@@ -19,15 +19,37 @@ import { TableCreateSheet } from "./table-create-sheet";
19
19
  import { FilterBar } from "@/components/shared/filter-bar";
20
20
  import { EmptyState } from "@/components/shared/empty-state";
21
21
  import { Table2 } from "lucide-react";
22
+ import { packOf } from "@/lib/apps/pack-of";
22
23
  import type { TableWithRelations } from "./types";
23
24
 
24
25
  interface TableBrowserProps {
25
26
  initialTables: TableWithRelations[];
26
27
  projects: { id: string; name: string }[];
28
+ /** Installed packs — marks tables whose project is a pack (FEAT-8). */
29
+ installedPacks?: { id: string; name: string }[];
27
30
  }
28
31
 
29
- export function TableBrowser({ initialTables, projects }: TableBrowserProps) {
32
+ export function TableBrowser({
33
+ initialTables,
34
+ projects,
35
+ installedPacks = [],
36
+ }: TableBrowserProps) {
30
37
  const [tables, setTables] = useState(initialTables);
38
+ // {projectId → pack name} for pack-installed projects, via the shared
39
+ // resolver (tables associate to a pack by projectId === packId).
40
+ const installedPackIds = new Set(installedPacks.map((p) => p.id));
41
+ const packNameById = new Map(installedPacks.map((p) => [p.id, p.name]));
42
+ const packNameForProject = useCallback(
43
+ (projectId: string | null | undefined): string | null => {
44
+ const packId = packOf(
45
+ { kind: "table", id: "", projectId: projectId ?? undefined },
46
+ installedPackIds
47
+ );
48
+ return packId ? packNameById.get(packId) ?? null : null;
49
+ },
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ [installedPacks]
52
+ );
31
53
  const [view, setView] = useState<"table" | "grid">("table");
32
54
  const [search, setSearch] = useState("");
33
55
  const [sourceFilter, setSourceFilter] = useState<string>("all");
@@ -214,12 +236,14 @@ export function TableBrowser({ initialTables, projects }: TableBrowserProps) {
214
236
  onToggleSelectAll={toggleSelectAll}
215
237
  onSelect={navigate}
216
238
  onOpen={navigate}
239
+ packNameForProject={packNameForProject}
217
240
  />
218
241
  ) : (
219
242
  <TableGrid
220
243
  tables={filtered}
221
244
  onSelect={navigate}
222
245
  onOpen={navigate}
246
+ packNameForProject={packNameForProject}
223
247
  />
224
248
  )}
225
249
 
@@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { Table2 } from "lucide-react";
6
6
  import { tableSourceVariant } from "@/lib/constants/table-status";
7
+ import { PackPill } from "@/components/shared/pack-pill";
7
8
  import { formatRowCount, formatColumnCount } from "./utils";
8
9
  import type { TableWithRelations } from "./types";
9
10
 
@@ -11,9 +12,16 @@ interface TableGridProps {
11
12
  tables: TableWithRelations[];
12
13
  onSelect: (id: string) => void;
13
14
  onOpen: (id: string) => void;
15
+ /** Resolves a projectId to its pack display name, or null (FEAT-8). */
16
+ packNameForProject?: (projectId: string | null | undefined) => string | null;
14
17
  }
15
18
 
16
- export function TableGrid({ tables, onSelect, onOpen }: TableGridProps) {
19
+ export function TableGrid({
20
+ tables,
21
+ onSelect,
22
+ onOpen,
23
+ packNameForProject,
24
+ }: TableGridProps) {
17
25
  return (
18
26
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
19
27
  {tables.map((t) => (
@@ -49,9 +57,13 @@ export function TableGrid({ tables, onSelect, onOpen }: TableGridProps) {
49
57
  <div className="flex items-center gap-3 text-xs text-muted-foreground">
50
58
  <span>{formatColumnCount(t.columnCount)}</span>
51
59
  <span>{formatRowCount(t.rowCount)}</span>
52
- {t.projectName && (
53
- <span className="truncate">{t.projectName}</span>
54
- )}
60
+ {(() => {
61
+ const packName = packNameForProject?.(t.projectId);
62
+ if (packName) return <PackPill packName={packName} />;
63
+ return t.projectName ? (
64
+ <span className="truncate">{t.projectName}</span>
65
+ ) : null;
66
+ })()}
55
67
  </div>
56
68
  </CardContent>
57
69
  </Card>