feat(FN-2992): merge fusion/fn-2992

Commits merged:
- fix(FN-2992): align ResearchView styles with design tokens
- fix(FN-2992): align Research view with standalone layout conventions
- test(FN-2992): complete Step 6 — expand orchestration verification
- feat(FN-2992): complete Step 5 — wire research settings and engine exports
- test(FN-2992): cover provider error classification in step runner
- feat(FN-2992): complete Step 4 — implement research step runner
- test(FN-2992): add orchestrator lifecycle regression coverage
- feat(FN-2992): complete Step 3 — add research orchestrator lifecycle
- feat(FN-2992): complete Step 2 — align research store API events
- feat(FN-2992): complete Step 1 — define orchestration domain types
- feat(FN-2992): complete Step 1 — define orchestration domain types

Files changed:
packages/core/src/index.ts                         |  15 +
 packages/core/src/research-store.ts                |   8 +-
 packages/core/src/research-types.ts                | 109 +++++
 packages/core/src/settings-schema.ts               |  10 +
 packages/core/src/types.ts                         |  31 ++
 packages/dashboard/app/components/ResearchView.css |  87 +++-
 packages/dashboard/app/components/ResearchView.tsx | 144 +++---
 .../app/components/__tests__/ResearchView.test.tsx |   3 +-
 .../src/__tests__/research-orchestrator.test.ts    | 256 ++++++++++
 .../src/__tests__/research-step-runner.test.ts     | 102 ++++
 packages/engine/src/index.ts                       |  11 +
 packages/engine/src/project-engine.ts              |  17 +
 packages/engine/src/research-orchestrator.ts       | 517 +++++++++++++++++++++
 packages/engine/src/research-step-runner.ts        | 235 ++++++++++
 14 files changed, 1464 insertions(+), 81 deletions(-)

Fusion-Task-Id: FN-2992
This commit is contained in:
Fusion
2026-04-30 00:47:01 -07:00
committed by gsxdsm
parent b359cb8672
commit a227d5b305
14 changed files with 1466 additions and 83 deletions

View File

