orionfold-relay 0.24.1 → 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.
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({
@@ -24164,6 +24170,7 @@ ${step.prompt}`;
24164
24170
  }
24165
24171
  async function executeCheckpoint(workflowId, definition, state, parentTaskId, workflowRuntimeId) {
24166
24172
  let previousOutput = "";
24173
+ let userAnswer = "";
24167
24174
  for (let i = 0; i < definition.steps.length; i++) {
24168
24175
  const step = definition.steps[i];
24169
24176
  state.currentStepIndex = i;
@@ -24177,8 +24184,27 @@ async function executeCheckpoint(workflowId, definition, state, parentTaskId, wo
24177
24184
  throw new Error(`Step "${step.name}" was denied approval`);
24178
24185
  }
24179
24186
  }
24180
- const contextPrompt = previousOutput ? `Previous step output:
24181
- ${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")}
24182
24208
 
24183
24209
  ---
24184
24210
 
@@ -24200,6 +24226,14 @@ ${step.prompt}` : step.prompt;
24200
24226
  throw new Error(`Step "${step.name}" failed: ${result.error}`);
24201
24227
  }
24202
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
+ }
24203
24237
  }
24204
24238
  }
24205
24239
  async function executeParallel(workflowId, definition, state, parentTaskId, workflowRuntimeId) {
@@ -24729,6 +24763,33 @@ ${previousOutput.slice(0, 500)}`,
24729
24763
  }).where(eq44(notifications.id, notificationId));
24730
24764
  return false;
24731
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
+ }
24732
24793
  async function updateWorkflowState(workflowId, state, status) {
24733
24794
  const [workflow] = await db.select().from(workflows).where(eq44(workflows.id, workflowId));
24734
24795
  if (!workflow) throw new Error(`Workflow ${workflowId} not found \u2014 cannot update state`);
@@ -25838,8 +25899,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25838
25899
  import yaml12 from "js-yaml";
25839
25900
  import semver from "semver";
25840
25901
  function relayCoreVersion() {
25841
- if (semver.valid("0.24.1")) {
25842
- return "0.24.1";
25902
+ if (semver.valid("0.25.0")) {
25903
+ return "0.25.0";
25843
25904
  }
25844
25905
  try {
25845
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.1",
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,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
  }
@@ -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
+ }
@@ -7,6 +7,7 @@ import { humanizeCron } from "@/lib/apps/registry";
7
7
  import type { AppDetail, AppManifest, ViewConfig } from "@/lib/apps/registry";
8
8
  import type { ResolvedBindings } from "./resolve";
9
9
  import type {
10
+ BlueprintCard,
10
11
  CadenceChipData,
11
12
  HeroTableData,
12
13
  KitId,
@@ -34,6 +35,8 @@ export interface KitProjectionShape {
34
35
  kpiSpecs?: KpiSpec[];
35
36
  blueprintIds?: string[];
36
37
  scheduleIds?: string[];
38
+ /** FEAT-5/6: Workflow Hub — blueprint flagged "Start here" on the card home. */
39
+ primaryBlueprintId?: string;
37
40
  /** Phase 3: Ledger period; threaded into windowed KPI specs. */
38
41
  period?: "mtd" | "qtd" | "ytd";
39
42
  /** Phase 3: Ledger column inference for data-layer queries. */
@@ -85,6 +88,7 @@ async function loadRuntimeStateUncached(
85
88
  cadence: await loadCadence(app.manifest, undefined),
86
89
  blueprintLastRuns: await loadBlueprintLastRuns(bindings.blueprintIds),
87
90
  blueprintRunCounts: await loadBlueprintRunCounts(bindings.blueprintIds),
91
+ blueprintCards: await loadBlueprintCards(app.manifest, projection.primaryBlueprintId),
88
92
  failedTasks: await loadFailedTasks(app.id, 10),
89
93
  evaluatedKpis: await loadEvaluatedKpis(projection.kpiSpecs ?? []),
90
94
  };
@@ -617,6 +621,81 @@ export async function loadBlueprintVariables(
617
621
  }
618
622
  }
619
623
 
624
+ /**
625
+ * FEAT-5/6: builds the runnable-card metadata for the Workflow Hub home — one
626
+ * card per manifest blueprint, in manifest order. Combines each manifest stub
627
+ * (id + trigger) with its registered definition (name + description +
628
+ * variables) so the card can render a name, one-line "what it does", a Run
629
+ * action, and honor row-insert gating.
630
+ *
631
+ * The blueprint registry is loaded via dynamic `import()` to keep this module
632
+ * off the runtime-catalog module-load cycle (see CLAUDE.md smoke-budget note).
633
+ * A blueprint whose definition is missing (not yet materialized / invalid) still
634
+ * gets a card — it falls back to its id for the name and offers a direct Run.
635
+ *
636
+ * `primaryBlueprintId` flags which card reads "Start here." When it doesn't
637
+ * match any blueprint, no card is flagged (rather than defaulting to the first,
638
+ * which for Agency Pro is the schedule-driven month-end-close — the wrong first
639
+ * click per the walkthrough).
640
+ */
641
+ async function loadBlueprintCards(
642
+ manifest: AppManifest,
643
+ primaryBlueprintId: string | undefined
644
+ ): Promise<BlueprintCard[]> {
645
+ let getBlueprint: ((id: string) => { name?: string; description?: string; variables?: BlueprintVariable[] } | undefined) | null = null;
646
+ try {
647
+ const mod = await import("@/lib/workflows/blueprints/registry");
648
+ getBlueprint = mod.getBlueprint;
649
+ } catch {
650
+ getBlueprint = null;
651
+ }
652
+ // Resolve human table names for row-insert copy. Install rewrites the
653
+ // manifest's `intake`/`grants` ids to real UUIDs, so the raw trigger.table
654
+ // is unreadable ("a new 583aa0c2-… row"). The table store's `name` column
655
+ // carries the label. Loaded via dynamic import (same module-cycle guard).
656
+ const tableName = await loadTableNameResolver();
657
+ return manifest.blueprints.map((stub) => {
658
+ const def = getBlueprint?.(stub.id);
659
+ const trigger =
660
+ stub.trigger?.kind === "row-insert"
661
+ ? {
662
+ kind: "row-insert" as const,
663
+ table: stub.trigger.table,
664
+ tableName: tableName(stub.trigger.table),
665
+ }
666
+ : null;
667
+ return {
668
+ id: stub.id,
669
+ name: def?.name ?? stub.id,
670
+ description: def?.description ?? null,
671
+ variables: def?.variables ?? [],
672
+ trigger,
673
+ isPrimary: stub.id === primaryBlueprintId,
674
+ };
675
+ });
676
+ }
677
+
678
+ /**
679
+ * Returns a synchronous `id → human name` lookup for user-tables, so
680
+ * `loadBlueprintCards` can label row-insert triggers without an await per
681
+ * blueprint. Falls back to the id itself when a table isn't found (or the
682
+ * store is unavailable). Dynamic import keeps `data.ts` off the runtime-catalog
683
+ * module-load cycle (smoke-budget rule).
684
+ */
685
+ async function loadTableNameResolver(): Promise<(id: string) => string> {
686
+ try {
687
+ const mod = await import("@/lib/data/tables");
688
+ const tables = await mod.listTables();
689
+ const byId = new Map<string, string>();
690
+ for (const t of tables) {
691
+ if (t.id && t.name) byId.set(t.id, t.name);
692
+ }
693
+ return (id: string) => byId.get(id) ?? id;
694
+ } catch {
695
+ return (id: string) => id;
696
+ }
697
+ }
698
+
620
699
  // --- Phase 4: helpers ---------------------------------------------------------
621
700
 
622
701
  /** Converts milliseconds to a human-readable age string. */
@@ -1,7 +1,7 @@
1
1
  import { createElement } from "react";
2
2
  import yaml from "js-yaml";
3
3
  import { ManifestPaneBody } from "@/components/apps/kit-view/manifest-pane-body";
4
- import { LastRunCard } from "@/components/apps/last-run-card";
4
+ import { RunnableBlueprintCard } from "@/components/apps/last-run-card";
5
5
  import { ErrorTimeline } from "@/components/workflows/error-timeline";
6
6
  import type { ViewConfig } from "@/lib/apps/registry";
7
7
  import type {
@@ -18,9 +18,35 @@ interface WorkflowHubProjection extends KitProjection {
18
18
  blueprintIds: string[];
19
19
  scheduleIds: string[];
20
20
  kpiSpecs: KpiSpec[];
21
+ primaryBlueprintId?: string;
21
22
  manifestYaml: string;
22
23
  }
23
24
 
25
+ /**
26
+ * FEAT-5/6: pick the "Start here" blueprint for the card home. The recommended
27
+ * first click is the most rewarding manually-runnable workflow. We rank by:
28
+ * 1. NOT row-insert-triggered — those fire on their own, so a manual Run
29
+ * fights the contract.
30
+ * 2. NOT the target of a schedule — a schedule-driven job (e.g. month-end
31
+ * close) runs itself on cadence and is a poor, often empty, first click
32
+ * (the walkthrough's BUG-2 dead end).
33
+ * First blueprint clearing both filters wins; then any manual blueprint; then
34
+ * the first blueprint. `manifest` is read directly here because `resolve()` has
35
+ * no registry access (that lives on the server-only data layer) — trigger +
36
+ * schedule shape from the manifest is enough to choose.
37
+ */
38
+ function pickPrimaryBlueprintId(input: ResolveInput): string | undefined {
39
+ const bps = input.manifest.blueprints;
40
+ const scheduled = new Set(
41
+ input.manifest.schedules
42
+ .map((s) => s.runs)
43
+ .filter((r): r is string => typeof r === "string")
44
+ );
45
+ const manual = bps.filter((b) => b.trigger?.kind !== "row-insert");
46
+ const unscheduledManual = manual.filter((b) => !scheduled.has(b.id));
47
+ return unscheduledManual[0]?.id ?? manual[0]?.id ?? bps[0]?.id;
48
+ }
49
+
24
50
  /**
25
51
  * Workflow Hub — the catch-all kit. Renders for any composed app that
26
52
  * doesn't match a more specific archetype (≥2 blueprints OR no clear hero
@@ -45,6 +71,7 @@ export const workflowHubKit: KitDefinition = {
45
71
  blueprintIds: input.manifest.blueprints.map((b) => b.id),
46
72
  scheduleIds: input.manifest.schedules.map((s) => s.id),
47
73
  kpiSpecs: input.manifest.view?.bindings?.kpis ?? [],
74
+ primaryBlueprintId: pickPrimaryBlueprintId(input),
48
75
  manifestYaml: yaml.dump(input.manifest, { lineWidth: 100 }),
49
76
  };
50
77
  return projection;
@@ -53,18 +80,24 @@ export const workflowHubKit: KitDefinition = {
53
80
  buildModel(proj: KitProjection, runtime: RuntimeState): ViewModel {
54
81
  const projection = proj as WorkflowHubProjection;
55
82
  const { app } = runtime;
56
- const blueprintIds = projection.blueprintIds;
57
83
 
58
84
  const lastRuns = runtime.blueprintLastRuns ?? {};
59
85
  const counts = runtime.blueprintRunCounts ?? {};
60
86
 
61
- const secondary = blueprintIds.map((bpId) => ({
62
- id: `blueprint-${bpId}`,
63
- content: createElement(LastRunCard, {
64
- blueprintId: bpId,
65
- blueprintLabel: bpId,
66
- lastRun: lastRuns[bpId] ?? null,
67
- runCount30d: counts[bpId] ?? 0,
87
+ // FEAT-5/6: runnable blueprint cards. Prefer the enriched cards (name +
88
+ // description + variables + trigger) resolved by the data layer; fall back
89
+ // to bare ids so the home never renders empty if enrichment failed. The
90
+ // "Start here" card sorts first.
91
+ const cards = runtime.blueprintCards ?? [];
92
+ const ordered = [...cards].sort(
93
+ (a, b) => Number(b.isPrimary) - Number(a.isPrimary)
94
+ );
95
+ const secondary = ordered.map((card) => ({
96
+ id: `blueprint-${card.id}`,
97
+ content: createElement(RunnableBlueprintCard, {
98
+ card,
99
+ lastRun: lastRuns[card.id] ?? null,
100
+ runCount30d: counts[card.id] ?? 0,
68
101
  }),
69
102
  }));
70
103
 
@@ -91,6 +124,12 @@ export const workflowHubKit: KitDefinition = {
91
124
  cadenceChip: runtime.cadence ?? undefined,
92
125
  },
93
126
  kpis: runtime.evaluatedKpis ?? [],
127
+ // FEAT-7: the blueprint-vs-workflow one-liner. Only render it when there
128
+ // are cards to explain, so an empty hub doesn't show a dangling lead.
129
+ secondaryLead:
130
+ secondary.length > 0
131
+ ? "Each card below is a workflow this app can run. Pick one and click Run to start it."
132
+ : undefined,
94
133
  secondary,
95
134
  activity,
96
135
  footer: {
@@ -78,6 +78,14 @@ export interface RuntimeState {
78
78
  blueprintRunCounts?: Record<string, number>;
79
79
  /** Phase 2: recent failed tasks for Workflow Hub `error-timeline`. */
80
80
  failedTasks?: RuntimeTaskSummary[];
81
+ /**
82
+ * FEAT-5/6: per-blueprint card metadata for the runnable-cards home. Name +
83
+ * one-line description + input variables (for the Run sheet) + trigger (for
84
+ * row-insert gating) + `isPrimary` ("Start here" flag). Resolved from the
85
+ * blueprint registry via a dynamic import so `data.ts` stays off the
86
+ * runtime-catalog module-load cycle (see CLAUDE.md smoke-budget note).
87
+ */
88
+ blueprintCards?: BlueprintCard[];
81
89
 
82
90
  /** Phase 3: Coach kit fields. */
83
91
  coachLatestTask?: RuntimeTaskSummary | null;
@@ -124,6 +132,30 @@ export interface HeroTableData {
124
132
  rows: UserTableRowRow[];
125
133
  }
126
134
 
135
+ /**
136
+ * FEAT-5/6: metadata for one runnable blueprint card on the app home. Combines
137
+ * the manifest stub (id + trigger) with the registered blueprint definition
138
+ * (name + description + variables). `isPrimary` flags the "Start here" card.
139
+ */
140
+ export interface BlueprintCard {
141
+ id: string;
142
+ /** Human name from the blueprint definition; falls back to the id. */
143
+ name: string;
144
+ /** One-line "what it does" from the blueprint definition, if any. */
145
+ description: string | null;
146
+ /** Input variables for the Run sheet; empty array = direct-POST run. */
147
+ variables: import("@/lib/workflows/blueprints/types").BlueprintVariable[];
148
+ /**
149
+ * How the blueprint fires. `row-insert` blueprints run automatically on new
150
+ * rows — the card labels that instead of offering a fighting manual Run.
151
+ * `table` is the raw (post-install often a UUID) table id; `tableName` is the
152
+ * human-readable table name for copy, resolved from the table store.
153
+ */
154
+ trigger: { kind: "row-insert"; table: string; tableName: string } | null;
155
+ /** The recommended first workflow, rendered with a "Start here" flag. */
156
+ isPrimary: boolean;
157
+ }
158
+
127
159
  /** Phase 2: minimal task summary used by Workflow Hub's secondary + activity. */
128
160
  export interface RuntimeTaskSummary {
129
161
  id: string;
@@ -215,6 +247,12 @@ export interface ViewModel {
215
247
  kpis?: KpiTile[];
216
248
  hero?: HeroSlot;
217
249
  secondary?: SecondarySlot[];
250
+ /**
251
+ * FEAT-7: an optional one-line lead rendered above the secondary grid. The
252
+ * Workflow Hub uses it for the blueprint-vs-workflow explainer ("each card is
253
+ * a workflow you can run"). Plain text so it can't become a styling hatch.
254
+ */
255
+ secondaryLead?: string;
218
256
  activity?: ActivityFeedSlot;
219
257
  footer?: ManifestPaneSlot;
220
258
  }
@@ -38,6 +38,12 @@ export function getWorkspaceContext(): WorkspaceContext {
38
38
  cwd,
39
39
  encoding: "utf-8",
40
40
  timeout: 3000,
41
+ // Route git's stderr to a pipe (captured on the thrown error, then
42
+ // discarded) instead of inheriting the console. Without this, a
43
+ // non-git cwd leaks a raw `fatal: not a git repository` to a
44
+ // customer's first-run log before the catch swallows the exit code.
45
+ // Mirrors src/lib/instance/git-ops.ts run().
46
+ stdio: ["ignore", "pipe", "pipe"],
41
47
  }).trim() || null;
42
48
  } catch {
43
49
  // not a git repo or git not available
@@ -93,9 +93,18 @@ export async function listPendingApprovalPayloads(
93
93
  const isBatchProposal = row.type === "context_proposal_batch";
94
94
  const parsedInput = parseNotificationToolInput(row.toolInput);
95
95
 
96
- // For checkpoint notifications (taskId is null), extract workflowId from toolInput
96
+ // For workflow-posted notifications with no taskId, extract workflowId from
97
+ // toolInput so the item deep-links to the workflow page. Both the checkpoint
98
+ // approval (WorkflowCheckpoint) and the HITL ask-user (AskUserQuestion, posted
99
+ // by the workflow engine — see BUG-3) carry `workflowId` in toolInput. A chat
100
+ // task's AskUserQuestion has a taskId and flows through row.workflowId instead,
101
+ // so this branch only fires for the engine-posted, taskId-null case.
97
102
  let effectiveWorkflowId = row.workflowId;
98
- if (!row.taskId && row.toolName === "WorkflowCheckpoint" && row.toolInput) {
103
+ if (
104
+ !row.taskId &&
105
+ (row.toolName === "WorkflowCheckpoint" || row.toolName === "AskUserQuestion") &&
106
+ row.toolInput
107
+ ) {
99
108
  try {
100
109
  const parsed = typeof row.toolInput === "string" ? JSON.parse(row.toolInput) : row.toolInput;
101
110
  effectiveWorkflowId = parsed.workflowId ?? null;
@@ -1,5 +1,5 @@
1
1
  id: relay-agency-pro
2
- version: "0.2.0"
2
+ version: "0.3.0"
3
3
  name: Relay Agency Pro
4
4
  description: >
5
5
  The agency operating system on top of the free Relay Agency verbs: a
@@ -74,7 +74,13 @@ schedules:
74
74
  runs: relay-agency-pro--month-end-close
75
75
 
76
76
  view:
77
- kit: ledger
77
+ # Agency Pro is a six-workflow operating system, so the app home is the
78
+ # multi-blueprint Workflow Hub: one runnable card per chapter with a Run
79
+ # action, the recommended first workflow flagged "Start here." (The finance
80
+ # KPI tiles below still render — the `kpis` binding is kit-agnostic.) The
81
+ # single-hero ledger kit hid 5 of the 6 chapters on the app's own home
82
+ # (FEAT-5/6, redesign 2026-07-04).
83
+ kit: workflow-hub
78
84
  bindings:
79
85
  hero:
80
86
  table: engagements
@@ -1,5 +1,5 @@
1
1
  id: relay-agency-pro
2
- version: "0.2.0"
2
+ version: "0.3.0"
3
3
  name: Relay Agency Pro
4
4
  author: Orionfold
5
5
  # This description renders as the what-you-get preview on the locked /packs
@@ -61,4 +61,8 @@ changelog:
61
61
  The Nonprofit deep chapter, a grant pipeline that takes every opportunity
62
62
  from fit-scored go/no-go through LOI, full application, and post-award
63
63
  restricted-funds compliance with a reporting calendar.
64
+ "0.3.0": >-
65
+ A new home screen that shows all six workflows as cards you can run with
66
+ one click. It tells you where to start, and each card shows its last run.
67
+ No more hunting through menus to find what your app can do.
64
68
  customers: []
@@ -1,6 +1,6 @@
1
1
  id: echo-server
2
2
  version: 0.1.0
3
- apiVersion: "0.24"
3
+ apiVersion: "0.25"
4
4
  kind: chat-tools
5
5
  name: Echo Server
6
6
  description: |
@@ -1,6 +1,6 @@
1
1
  id: finance-pack
2
2
  version: 0.1.0
3
- apiVersion: "0.24"
3
+ apiVersion: "0.25"
4
4
  kind: primitives-bundle
5
5
  name: Finance Pack
6
6
  description: |
@@ -1,6 +1,6 @@
1
1
  id: reading-radar
2
2
  version: 0.1.0
3
- apiVersion: "0.24"
3
+ apiVersion: "0.25"
4
4
  kind: primitives-bundle
5
5
  name: Reading Radar
6
6
  description: |
@@ -53,7 +53,7 @@ import type { ScheduleSpec } from "@/lib/validators/schedule-spec";
53
53
  // unfixed from 0.15.0 through 0.16.0 — treat the window test's failure as
54
54
  // a release blocker, not noise). The 0.13→0.14 three-MINOR bridge is over;
55
55
  // this is the standard 2-MINOR window now.
56
- const SUPPORTED_API_VERSIONS = new Set([CURRENT_PLUGIN_API_VERSION, "0.23"]);
56
+ const SUPPORTED_API_VERSIONS = new Set([CURRENT_PLUGIN_API_VERSION, "0.24"]);
57
57
 
58
58
  /** Test-helper export so the window-enforcement test can read state. */
59
59
  export function isSupportedApiVersion(apiVersion: string): boolean {
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  // (a hardcoded copy there once drifted to "0.14" — scaffolded plugins would
7
7
  // have been disabled on load the moment the window tightened). Bump on every
8
8
  // MINOR release; api-version-window.test.ts fails if this goes stale.
9
- export const CURRENT_PLUGIN_API_VERSION = "0.24";
9
+ export const CURRENT_PLUGIN_API_VERSION = "0.25";
10
10
 
11
11
  // Shared capability tuple — single source of truth used by Zod schema and
12
12
  // capability-check.ts hash derivation. Exported so consumers don't need a
@@ -347,6 +347,7 @@ async function executeCheckpoint(
347
347
  workflowRuntimeId?: string
348
348
  ): Promise<void> {
349
349
  let previousOutput = "";
350
+ let userAnswer = "";
350
351
 
351
352
  for (let i = 0; i < definition.steps.length; i++) {
352
353
  const step = definition.steps[i];
@@ -365,9 +366,35 @@ async function executeCheckpoint(
365
366
  }
366
367
  }
367
368
 
368
- const contextPrompt = previousOutput
369
- ? `Previous step output:\n${previousOutput}\n\n---\n\n${step.prompt}`
370
- : step.prompt;
369
+ // If step declares it needs data from the user, ask BEFORE running the agent
370
+ // and BLOCK (indefinitely) until answered. The run holds in `paused`; the
371
+ // typed answer is injected into this step's context. (BUG-3.)
372
+ userAnswer = "";
373
+ if (step.requiresInput) {
374
+ state.stepStates[i].status = "waiting_approval";
375
+ state.status = "paused";
376
+ await updateWorkflowState(workflowId, state, "paused");
377
+
378
+ const question = step.inputPrompt ?? step.prompt;
379
+ userAnswer = await waitForInput(workflowId, step.name, question);
380
+
381
+ // Answered — clear the pause and resume execution of this step.
382
+ state.status = "running";
383
+ state.stepStates[i].status = "running";
384
+ await updateWorkflowState(workflowId, state, "active");
385
+ }
386
+
387
+ const contextParts: string[] = [];
388
+ if (previousOutput) {
389
+ contextParts.push(`Previous step output:\n${previousOutput}`);
390
+ }
391
+ if (userAnswer.trim()) {
392
+ contextParts.push(`User-provided input:\n${userAnswer}`);
393
+ }
394
+ const contextPrompt =
395
+ contextParts.length > 0
396
+ ? `${contextParts.join("\n\n---\n\n")}\n\n---\n\n${step.prompt}`
397
+ : step.prompt;
371
398
 
372
399
  const result = await executeStep(
373
400
  workflowId,
@@ -388,6 +415,24 @@ async function executeCheckpoint(
388
415
  }
389
416
 
390
417
  previousOutput = result.result ?? "";
418
+
419
+ // Halt-on-refusal: an agent can "complete" while producing no usable
420
+ // artifact (e.g. it refused to fabricate and asked for missing data in
421
+ // prose). Running dependent steps on top of that empty output cascades
422
+ // refusals into a false `completed` with nothing on disk (the BUG-3
423
+ // failure mode). If this step yielded no usable output and later steps
424
+ // depend on it, stop loudly instead. We key on empty output — not brittle
425
+ // prose matching — which is exactly the "no artifact" symptom observed.
426
+ const hasDependents = i < definition.steps.length - 1;
427
+ if (hasDependents && previousOutput.trim().length === 0) {
428
+ state.stepStates[i].status = "failed";
429
+ state.stepStates[i].error =
430
+ "Step produced no usable output; halting dependent steps to avoid a false completion. " +
431
+ "The agent may have refused for missing context — check its output and re-run with the needed input.";
432
+ throw new Error(
433
+ `Step "${step.name}" produced no usable output — halting workflow to avoid cascading refusals`
434
+ );
435
+ }
391
436
  }
392
437
  }
393
438
 
@@ -1157,6 +1202,64 @@ async function waitForApproval(
1157
1202
  return false; // Timeout — treat as denied
1158
1203
  }
1159
1204
 
1205
+ /**
1206
+ * Ask the user a question mid-workflow and BLOCK until they answer.
1207
+ *
1208
+ * Reuses the existing `AskUserQuestion` answer-carrying loop that already backs
1209
+ * chat tasks: it writes a `permission_required` notification with
1210
+ * `toolName:"AskUserQuestion"`, which `PermissionResponseActions` renders as a
1211
+ * free-text/options prompt and `/api/tasks/[id]/respond` answers by writing
1212
+ * `response.updatedInput.answer`. The Inbox surfaces it (via
1213
+ * listPendingApprovalPayloads) deep-linked to the workflow page.
1214
+ *
1215
+ * Unlike waitForApproval this has NO deadline — the workflow row is marked
1216
+ * `paused` by the caller and this poll holds until a response appears. No silent
1217
+ * deny-on-timeout (BUG-3, operator decision: indefinite pause is the honest
1218
+ * default). Returns the typed answer string, or "" if the response carried none.
1219
+ */
1220
+ async function waitForInput(
1221
+ workflowId: string,
1222
+ stepName: string,
1223
+ question: string,
1224
+ options?: string[]
1225
+ ): Promise<string> {
1226
+ const notificationId = crypto.randomUUID();
1227
+
1228
+ await db.insert(notifications).values({
1229
+ id: notificationId,
1230
+ taskId: null,
1231
+ type: "permission_required",
1232
+ title: `Workflow needs input: ${stepName}`,
1233
+ body: question.slice(0, 500),
1234
+ toolName: "AskUserQuestion",
1235
+ toolInput: JSON.stringify({ question, options, workflowId, stepName }),
1236
+ createdAt: new Date(),
1237
+ });
1238
+
1239
+ // Poll indefinitely — no deadline. The workflow is already marked `paused`, so
1240
+ // a restart won't leave it falsely `active`; the operator answers whenever.
1241
+ const pollInterval = 2000;
1242
+
1243
+ for (;;) {
1244
+ const [notification] = await db
1245
+ .select()
1246
+ .from(notifications)
1247
+ .where(eq(notifications.id, notificationId));
1248
+
1249
+ if (notification?.response) {
1250
+ try {
1251
+ const parsed = JSON.parse(notification.response);
1252
+ const answer = parsed?.updatedInput?.answer;
1253
+ return typeof answer === "string" ? answer : "";
1254
+ } catch {
1255
+ return "";
1256
+ }
1257
+ }
1258
+
1259
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
1260
+ }
1261
+ }
1262
+
1160
1263
 
1161
1264
  /**
1162
1265
  * Update workflow state in the database.
@@ -28,6 +28,19 @@ export interface WorkflowStep {
28
28
  * a prompt/profile/runtime. See features/workflow-step-delays.md.
29
29
  */
30
30
  delayDuration?: string;
31
+ /**
32
+ * If set, the checkpoint engine pauses BEFORE running this step's agent and
33
+ * asks the user a question (via the existing `AskUserQuestion` notification +
34
+ * `/api/tasks/[id]/respond` answer loop). The typed answer is injected into
35
+ * the step's context prompt so the agent has the missing data. The run holds
36
+ * in workflow status `paused` indefinitely — no deadline, no auto-fail — until
37
+ * the user responds from the Inbox. Use `inputPrompt` to override the question
38
+ * text; when omitted, the step's own `prompt` is used as the question.
39
+ * See features/fix-workflow-hitl-ask-user.md (BUG-3).
40
+ */
41
+ requiresInput?: boolean;
42
+ /** Question text shown to the user when `requiresInput` is set. Falls back to `prompt`. */
43
+ inputPrompt?: string;
31
44
  /**
32
45
  * Optional declarative side-effect to apply after the step's task completes
33
46
  * successfully. Used by bulk row enrichment to write the agent's result back