orionfold-relay 0.24.0 → 0.25.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 (35) hide show
  1. package/dist/cli.js +111 -15
  2. package/package.json +1 -1
  3. package/src/app/api/snapshots/[id]/restore/route.ts +6 -0
  4. package/src/app/api/snapshots/route.ts +6 -0
  5. package/src/components/apps/app-detail-actions.tsx +10 -23
  6. package/src/components/apps/kit-view/kit-view.tsx +8 -1
  7. package/src/components/apps/kit-view/slots/header.tsx +3 -3
  8. package/src/components/apps/kit-view/slots/manifest-sheet.tsx +2 -2
  9. package/src/components/apps/last-run-card.tsx +91 -2
  10. package/src/components/apps/ledger-hero-panel.tsx +2 -1
  11. package/src/components/chat/app-composer-hero.tsx +1 -1
  12. package/src/components/chat/chat-empty-state.tsx +3 -3
  13. package/src/components/dashboard/welcome-landing.tsx +4 -4
  14. package/src/components/onboarding/runtime-preference-modal.tsx +8 -8
  15. package/src/components/profiles/profile-card.tsx +18 -8
  16. package/src/components/settings/license-section.tsx +5 -0
  17. package/src/components/workflows/shared/workflow-header.tsx +114 -0
  18. package/src/lib/agents/runtime/ollama-adapter.ts +11 -6
  19. package/src/lib/agents/runtime/ollama-model-resolver.ts +76 -0
  20. package/src/lib/apps/view-kits/data.ts +79 -0
  21. package/src/lib/apps/view-kits/kits/workflow-hub.ts +48 -9
  22. package/src/lib/apps/view-kits/types.ts +38 -0
  23. package/src/lib/chat/ollama-engine.ts +17 -4
  24. package/src/lib/environment/workspace-context.ts +6 -0
  25. package/src/lib/notifications/actionable.ts +11 -2
  26. package/src/lib/packs/templates/relay-agency-pro/base/manifest.yaml +8 -2
  27. package/src/lib/packs/templates/relay-agency-pro/pack.yaml +34 -14
  28. package/src/lib/plugins/examples/echo-server/plugin.yaml +1 -1
  29. package/src/lib/plugins/examples/finance-pack/plugin.yaml +1 -1
  30. package/src/lib/plugins/examples/reading-radar/plugin.yaml +1 -1
  31. package/src/lib/plugins/registry.ts +1 -1
  32. package/src/lib/plugins/sdk/types.ts +1 -1
  33. package/src/lib/snapshots/snapshot-manager.ts +40 -6
  34. package/src/lib/workflows/engine.ts +106 -3
  35. package/src/lib/workflows/types.ts +13 -0
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.24";
1189
+ CURRENT_PLUGIN_API_VERSION = "0.25";
1190
1190
  CAPABILITY_VALUES = ["fs", "net", "child_process", "env"];
1191
1191
  ORIGIN_VALUES = ["ainative-internal", "third-party"];
