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 +69 -8
- package/package.json +1 -1
- package/src/components/apps/app-detail-actions.tsx +10 -23
- package/src/components/apps/kit-view/kit-view.tsx +8 -1
- package/src/components/apps/kit-view/slots/header.tsx +3 -3
- package/src/components/apps/kit-view/slots/manifest-sheet.tsx +2 -2
- package/src/components/apps/last-run-card.tsx +91 -2
- package/src/components/apps/ledger-hero-panel.tsx +2 -1
- package/src/components/settings/license-section.tsx +5 -0
- package/src/components/workflows/shared/workflow-header.tsx +114 -0
- package/src/lib/apps/view-kits/data.ts +79 -0
- package/src/lib/apps/view-kits/kits/workflow-hub.ts +48 -9
- package/src/lib/apps/view-kits/types.ts +38 -0
- package/src/lib/environment/workspace-context.ts +6 -0
- package/src/lib/notifications/actionable.ts +11 -2
- package/src/lib/packs/templates/relay-agency-pro/base/manifest.yaml +8 -2
- package/src/lib/packs/templates/relay-agency-pro/pack.yaml +5 -1
- package/src/lib/plugins/examples/echo-server/plugin.yaml +1 -1
- package/src/lib/plugins/examples/finance-pack/plugin.yaml +1 -1
- package/src/lib/plugins/examples/reading-radar/plugin.yaml +1 -1
- package/src/lib/plugins/registry.ts +1 -1
- package/src/lib/plugins/sdk/types.ts +1 -1
- package/src/lib/workflows/engine.ts +106 -3
- 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.
|
|
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.
|
|
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
|
-
|
|
24181
|
-
|
|
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.
|
|
25842
|
-
return "0.
|
|
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
|
@@ -2,15 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useTransition } from "react";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
|
-
import {
|
|
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
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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.
|
|
18
|
+
No data yet. Click <strong>Run now</strong> to start this app'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 {
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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 (
|
|
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
|
+
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
|
-
|
|
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
|
+
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: []
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|