orionfold-relay 0.24.1 → 0.25.1

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 (29) hide show
  1. package/dist/cli.js +96 -16
  2. package/package.json +1 -1
  3. package/src/app/api/chat/conversations/route.ts +4 -1
  4. package/src/components/apps/app-detail-actions.tsx +10 -23
  5. package/src/components/apps/kit-view/kit-view.tsx +8 -1
  6. package/src/components/apps/kit-view/slots/header.tsx +3 -3
  7. package/src/components/apps/kit-view/slots/manifest-sheet.tsx +2 -2
  8. package/src/components/apps/last-run-card.tsx +91 -2
  9. package/src/components/apps/ledger-hero-panel.tsx +2 -1
  10. package/src/components/chat/chat-session-provider.tsx +10 -1
  11. package/src/components/settings/license-section.tsx +5 -0
  12. package/src/components/workflows/shared/workflow-header.tsx +114 -0
  13. package/src/lib/apps/view-kits/data.ts +79 -0
  14. package/src/lib/apps/view-kits/kits/workflow-hub.ts +48 -9
  15. package/src/lib/apps/view-kits/types.ts +38 -0
  16. package/src/lib/chat/tools/table-tools.ts +5 -2
  17. package/src/lib/environment/workspace-context.ts +6 -0
  18. package/src/lib/http/self-base-url.ts +42 -0
  19. package/src/lib/notifications/actionable.ts +11 -2
  20. package/src/lib/packs/templates/relay-agency-pro/base/manifest.yaml +8 -2
  21. package/src/lib/packs/templates/relay-agency-pro/pack.yaml +5 -1
  22. package/src/lib/plugins/examples/echo-server/plugin.yaml +1 -1
  23. package/src/lib/plugins/examples/finance-pack/plugin.yaml +1 -1
  24. package/src/lib/plugins/examples/reading-radar/plugin.yaml +1 -1
  25. package/src/lib/plugins/registry.ts +1 -1
  26. package/src/lib/plugins/sdk/types.ts +1 -1
  27. package/src/lib/tables/trigger-evaluator.ts +6 -6
  28. package/src/lib/workflows/engine.ts +106 -3
  29. 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({
@@ -4614,6 +4614,19 @@ var init_history = __esm({
4614
4614
  }
4615
4615
  });
4616
4616
 
4617
+ // src/lib/http/self-base-url.ts
4618
+ function getSelfBaseUrl() {
4619
+ const explicit = process.env.RELAY_SELF_BASE_URL || process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL;
4620
+ if (explicit) return explicit;
4621
+ const port = process.env.PORT || "3000";
4622
+ return `http://127.0.0.1:${port}`;
4623
+ }
4624
+ var init_self_base_url = __esm({
4625
+ "src/lib/http/self-base-url.ts"() {
4626
+ "use strict";
4627
+ }
4628
+ });
4629
+
4617
4630
  // src/lib/workflows/delay.ts
4618
4631
  function parseDuration(input) {
4619
4632
  const match = input.match(DURATION_PATTERN);
@@ -7135,7 +7148,13 @@ function getWorkspaceContext() {
7135
7148
  gitBranch = execFileSync2("git", ["branch", "--show-current"], {
7136
7149
  cwd,
7137
7150
  encoding: "utf-8",
7138
- timeout: 3e3
7151
+ timeout: 3e3,
7152
+ // Route git's stderr to a pipe (captured on the thrown error, then
7153
+ // discarded) instead of inheriting the console. Without this, a
7154
+ // non-git cwd leaks a raw `fatal: not a git repository` to a
7155
+ // customer's first-run log before the catch swallows the exit code.
7156
+ // Mirrors src/lib/instance/git-ops.ts run().
7157
+ stdio: ["ignore", "pipe", "pipe"]
7139
7158
  }).trim() || null;
7140
7159
  } catch {
7141
7160
  }
@@ -12134,7 +12153,7 @@ async function listPendingApprovalPayloads(limit = 20) {
12134
12153
  const isBatchProposal = row.type === "context_proposal_batch";
12135
12154
  const parsedInput = parseNotificationToolInput(row.toolInput);
12136
12155
  let effectiveWorkflowId = row.workflowId;
12137
- if (!row.taskId && row.toolName === "WorkflowCheckpoint" && row.toolInput) {
12156
+ if (!row.taskId && (row.toolName === "WorkflowCheckpoint" || row.toolName === "AskUserQuestion") && row.toolInput) {
12138
12157
  try {
12139
12158
  const parsed = typeof row.toolInput === "string" ? JSON.parse(row.toolInput) : row.toolInput;
12140
12159
  effectiveWorkflowId = parsed.workflowId ?? null;
@@ -12963,7 +12982,7 @@ var init_registry6 = __esm({
12963
12982
  init_registry5();
12964
12983
  init_installer();
12965
12984
  init_schedule_spec();
12966
- SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.23"]);
12985
+ SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.24"]);
12967
12986
  pluginCache = null;
12968
12987
  lastLoadedPluginIds = /* @__PURE__ */ new Set();
12969
12988
  PluginTableSchema = z16.object({
@@ -15275,7 +15294,7 @@ Guidelines for schema inference:
15275
15294
  try {
15276
15295
  const table = await getTable(args.tableId);
15277
15296
  if (!table) return err("Table not found");
15278
- const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
15297
+ const baseUrl = getSelfBaseUrl();
15279
15298
  return ok({
15280
15299
  url: `${baseUrl}/api/tables/${args.tableId}/export?format=${args.format}`,
15281
15300
  table: table.name,
@@ -15721,7 +15740,7 @@ Guidelines for schema inference:
15721
15740
  ];
15722
15741
  }
15723
15742
  function getBaseUrl() {
15724
- return process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
15743
+ return getSelfBaseUrl();
15725
15744
  }
15726
15745
  var init_table_tools = __esm({
15727
15746
  "src/lib/chat/tools/table-tools.ts"() {
@@ -15732,6 +15751,7 @@ var init_table_tools = __esm({
15732
15751
  init_history();
15733
15752
  init_import();
15734
15753
  init_enrichment();
15754
+ init_self_base_url();
15735
15755
  }
15736
15756
  });
15737
15757
 
@@ -24164,6 +24184,7 @@ ${step.prompt}`;
24164
24184
  }
24165
24185
  async function executeCheckpoint(workflowId, definition, state, parentTaskId, workflowRuntimeId) {
24166
24186
  let previousOutput = "";
24187
+ let userAnswer = "";
24167
24188
  for (let i = 0; i < definition.steps.length; i++) {
24168
24189
  const step = definition.steps[i];
24169
24190
  state.currentStepIndex = i;
@@ -24177,8 +24198,27 @@ async function executeCheckpoint(workflowId, definition, state, parentTaskId, wo
24177
24198
  throw new Error(`Step "${step.name}" was denied approval`);
24178
24199
  }
24179
24200
  }
24180
- const contextPrompt = previousOutput ? `Previous step output:
24181
- ${previousOutput}
24201
+ userAnswer = "";
24202
+ if (step.requiresInput) {
24203
+ state.stepStates[i].status = "waiting_approval";
24204
+ state.status = "paused";
24205
+ await updateWorkflowState(workflowId, state, "paused");
24206
+ const question = step.inputPrompt ?? step.prompt;
24207
+ userAnswer = await waitForInput(workflowId, step.name, question);
24208
+ state.status = "running";
24209
+ state.stepStates[i].status = "running";
24210
+ await updateWorkflowState(workflowId, state, "active");
24211
+ }
24212
+ const contextParts = [];
24213
+ if (previousOutput) {
24214
+ contextParts.push(`Previous step output:
24215
+ ${previousOutput}`);
24216
+ }
24217
+ if (userAnswer.trim()) {
24218
+ contextParts.push(`User-provided input:
24219
+ ${userAnswer}`);
24220
+ }
24221
+ const contextPrompt = contextParts.length > 0 ? `${contextParts.join("\n\n---\n\n")}
24182
24222
 
24183
24223
  ---
24184
24224
 
@@ -24200,6 +24240,14 @@ ${step.prompt}` : step.prompt;
24200
24240
  throw new Error(`Step "${step.name}" failed: ${result.error}`);
24201
24241
  }
24202
24242
  previousOutput = result.result ?? "";
24243
+ const hasDependents = i < definition.steps.length - 1;
24244
+ if (hasDependents && previousOutput.trim().length === 0) {
24245
+ state.stepStates[i].status = "failed";
24246
+ 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.";
24247
+ throw new Error(
24248
+ `Step "${step.name}" produced no usable output \u2014 halting workflow to avoid cascading refusals`
24249
+ );
24250
+ }
24203
24251
  }
24204
24252
  }
24205
24253
  async function executeParallel(workflowId, definition, state, parentTaskId, workflowRuntimeId) {
@@ -24729,6 +24777,33 @@ ${previousOutput.slice(0, 500)}`,
24729
24777
  }).where(eq44(notifications.id, notificationId));
24730
24778
  return false;
24731
24779
  }
24780
+ async function waitForInput(workflowId, stepName, question, options) {
24781
+ const notificationId = crypto.randomUUID();
24782
+ await db.insert(notifications).values({
24783
+ id: notificationId,
24784
+ taskId: null,
24785
+ type: "permission_required",
24786
+ title: `Workflow needs input: ${stepName}`,
24787
+ body: question.slice(0, 500),
24788
+ toolName: "AskUserQuestion",
24789
+ toolInput: JSON.stringify({ question, options, workflowId, stepName }),
24790
+ createdAt: /* @__PURE__ */ new Date()
24791
+ });
24792
+ const pollInterval = 2e3;
24793
+ for (; ; ) {
24794
+ const [notification] = await db.select().from(notifications).where(eq44(notifications.id, notificationId));
24795
+ if (notification?.response) {
24796
+ try {
24797
+ const parsed = JSON.parse(notification.response);
24798
+ const answer = parsed?.updatedInput?.answer;
24799
+ return typeof answer === "string" ? answer : "";
24800
+ } catch {
24801
+ return "";
24802
+ }
24803
+ }
24804
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
24805
+ }
24806
+ }
24732
24807
  async function updateWorkflowState(workflowId, state, status) {
24733
24808
  const [workflow] = await db.select().from(workflows).where(eq44(workflows.id, workflowId));
24734
24809
  if (!workflow) throw new Error(`Workflow ${workflowId} not found \u2014 cannot update state`);
@@ -25318,13 +25393,15 @@ async function fireAction(actionType, config, rowData, meta) {
25318
25393
  Trigger data: ${JSON.stringify(rowData, null, 2)}` : `Triggered by table row change.
25319
25394
 
25320
25395
  Data: ${JSON.stringify(rowData, null, 2)}`;
25321
- await fetch(`${getBaseUrl2()}/api/tasks`, {
25396
+ await fetch(`${getSelfBaseUrl()}/api/tasks`, {
25322
25397
  method: "POST",
25323
25398
  headers: { "Content-Type": "application/json" },
25324
25399
  body: JSON.stringify({
25325
25400
  title: config.title ?? "Triggered Task",
25326
25401
  description,
25327
- projectId: config.projectId ?? null
25402
+ // createTaskSchema wants `string | undefined`, not null — sending null
25403
+ // 400s the self-call and the task is never created. Omit when absent.
25404
+ ...config.projectId ? { projectId: config.projectId } : {}
25328
25405
  })
25329
25406
  });
25330
25407
  return;
@@ -25350,7 +25427,7 @@ Data: ${JSON.stringify(rowData, null, 2)}`;
25350
25427
  return;
25351
25428
  }
25352
25429
  if (actionType === "run_workflow" && config.workflowId) {
25353
- await fetch(`${getBaseUrl2()}/api/workflows/${config.workflowId}/execute`, {
25430
+ await fetch(`${getSelfBaseUrl()}/api/workflows/${config.workflowId}/execute`, {
25354
25431
  method: "POST",
25355
25432
  headers: { "Content-Type": "application/json" },
25356
25433
  body: JSON.stringify({
@@ -25368,14 +25445,12 @@ function deriveAppIdFromBlueprintId(blueprintId) {
25368
25445
  if (idx <= 0) return null;
25369
25446
  return blueprintId.slice(0, idx);
25370
25447
  }
25371
- function getBaseUrl2() {
25372
- return process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
25373
- }
25374
25448
  var init_trigger_evaluator = __esm({
25375
25449
  "src/lib/tables/trigger-evaluator.ts"() {
25376
25450
  "use strict";
25377
25451
  init_db();
25378
25452
  init_schema();
25453
+ init_self_base_url();
25379
25454
  }
25380
25455
  });
25381
25456
 
@@ -25838,8 +25913,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25838
25913
  import yaml12 from "js-yaml";
25839
25914
  import semver from "semver";
25840
25915
  function relayCoreVersion() {
25841
- if (semver.valid("0.24.1")) {
25842
- return "0.24.1";
25916
+ if (semver.valid("0.25.1")) {
25917
+ return "0.25.1";
25843
25918
  }
25844
25919
  try {
25845
25920
  const root = getAppRoot(import.meta.dirname, 3);
@@ -27487,6 +27562,11 @@ Falling back to development mode for this run \u2014 Relay still works, but slow
27487
27562
  RELAY_DATA_DIR: DATA_DIR,
27488
27563
  RELAY_LAUNCH_CWD: launchCwd2,
27489
27564
  PORT: String(actualPort),
27565
+ // Origin for Relay's internal loopback self-calls (trigger dispatch,
27566
+ // compose table tools). The server always listens on loopback even when
27567
+ // bound to a non-loopback host, so self-calls target 127.0.0.1 + the real
27568
+ // port — never :3000, never the LAN IP. Fixes issue #29.
27569
+ RELAY_SELF_BASE_URL: buildSidecarUrl(actualPort, "127.0.0.1"),
27490
27570
  ...opts.safeMode ? { RELAY_SAFE_MODE: "true" } : {},
27491
27571
  // In dev mode, Next blocks cross-origin /_next/* dev-asset requests from
27492
27572
  // the LAN client's IP, breaking the app over the network (issue #13).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
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",
@@ -51,7 +51,10 @@ export async function POST(req: NextRequest) {
51
51
  );
52
52
  }
53
53
 
54
- const validRuntimes = ["claude-code", "openai-codex-app-server"];
54
+ // "ollama" is first-class: getRuntimeForModel() returns it for local models,
55
+ // engine.ts routes it to sendOllamaMessage. Omitting it here 400'd the
56
+ // "Best privacy (local only)" tier's first chat/compose on a fresh install (#30).
57
+ const validRuntimes = ["claude-code", "openai-codex-app-server", "ollama"];
55
58
  if (!validRuntimes.includes(runtimeId)) {
56
59
  return NextResponse.json(
57
60
  { error: `Invalid runtimeId. Must be one of: ${validRuntimes.join(", ")}` },
@@ -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
  }
@@ -297,7 +297,16 @@ export function ChatSessionProvider({ children }: { children: ReactNode }) {
297
297
  ...(opts?.title ? { title: opts.title } : {}),
298
298
  }),
299
299
  });
300
- if (!res.ok) return null;
300
+ if (!res.ok) {
301
+ // Never swallow a create failure silently again (#30): a rejected
302
+ // runtime/model used to clear the composer with no signal at all.
303
+ const detail = await res
304
+ .json()
305
+ .then((b) => (b as { error?: string })?.error)
306
+ .catch(() => null);
307
+ toast.error(detail || "Couldn't start a chat with this model. Try another model.");
308
+ return null;
309
+ }
301
310
  const conversation = (await res.json()) as ConversationRow;
302
311
  setConversations((prev) => [conversation, ...prev]);
303
312
  // Set empty messages BEFORE activating so the conversation has an
@@ -53,10 +53,15 @@ function daysUntil(iso: string): number {
53
53
  }
54
54
 
55
55
  function formatDate(iso: string): string {
56
+ // License dates are calendar dates stored as midnight-UTC instants
57
+ // (e.g. "2026-07-03T00:00:00Z"). Format in UTC so a customer in a
58
+ // behind-UTC timezone doesn't see the date shift one day earlier than
59
+ // the license file and their purchase email say.
56
60
  return new Date(iso).toLocaleDateString(undefined, {
57
61
  year: "numeric",
58
62
  month: "short",
59
63
  day: "numeric",
64
+ timeZone: "UTC",
60
65
  });
61
66
  }
62
67
 
@@ -11,11 +11,74 @@ import {
11
11
  RotateCcw,
12
12
  Trash2,
13
13
  FolderKanban,
14
+ ArrowRight,
15
+ Inbox as InboxIcon,
16
+ Clock3,
17
+ Loader2,
14
18
  } from "lucide-react";
15
19
  import { workflowStatusVariant, patternLabels } from "@/lib/constants/status-colors";
16
20
  import { IconCircle, getWorkflowIconFromName } from "@/lib/constants/card-icons";
17
21
  import type { WorkflowStatusResponse } from "@/lib/workflows/types";
18
22
 
23
+ /**
24
+ * FEAT-7/8: a status-aware signpost telling the user what to do next. After a
25
+ * blueprint instantiates a draft, "Execute" is not obvious — so `draft` gets a
26
+ * "click Execute to start" nudge. Once running/paused, activity lives on other
27
+ * surfaces (Inbox for approvals, the steps below for progress) — so those
28
+ * statuses point there. A paused HITL run must read as "waiting for you", not
29
+ * "stuck".
30
+ *
31
+ * Distinguishing the two kinds of `paused`: a delay pause carries `resumeAt`
32
+ * (it resumes on its own); a HITL pause (BUG-3) sets a step to
33
+ * `waiting_approval` and has no resumeAt (it waits for your answer → Inbox).
34
+ */
35
+ export type Signpost = {
36
+ tone: "info" | "wait";
37
+ icon: "arrow" | "inbox" | "clock" | "spinner";
38
+ href?: string;
39
+ text: string;
40
+ } | null;
41
+
42
+ export function computeSignpost(data: WorkflowStatusResponse): Signpost {
43
+ const status = data.status;
44
+ if (status === "draft") {
45
+ return {
46
+ tone: "info",
47
+ icon: "arrow",
48
+ text: "Ready to go. Click Execute to start this workflow.",
49
+ };
50
+ }
51
+ // A live workflow is `active` at the top level (`running` is a step/run-state
52
+ // value; the loop arm may report it too — accept both).
53
+ if (status === "active" || status === "running") {
54
+ return {
55
+ tone: "info",
56
+ icon: "spinner",
57
+ text: "Working now. Watch the steps below as it goes.",
58
+ };
59
+ }
60
+ if (status === "paused") {
61
+ // A delay pause resumes on its own; the non-loop arm carries resumeAt.
62
+ const resumeAt =
63
+ "resumeAt" in data ? (data.resumeAt as number | null) : null;
64
+ if (resumeAt != null) {
65
+ return {
66
+ tone: "wait",
67
+ icon: "clock",
68
+ text: "Paused for a scheduled step. It resumes on its own.",
69
+ };
70
+ }
71
+ // Otherwise it is waiting for your answer (HITL checkpoint → Inbox).
72
+ return {
73
+ tone: "wait",
74
+ icon: "inbox",
75
+ href: "/inbox",
76
+ text: "Waiting for your approval. Answer it in your Inbox.",
77
+ };
78
+ }
79
+ return null;
80
+ }
81
+
19
82
  /**
20
83
  * Pattern-agnostic header card for the workflow detail page. Renders the
21
84
  * workflow name, pattern label, project/run badges, status badge, and the
@@ -136,6 +199,57 @@ export function WorkflowHeader({
136
199
  )}
137
200
  </div>
138
201
  </div>
202
+ <SignpostBanner signpost={computeSignpost(data)} />
139
203
  </CardHeader>
140
204
  );
141
205
  }
206
+
207
+ /**
208
+ * FEAT-7/8: renders the status-aware "what to do next" banner. `wait` tone
209
+ * (waiting on the user) is emphasized; `info` tone is quiet. When a signpost
210
+ * has an href, the whole banner is a link (e.g. paused HITL → /inbox).
211
+ */
212
+ function SignpostBanner({ signpost }: { signpost: Signpost }) {
213
+ const router = useRouter();
214
+ if (!signpost) return null;
215
+
216
+ const Icon =
217
+ signpost.icon === "inbox"
218
+ ? InboxIcon
219
+ : signpost.icon === "clock"
220
+ ? Clock3
221
+ : signpost.icon === "spinner"
222
+ ? Loader2
223
+ : ArrowRight;
224
+
225
+ const tone =
226
+ signpost.tone === "wait"
227
+ ? "border-status-warning/40 bg-status-warning/10 text-foreground"
228
+ : "border-border bg-muted/40 text-muted-foreground";
229
+
230
+ const body = (
231
+ <div
232
+ className={`mt-3 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${tone} ${
233
+ signpost.href ? "cursor-pointer hover:bg-accent transition-colors" : ""
234
+ }`}
235
+ >
236
+ <Icon
237
+ className={`h-4 w-4 shrink-0 ${
238
+ signpost.icon === "spinner" ? "animate-spin" : ""
239
+ }`}
240
+ />
241
+ <span>{signpost.text}</span>
242
+ {signpost.href && <ArrowRight className="h-3.5 w-3.5 ml-auto shrink-0" />}
243
+ </div>
244
+ );
245
+
246
+ if (signpost.href) {
247
+ const href = signpost.href;
248
+ return (
249
+ <div role="link" tabIndex={0} onClick={() => router.push(href)}>
250
+ {body}
251
+ </div>
252
+ );
253
+ }
254
+ return body;
255
+ }