@@ -1,8 +1,17 @@
.research-view {
--research-font-size-label: 0.75rem;
--research-font-size-body: 0.8125rem;
--research-font-size-title: 1.125rem;
--research-font-size-subtitle: 0.8125rem;
--research-font-size-state-title: 1rem;
--research-font-size-run-title: 0.9375rem;
--research-font-size-stat-value: 1.25rem;
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
height: 100%;
min-height: 0;
overflow: hidden;
}
.research-view__header {
@@ -10,16 +19,35 @@
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-lg);
border-bottom: var(--btn-border-width) solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.research-view__content {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.research-view__title {
margin: 0;
color: var(--text);
font-size: var(--research-font-size-title);
font-weight: 600;
line-height: 1.3;
}
.research-view__subtitle {
margin: var(--space-xs) 0 0;
color: var(--text-muted);
font-size: var(--research-font-size-subtitle);
line-height: 1.5;
}
.research-view__state {
@@ -28,7 +56,6 @@
gap: var(--space-md);
padding: var(--space-lg);
}
.research-view__state--error {
border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 12%, var(--card));
@@ -46,12 +73,20 @@
.research-view__stat-label {
color: var(--text-muted);
font-size: var(--research-font-size-label);
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.research-view__stat-value {
margin-top: var(--space-xs);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--research-font-size-stat-value);
font-weight: 600;
line-height: 1.2;
}
.research-view__list {
@@ -73,34 +108,62 @@
gap: var(--space-sm);
}
.research-view__status-badge--failed {
border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 18%, transparent);
color: var(--color-error);
}
.research-view__run-title {
margin: 0;
color: var(--text);
font-size: var(--research-font-size-run-title);
font-weight: 600;
line-height: 1.4;
}
.research-view__run-query {
margin: 0;
color: var(--text-muted);
font-size: var(--research-font-size-body);
line-height: 1.5;
}
.research-view__run-summary {
margin: 0;
color: var(--text);
font-size: var(--research-font-size-body);
line-height: 1.5;
}
.research-view__state-title {
margin: 0;
color: var(--text);
font-size: var(--research-font-size-state-title);
font-weight: 600;
line-height: 1.3;
}
.research-view__state-copy {
margin: 0;
color: var(--text-muted);
font-size: var(--research-font-size-body);
line-height: 1.5;
}
.research-view__hint {
margin: 0;
color: var(--text-muted);
font-size: var(--research-font-size-body);
line-height: 1.5;
}
@media (max-width: 768px) {
.research-view {
.research-view__header {
flex-direction: column;
padding: var(--space-md);
}
.research-view__header {
flex-direction: column;
.research-view__title {
font-size: var(--research-font-size-state-title);
}
.research-view__content {
padding: var(--space-md);
}
.research-view__stats,

View File

@@ -67,79 +67,89 @@ export function ResearchView({ projectId, addToast }: ResearchViewProps) {
</button>
</header>
{isLoading && (
<div className="research-view__state card" data-testid="research-state-loading">
Loading research runs
</div>
)}
{!isLoading && error && (
<div className="research-view__state research-view__state--error card" data-testid="research-state-error">
<p>{error}</p>
<button className="btn btn-danger" type="button" onClick={() => void load()}>
Retry
</button>
</div>
)}
{!isLoading && !error && runs.length === 0 && (
<div className="research-view__state card" data-testid="research-state-empty">
No research runs yet. Start a run from the API or upcoming orchestration workflow.
</div>
)}
{!isLoading && !error && runs.length > 0 && (
<>
<div className="research-view__stats" data-testid="research-state-running">
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Total Runs</div>
<div className="research-view__stat-value">{stats?.total ?? runs.length}</div>
</div>
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Running</div>
<div className="research-view__stat-value">{stats?.byStatus.running ?? 0}</div>
</div>
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Completed</div>
<div className="research-view__stat-value">{stats?.byStatus.completed ?? 0}</div>
</div>
<div className="research-view__content">
{isLoading && (
<div className="research-view__state card" data-testid="research-state-loading">
Loading research runs
</div>
)}
<div className="research-view__list">
{runs.map((run) => (
<article key={run.id} className="card research-view__run-card">
<div className="research-view__run-head">
<span
className={`card-status-badge ${
run.status === "failed"
? "research-view__status-badge--failed"
: `card-status-badge--${
run.status === "pending"
? "todo"
: run.status === "running"
? "in-progress"
: run.status === "completed"
? "done"
: "archived"
}`
}`}
>
{STATUS_LABELS[run.status]}
</span>
<span className="card-id">{run.id}</span>
</div>
<h3 className="research-view__run-title">{run.topic || run.query}</h3>
<p className="research-view__run-query">{run.query}</p>
{run.results?.summary && <p data-testid="research-state-results">{run.results.summary}</p>}
</article>
))}
{!isLoading && error && (
<div className="research-view__state research-view__state--error card" data-testid="research-state-error">
<p>{error}</p>
<button className="btn btn-danger" type="button" onClick={() => void load()}>
Retry
</button>
</div>
)}
{!hasResults && (
<p className="research-view__hint">Runs are active, but no summarized results are available yet.</p>
)}
</>
)}
{!isLoading && !error && runs.length === 0 && (
<div className="research-view__state card" data-testid="research-state-empty">
<p className="research-view__state-title">No research runs yet</p>
<p className="research-view__state-copy">
Connect a research provider to begin collecting sources and generating synthesis reports. New runs can be
started through the API today and will appear here automatically.
</p>
</div>
)}
{!isLoading && !error && runs.length > 0 && (
<>
<div className="research-view__stats" data-testid="research-state-running">
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Total Runs</div>
<div className="research-view__stat-value">{stats?.total ?? runs.length}</div>
</div>
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Running</div>
<div className="research-view__stat-value">{stats?.byStatus.running ?? 0}</div>
</div>
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Completed</div>
<div className="research-view__stat-value">{stats?.byStatus.completed ?? 0}</div>
</div>
</div>
<div className="research-view__list">
{runs.map((run) => (
<article key={run.id} className="card research-view__run-card">
<div className="research-view__run-head">
<span
className={`card-status-badge ${
run.status === "failed"
? "failed"
: `card-status-badge--${
run.status === "pending"
? "todo"
: run.status === "running"
? "in-progress"
: run.status === "completed"
? "done"
: "archived"
}`
}`}
>
{STATUS_LABELS[run.status]}
</span>
<span className="card-id">{run.id}</span>
</div>
<h3 className="research-view__run-title">{run.topic || run.query}</h3>
<p className="research-view__run-query">{run.query}</p>
{run.results?.summary && (
<p className="research-view__run-summary" data-testid="research-state-results">
{run.results.summary}
</p>
)}
</article>
))}
</div>
{!hasResults && (
<p className="research-view__hint">Runs are active, but no summarized results are available yet.</p>
)}
</>
)}
</div>
</section>
);
}

View File

@@ -144,7 +144,8 @@ describe("ResearchView", () => {
render(<ResearchView projectId="p1" />);
await waitFor(() => {
expect(screen.getByText("Failed")).toHaveClass("research-view__status-badge--failed");
expect(screen.getByText("Failed")).toHaveClass("failed");
expect(screen.getByText("Failed")).toHaveClass("card-status-badge");
});
});