1192
1192
  PrimitivesBundleManifestSchema = z.object({
@@ -7135,7 +7135,13 @@ function getWorkspaceContext() {
7135
7135
  gitBranch = execFileSync2("git", ["branch", "--show-current"], {
7136
7136
  cwd,
7137
7137
  encoding: "utf-8",
7138
- timeout: 3e3
7138
+ timeout: 3e3,
7139
+ // Route git's stderr to a pipe (captured on the thrown error, then
7140
+ // discarded) instead of inheriting the console. Without this, a
7141
+ // non-git cwd leaks a raw `fatal: not a git repository` to a
7142
+ // customer's first-run log before the catch swallows the exit code.
7143
+ // Mirrors src/lib/instance/git-ops.ts run().
7144
+ stdio: ["ignore", "pipe", "pipe"]
7139
7145
  }).trim() || null;
7140
7146
  } catch {
7141
7147
  }
@@ -12134,7 +12140,7 @@ async function listPendingApprovalPayloads(limit = 20) {
12134
12140
  const isBatchProposal = row.type === "context_proposal_batch";
12135
12141
  const parsedInput = parseNotificationToolInput(row.toolInput);
12136
12142
  let effectiveWorkflowId = row.workflowId;
12137
- if (!row.taskId && row.toolName === "WorkflowCheckpoint" && row.toolInput) {
12143
+ if (!row.taskId && (row.toolName === "WorkflowCheckpoint" || row.toolName === "AskUserQuestion") && row.toolInput) {
12138
12144
  try {
12139
12145
  const parsed = typeof row.toolInput === "string" ? JSON.parse(row.toolInput) : row.toolInput;
12140
12146
  effectiveWorkflowId = parsed.workflowId ?? null;
@@ -12963,7 +12969,7 @@ var init_registry6 = __esm({
12963
12969
  init_registry5();
12964
12970
  init_installer();
12965
12971
  init_schedule_spec();
12966
- SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.23"]);
12972
+ SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.24"]);
12967
12973
  pluginCache = null;
12968
12974
  lastLoadedPluginIds = /* @__PURE__ */ new Set();
12969
12975
  PluginTableSchema = z16.object({
@@ -21726,6 +21732,41 @@ var init_openai_direct = __esm({
21726
21732
  }
21727
21733
  });
21728
21734
 
21735
+ // src/lib/agents/runtime/ollama-model-resolver.ts
21736
+ async function listPulledOllamaModels(baseUrl) {
21737
+ try {
21738
+ const response = await fetch(`${baseUrl}/api/tags`, {
21739
+ signal: AbortSignal.timeout(5e3)
21740
+ });
21741
+ if (!response.ok) return [];
21742
+ const data = await response.json();
21743
+ return (data.models ?? []).map((m) => m.name).filter((n) => typeof n === "string" && n.length > 0);
21744
+ } catch {
21745
+ return [];
21746
+ }
21747
+ }
21748
+ async function resolveOllamaModel(baseUrl, requestedModel, defaultModel) {
21749
+ const explicit = requestedModel?.trim() || defaultModel?.trim();
21750
+ if (explicit) return explicit;
21751
+ const pulled = await listPulledOllamaModels(baseUrl);
21752
+ if (pulled.length > 0) return pulled[0];
21753
+ throw new OllamaModelNotConfiguredError();
21754
+ }
21755
+ var OllamaModelNotConfiguredError;
21756
+ var init_ollama_model_resolver = __esm({
21757
+ "src/lib/agents/runtime/ollama-model-resolver.ts"() {
21758
+ "use strict";
21759
+ OllamaModelNotConfiguredError = class extends Error {
21760
+ constructor(message) {
21761
+ super(
21762
+ message ?? "No Ollama model is configured. Pull a model (e.g. `ollama pull llama3.2`) or set a default in Settings \u2192 Ollama."
21763
+ );
21764
+ this.name = "OllamaModelNotConfiguredError";
21765
+ }
21766
+ };
21767
+ }
21768
+ });
21769
+
21729
21770
  // src/lib/agents/runtime/ollama-adapter.ts
21730
21771
  import { eq as eq38 } from "drizzle-orm";
21731
21772
  async function getOllamaBaseUrl() {
@@ -21734,11 +21775,11 @@ async function getOllamaBaseUrl() {
21734
21775
  const url = await getSetting2(SETTINGS_KEYS2.OLLAMA_BASE_URL);
21735
21776
  return url || DEFAULT_OLLAMA_BASE_URL;
21736
21777
  }
21737
- async function getOllamaModel() {
21778
+ async function getOllamaModel(baseUrl) {
21738
21779
  const { getSetting: getSetting2 } = await Promise.resolve().then(() => (init_helpers(), helpers_exports));
21739
21780
  const { SETTINGS_KEYS: SETTINGS_KEYS2 } = await Promise.resolve().then(() => (init_settings(), settings_exports));
21740
- const model = await getSetting2(SETTINGS_KEYS2.OLLAMA_DEFAULT_MODEL);
21741
- return model || DEFAULT_OLLAMA_MODEL;
21781
+ const defaultModel = await getSetting2(SETTINGS_KEYS2.OLLAMA_DEFAULT_MODEL);
21782
+ return resolveOllamaModel(baseUrl, null, defaultModel);
21742
21783
  }
21743
21784
  async function streamOllamaChat(baseUrl, model, messages, signal) {
21744
21785
  const response = await fetch(`${baseUrl}/api/chat`, {
@@ -21833,7 +21874,7 @@ async function executeOllamaTask(taskId) {
21833
21874
  await db.update(tasks).set({ status: "running", updatedAt: /* @__PURE__ */ new Date() }).where(eq38(tasks.id, taskId));
21834
21875
  const ctx = await buildTaskQueryContext(task, agentProfileId);
21835
21876
  const baseUrl = await getOllamaBaseUrl();
21836
- const modelId = await getOllamaModel();
21877
+ const modelId = await getOllamaModel(baseUrl);
21837
21878
  const messages = [];
21838
21879
  if (ctx.systemInstructions) {
21839
21880
  messages.push({ role: "system", content: ctx.systemInstructions });
@@ -21924,7 +21965,7 @@ async function executeOllamaTask(taskId) {
21924
21965
  }
21925
21966
  async function runOllamaTaskAssist(input) {
21926
21967
  const baseUrl = await getOllamaBaseUrl();
21927
- const modelId = await getOllamaModel();
21968
+ const modelId = await getOllamaModel(baseUrl);
21928
21969
  const profileIds = listProfiles().map((p) => p.id);
21929
21970
  const profileList = profileIds.length > 0 ? `Available agent profiles: ${profileIds.join(", ")}` : "No explicit profiles available.";
21930
21971
  const systemPrompt = `You are an AI task definition assistant. Analyze the given task and return ONLY a JSON object (no markdown) with:
@@ -21980,7 +22021,7 @@ async function testOllamaConnection() {
21980
22021
  };
21981
22022
  }
21982
22023
  }
21983
- var DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, ollamaRuntimeAdapter;
22024
+ var DEFAULT_OLLAMA_BASE_URL, ollamaRuntimeAdapter;
21984
22025
  var init_ollama_adapter = __esm({
21985
22026
  "src/lib/agents/runtime/ollama-adapter.ts"() {
21986
22027
  "use strict";
@@ -21989,10 +22030,10 @@ var init_ollama_adapter = __esm({
21989
22030
  init_execution_manager();
21990
22031
  init_claude_agent();
21991
22032
  init_catalog2();
22033
+ init_ollama_model_resolver();
21992
22034
  init_registry2();
21993
22035
  init_ledger();
21994
22036
  DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
21995
- DEFAULT_OLLAMA_MODEL = "llama3.2";
21996
22037
  ollamaRuntimeAdapter = {
21997
22038
  metadata: getRuntimeCatalogEntry("ollama"),
21998
22039
  async executeTask(taskId) {
@@ -24129,6 +24170,7 @@ ${step.prompt}`;
24129
24170
  }
24130
24171
  async function executeCheckpoint(workflowId, definition, state, parentTaskId, workflowRuntimeId) {
24131
24172
  let previousOutput = "";
24173
+ let userAnswer = "";
24132
24174
  for (let i = 0; i < definition.steps.length; i++) {
24133
24175
  const step = definition.steps[i];
24134
24176
  state.currentStepIndex = i;
@@ -24142,8 +24184,27 @@ async function executeCheckpoint(workflowId, definition, state, parentTaskId, wo
24142
24184
  throw new Error(`Step "${step.name}" was denied approval`);
24143
24185
  }
24144
24186
  }
24145
- const contextPrompt = previousOutput ? `Previous step output:
24146
- ${previousOutput}
24187
+ userAnswer = "";
24188
+ if (step.requiresInput) {
24189
+ state.stepStates[i].status = "waiting_approval";
24190
+ state.status = "paused";
24191
+ await updateWorkflowState(workflowId, state, "paused");
24192
+ const question = step.inputPrompt ?? step.prompt;
24193
+ userAnswer = await waitForInput(workflowId, step.name, question);
24194
+ state.status = "running";
24195
+ state.stepStates[i].status = "running";
24196
+ await updateWorkflowState(workflowId, state, "active");
24197
+ }
24198
+ const contextParts = [];
24199
+ if (previousOutput) {
24200
+ contextParts.push(`Previous step output:
24201
+ ${previousOutput}`);
24202
+ }
24203
+ if (userAnswer.trim()) {
24204
+ contextParts.push(`User-provided input:
24205
+ ${userAnswer}`);
24206
+ }
24207
+ const contextPrompt = contextParts.length > 0 ? `${contextParts.join("\n\n---\n\n")}
24147
24208
 
24148
24209
  ---
24149
24210
 
@@ -24165,6 +24226,14 @@ ${step.prompt}` : step.prompt;
24165
24226
  throw new Error(`Step "${step.name}" failed: ${result.error}`);
24166
24227
  }
24167
24228
  previousOutput = result.result ?? "";
24229
+ const hasDependents = i < definition.steps.length - 1;
24230
+ if (hasDependents && previousOutput.trim().length === 0) {
24231
+ state.stepStates[i].status = "failed";
24232
+ state.stepStates[i].error = "Step produced no usable output; halting dependent steps to avoid a false completion. The agent may have refused for missing context \u2014 check its output and re-run with the needed input.";
24233
+ throw new Error(
24234
+ `Step "${step.name}" produced no usable output \u2014 halting workflow to avoid cascading refusals`
24235
+ );
24236
+ }
24168
24237
  }
24169
24238
  }
24170
24239
  async function executeParallel(workflowId, definition, state, parentTaskId, workflowRuntimeId) {
@@ -24694,6 +24763,33 @@ ${previousOutput.slice(0, 500)}`,
24694
24763
  }).where(eq44(notifications.id, notificationId));
24695
24764
  return false;
24696
24765
  }
24766
+ async function waitForInput(workflowId, stepName, question, options) {
24767
+ const notificationId = crypto.randomUUID();
24768
+ await db.insert(notifications).values({
24769
+ id: notificationId,
24770
+ taskId: null,
24771
+ type: "permission_required",
24772
+ title: `Workflow needs input: ${stepName}`,
24773
+ body: question.slice(0, 500),
24774
+ toolName: "AskUserQuestion",
24775
+ toolInput: JSON.stringify({ question, options, workflowId, stepName }),
24776
+ createdAt: /* @__PURE__ */ new Date()
24777
+ });
24778
+ const pollInterval = 2e3;
24779
+ for (; ; ) {
24780
+ const [notification] = await db.select().from(notifications).where(eq44(notifications.id, notificationId));
24781
+ if (notification?.response) {
24782
+ try {
24783
+ const parsed = JSON.parse(notification.response);
24784
+ const answer = parsed?.updatedInput?.answer;
24785
+ return typeof answer === "string" ? answer : "";
24786
+ } catch {
24787
+ return "";
24788
+ }
24789
+ }
24790
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
24791
+ }
24792
+ }
24697
24793
  async function updateWorkflowState(workflowId, state, status) {
24698
24794
  const [workflow] = await db.select().from(workflows).where(eq44(workflows.id, workflowId));
24699
24795
  if (!workflow) throw new Error(`Workflow ${workflowId} not found \u2014 cannot update state`);
@@ -25803,8 +25899,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25803
25899
  import yaml12 from "js-yaml";
25804
25900
  import semver from "semver";
25805
25901
  function relayCoreVersion() {
25806
- if (semver.valid("0.24.0")) {
25807
- return "0.24.0";
25902
+ if (semver.valid("0.25.0")) {
25903
+ return "0.25.0";
25808
25904
  }
25809
25905
  try {
25810
25906
  const root = getAppRoot(import.meta.dirname, 3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import {
3
3
  restoreFromSnapshot,
4
4
  isSnapshotLocked,
5
+ SnapshotBusyError,
5
6
  } from "@/lib/snapshots/snapshot-manager";
6
7
  import { db } from "@/lib/db";
7
8
  import { tasks } from "@/lib/db/schema";
@@ -51,6 +52,11 @@ export async function POST(
51
52
  "Restore complete. Please restart the server to load the restored database.",
52
53
  });
53
54
  } catch (error) {
55
+ // Lock contention is a 409, not a server error — never conflate it with a
56
+ // genuine failure (issue #24).
57
+ if (error instanceof SnapshotBusyError) {
58
+ return NextResponse.json({ error: error.message }, { status: 409 });
59
+ }
54
60
  return NextResponse.json(
55
61
  {
56
62
  error:
@@ -4,6 +4,7 @@ import {
4
4
  listSnapshots,
5
5
  getSnapshotsSize,
6
6
  isSnapshotLocked,
7
+ SnapshotBusyError,
7
8
  } from "@/lib/snapshots/snapshot-manager";
8
9
 
9
10
  /** GET /api/snapshots — list all snapshots with disk usage */
@@ -46,6 +47,11 @@ export async function POST(req: NextRequest) {
46
47
 
47
48
  return NextResponse.json(snapshot, { status: 201 });
48
49
  } catch (error) {
50
+ // A lock grabbed between the isSnapshotLocked() check and createSnapshot is
51
+ // still contention, not a server error (issue #24).
52
+ if (error instanceof SnapshotBusyError) {
53
+ return NextResponse.json({ error: error.message }, { status: 409 });
54
+ }
49
55
  return NextResponse.json(
50
56
  { error: error instanceof Error ? error.message : "Failed to create snapshot" },
51
57
  { status: 500 }
@@ -2,15 +2,9 @@
2
2
 
3
3
  import { useState, useTransition } from "react";
4
4
  import { useRouter } from "next/navigation";
5
- import { MoreHorizontal, Trash2 } from "lucide-react";
5
+ import { Trash2 } from "lucide-react";
6
6
  import { toast } from "sonner";
7
7
  import { Button } from "@/components/ui/button";
8
- import {
9
- DropdownMenu,
10
- DropdownMenuContent,
11
- DropdownMenuItem,
12
- DropdownMenuTrigger,
13
- } from "@/components/ui/dropdown-menu";
14
8
  import { ConfirmDialog } from "@/components/shared/confirm-dialog";
15
9
 
16
10
  interface AppDetailActionsProps {
@@ -76,22 +70,15 @@ export function AppDetailActions({
76
70
 
77
71
  return (
78
72
  <>
79
- <DropdownMenu>
80
- <DropdownMenuTrigger asChild>
81
- <Button variant="ghost" size="sm" aria-label="App actions">
82
- <MoreHorizontal className="h-4 w-4" aria-hidden="true" />
83
- </Button>
84
- </DropdownMenuTrigger>
85
- <DropdownMenuContent align="end">
86
- <DropdownMenuItem
87
- onClick={() => setConfirmOpen(true)}
88
- className="text-destructive focus:text-destructive"
89
- >
90
- <Trash2 className="h-3.5 w-3.5 mr-2" aria-hidden="true" />
91
- Delete app
92
- </DropdownMenuItem>
93
- </DropdownMenuContent>
94
- </DropdownMenu>
73
+ <Button
74
+ variant="outline"
75
+ size="sm"
76
+ onClick={() => setConfirmOpen(true)}
77
+ className="text-destructive hover:text-destructive"
78
+ >
79
+ <Trash2 className="h-3.5 w-3.5 mr-1.5" aria-hidden="true" />
80
+ Delete app
81
+ </Button>
95
82
 
96
83
  <ConfirmDialog
97
84
  open={confirmOpen}
@@ -25,7 +25,14 @@ export function KitView({ model }: KitViewProps) {
25
25
  {model.kpis && <KpisSlotView tiles={model.kpis} />}
26
26
  {model.hero && <HeroSlotView slot={model.hero} />}
27
27
  {model.secondary && model.secondary.length > 0 && (
28
- <SecondarySlotView slots={model.secondary} />
28
+ <div className="space-y-3">
29
+ {model.secondaryLead && (
30
+ <p className="text-sm text-muted-foreground">
31
+ {model.secondaryLead}
32
+ </p>
33
+ )}
34
+ <SecondarySlotView slots={model.secondary} />
35
+ </div>
29
36
  )}
30
37
  {model.activity && <ActivitySlotView slot={model.activity} />}
31
38
  {model.footer && <FooterSlotView slot={model.footer} />}
@@ -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">
26
- <div className="min-w-0">
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">
27
27
  <h1 className="text-xl font-semibold tracking-tight line-clamp-2" title={title}>
28
28
  {title}
29
29
  </h1>
@@ -33,7 +33,7 @@ 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">
36
+ <div className="flex flex-wrap items-center gap-2 mt-2 sm:mt-0 sm:flex-nowrap sm:shrink-0">
37
37
  {status && <StatusChip status={status} size="md" />}
38
38
  {cadenceChip && (
39
39
  <ScheduleCadenceChip
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { ReactNode } from "react";
4
4
  import { useState } from "react";
5
- import { ChevronDown, FileText } from "lucide-react";
5
+ import { ChevronRight, FileText } from "lucide-react";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import {
8
8
  Sheet,
@@ -36,7 +36,7 @@ export function ManifestSheet({ appName, body }: ManifestSheetProps) {
36
36
  >
37
37
  <FileText className="h-3.5 w-3.5 mr-1.5" aria-hidden="true" />
38
38
  View manifest
39
- <ChevronDown className="h-3.5 w-3.5 ml-1" aria-hidden="true" />
39
+ <ChevronRight className="h-3.5 w-3.5 ml-1" aria-hidden="true" />
40
40
  </Button>
41
41
  <Sheet open={open} onOpenChange={setOpen}>
42
42
  <SheetContent side="right" className="w-full sm:max-w-2xl overflow-hidden flex flex-col">
@@ -13,10 +13,11 @@ import {
13
13
  SheetTitle,
14
14
  SheetTrigger,
15
15
  } from "@/components/ui/sheet";
16
- import { ChevronDown, AlertCircle } from "lucide-react";
16
+ import { ChevronDown, AlertCircle, Sparkles, RefreshCw } from "lucide-react";
17
17
  import { ErrorBoundary } from "@/components/shared/error-boundary";
18
+ import { RunNowButton } from "@/components/apps/run-now-button";
18
19
  import type { TaskStatus } from "@/lib/constants/task-status";
19
- import type { RuntimeTaskSummary } from "@/lib/apps/view-kits/types";
20
+ import type { BlueprintCard, RuntimeTaskSummary } from "@/lib/apps/view-kits/types";
20
21
 
21
22
  interface LastRunSummary {
22
23
  id: string;
@@ -94,6 +95,94 @@ function CompactVariant({ blueprintLabel, lastRun, runCount30d }: CompactProps)
94
95
  );
95
96
  }
96
97
 
98
+ interface RunnableBlueprintCardProps {
99
+ card: BlueprintCard;
100
+ lastRun: LastRunSummary | null;
101
+ runCount30d: number;
102
+ }
103
+
104
+ /**
105
+ * FEAT-5/6: the runnable blueprint card on the app home. Renders the
106
+ * blueprint's name + one-line description + last-run status + a Run action.
107
+ * The "Start here" card is highlighted. Row-insert blueprints label their
108
+ * automatic trigger instead of offering a manual Run that fights the contract.
109
+ */
110
+ export function RunnableBlueprintCard({
111
+ card,
112
+ lastRun,
113
+ runCount30d,
114
+ }: RunnableBlueprintCardProps) {
115
+ const isRowInsert = card.trigger?.kind === "row-insert";
116
+ return (
117
+ <Card
118
+ className={
119
+ card.isPrimary
120
+ ? "surface-card border-primary ring-1 ring-primary/30"
121
+ : "surface-card"
122
+ }
123
+ >
124
+ <CardHeader className="pb-2 space-y-1.5">
125
+ {card.isPrimary && (
126
+ <Badge className="w-fit gap-1">
127
+ <Sparkles className="h-3 w-3" />
128
+ Start here
129
+ </Badge>
130
+ )}
131
+ <CardTitle className="text-sm font-medium">{card.name}</CardTitle>
132
+ {card.description && (
133
+ <p className="text-xs text-muted-foreground line-clamp-2">
134
+ {card.description}
135
+ </p>
136
+ )}
137
+ </CardHeader>
138
+ <CardContent className="space-y-3">
139
+ <div className="flex items-center gap-2 flex-wrap">
140
+ {lastRun ? (
141
+ <>
142
+ <Badge variant={statusVariant[lastRun.status]}>
143
+ {lastRun.status}
144
+ </Badge>
145
+ <span className="text-xs text-muted-foreground">
146
+ {formatAgo(lastRun.createdAt)}
147
+ </span>
148
+ </>
149
+ ) : (
150
+ <span className="text-xs text-muted-foreground">never run</span>
151
+ )}
152
+ <span className="text-xs text-muted-foreground">
153
+ · {runCount30d} {runCount30d === 1 ? "run" : "runs"} · 30d
154
+ </span>
155
+ </div>
156
+ {isRowInsert ? (
157
+ <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
158
+ <RefreshCw className="h-3.5 w-3.5" />
159
+ Runs on its own when you add a row to the{" "}
160
+ {card.trigger?.tableName ?? "linked"} table
161
+ </p>
162
+ ) : (
163
+ <div className="space-y-1.5">
164
+ <RunNowButton
165
+ blueprintId={card.id}
166
+ variables={card.variables}
167
+ label="Run"
168
+ />
169
+ {/* FEAT-5.2: on the "Start here" card, tell a first-time user what
170
+ Run does. Only the primary card carries this hint, so the grid
171
+ stays scannable (progressive disclosure). */}
172
+ {card.isPrimary && (
173
+ <p className="text-xs text-muted-foreground">
174
+ {card.variables.length > 0
175
+ ? "Run asks a few questions, then starts a workflow you can watch."
176
+ : "Run starts a workflow you can watch as it works."}
177
+ </p>
178
+ )}
179
+ </div>
180
+ )}
181
+ </CardContent>
182
+ </Card>
183
+ );
184
+ }
185
+
97
186
  function HeroVariant({ task, previousRuns }: HeroProps) {
98
187
  const [sheetOpen, setSheetOpen] = useState(false);
99
188
 
@@ -15,7 +15,8 @@ export function LedgerHeroPanel({ series, categories, period }: LedgerHeroPanelP
15
15
  if (series.length === 0 && categories.length === 0) {
16
16
  return (
17
17
  <div className="surface-card rounded-xl p-12 text-center text-muted-foreground border">
18
- No data yet. Add transactions or click <strong>Run now</strong> to ingest a CSV.
18
+ No data yet. Click <strong>Run now</strong> to start this app&apos;s workflow.
19
+ Results show up here once it finishes.
19
20
  </div>
20
21
  );
21
22
  }
@@ -16,7 +16,7 @@ const EXAMPLE_PROMPTS: { label: string; prompt: string }[] = [
16
16
  {
17
17
  label: "Build me a reading log…",
18
18
  prompt:
19
- "Build me a reading log. Track each book with title, author, date finished, and a 15 rating. Every Friday at 5pm, summarize what I read this week.",
19
+ "Build me a reading log. Track each book with title, author, date finished, and a rating from 1 to 5. Every Friday at 5pm, summarize what I read this week.",
20
20
  },
21
21
  {
22
22
  label: "Build me an expense tracker for my contractors…",
@@ -97,10 +97,10 @@ export function ChatEmptyState({
97
97
  <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
98
98
  <Bot className="h-6 w-6 text-primary" />
99
99
  </div>
100
- <h2 className="text-lg font-semibold">Describe an app. Orionfold Relay builds it.</h2>
100
+ <h2 className="text-lg font-semibold">Describe an app. Relay builds it.</h2>
101
101
  <p className="text-sm text-muted-foreground text-center max-w-md">
102
- Profiles, blueprints, tables, and schedules, composed from a single prompt.
103
- Or ask anything about your workspace.
102
+ One prompt builds the whole app: profiles, blueprints, tables, and
103
+ schedules. Or ask anything about your workspace.
104
104
  </p>
105
105
  </div>
106
106
 
@@ -9,17 +9,17 @@ const pillars = [
9
9
  {
10
10
  icon: Sparkles,
11
11
  title: "Apps from a sentence",
12
- description: "Describe what you do every week. Orionfold Relay composes the profile, blueprint, schedule, and tables into a running app. No code.",
12
+ description: "Tell Relay what you do each week. It builds a running app for you. No code needed.",
13
13
  },
14
14
  {
15
15
  icon: Shield,
16
16
  title: "Your rules, enforced",
17
- description: "Every agent action respects your policies. Full audit trail for every decision.",
17
+ description: "Agents follow your rules on every task. You get a full record of what they did.",
18
18
  },
19
19
  {
20
20
  icon: Wallet,
21
21
  title: "Know what you spend",
22
- description: "Track spend per task, per provider. Budget guardrails prevent surprise bills.",
22
+ description: "See the cost of each task. Set a budget so you never get a surprise bill.",
23
23
  },
24
24
  ];
25
25
 
@@ -44,7 +44,7 @@ export function WelcomeLanding({ starters = [] }: WelcomeLandingProps) {
44
44
  Welcome
45
45
  </h1>
46
46
  <p className="text-base text-muted-foreground mb-8 max-w-lg">
47
- Your AI Business Operating System. Describe an app, Orionfold Relay builds it, and runs it on your rules, your budget, your data.
47
+ Relay is your AI business operating system. Describe an app. Relay builds it and runs it on your rules, your budget, and your data.
48
48
  </p>
49
49
 
50
50
  <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full mb-8">
@@ -42,7 +42,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
42
42
  id: "quality",
43
43
  label: "Best quality",
44
44
  capabilityNote:
45
- "Top-tier model (Opus). Full filesystem + tool access; highest cost.",
45
+ "Our smartest model (Opus). It can use every tool and read files. Costs the most.",
46
46
  recommendedModel: "opus",
47
47
  icon: Sparkles,
48
48
  },
@@ -50,7 +50,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
50
50
  id: "balanced",
51
51
  label: "Balanced (recommended)",
52
52
  capabilityNote:
53
- "Strong quality at a moderate price (Sonnet). Same capabilities as Opus.",
53
+ "Great quality for less (Sonnet). It can do everything Opus can.",
54
54
  recommendedModel: "sonnet",
55
55
  icon: Scale,
56
56
  },
@@ -58,7 +58,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
58
58
  id: "cost",
59
59
  label: "Lowest cost",
60
60
  capabilityNote:
61
- "Fastest, cheapest cloud model (Haiku). Same tool surface as Sonnet.",
61
+ "Our fastest, cheapest model (Haiku). It uses the same tools as Sonnet.",
62
62
  recommendedModel: "haiku",
63
63
  icon: DollarSign,
64
64
  },
@@ -66,7 +66,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
66
66
  id: "privacy",
67
67
  label: "Best privacy (local only)",
68
68
  capabilityNote:
69
- "Runs entirely on your machine via Ollama. No cloud calls; no MCP servers; smaller context window.",
69
+ "Runs on your own computer with Ollama. Nothing leaves your machine. It skips cloud tools and holds less at once.",
70
70
  recommendedModel: "", // resolved against the Ollama discovery list at submit time
71
71
  icon: Lock,
72
72
  },
@@ -168,7 +168,7 @@ export function RuntimePreferenceModal({
168
168
  return {
169
169
  modelId: BALANCED_FALLBACK_MODEL,
170
170
  fallbackNote:
171
- "No local models found. Point Orionfold Relay at your Ollama install in Settings. Using balanced default for now.",
171
+ "We could not find a local model. We will use the balanced model for now. To use your own, point Relay at Ollama in Settings.",
172
172
  };
173
173
  }
174
174
  return {
@@ -248,8 +248,8 @@ export function RuntimePreferenceModal({
248
248
  <DialogHeader>
249
249
  <DialogTitle>Pick your default chat model</DialogTitle>
250
250
  <DialogDescription>
251
- We&apos;ll set sensible defaults based on what matters most to you.
252
- You can change this anytime in Settings.
251
+ Tell us what matters most and we will set a good default. You can
252
+ change it anytime in Settings.
253
253
  </DialogDescription>
254
254
  </DialogHeader>
255
255
 
@@ -311,7 +311,7 @@ export function RuntimePreferenceModal({
311
311
  onClick={handleSkip}
312
312
  disabled={submitting}
313
313
  >
314
- Skip use default
314
+ Skip, use default
315
315
  </Button>
316
316
  <Button onClick={handleConfirm} disabled={submitting}>
317
317
  {submitting ? "Saving…" : "Continue"}