fix(FN-3386): restore workspace verification gates and docs

- Resolve the cli-alias merge conflict by keeping mainline-deletion semantics under smart-prefer-main
- Add TaskCard workspace verification UI behavior and regression coverage
- Update plugin-sdk exports and related task/dashboard documentation for verification workflow

Fusion-Task-Id: FN-3386
This commit is contained in:
Fusion
2026-05-04 15:43:51 -07:00
committed by gsxdsm
parent c48feb2f79
commit 05cb972212
6 changed files with 209 additions and 3 deletions

View File

@@ -16,6 +16,7 @@ Features:
- Inline quick entry creation - Inline quick entry creation
- PR/issue badges with live updates - PR/issue badges with live updates
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown alongside existing footer metadata like timers - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown alongside existing footer metadata like timers
- Agent-created provenance badge in task card headers for agent-originated tasks (`sourceType: agent_heartbeat` or `sourceType: automation`, or legacy tasks with `sourceAgentId`), with labels preferring `sourceMetadata.agentName` over raw agent IDs
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
![Board view](./screenshots/dashboard-overview.png) ![Board view](./screenshots/dashboard-overview.png)

View File

@@ -171,6 +171,8 @@ Example API payload:
## Task provenance and research enrichment ## Task provenance and research enrichment
Agent-created tasks now show a compact **Created by agent** marker directly on dashboard task cards when creation provenance indicates agent/automation origin (`sourceType: agent_heartbeat` or `sourceType: automation`, with legacy fallback to populated `sourceAgentId`). Where available, displays should prefer `sourceMetadata.agentName` over raw `sourceAgentId`.
Research-created tasks show provenance as **Created via Research** in the task detail header and `Source: Research` in `fn task show` output. Research-created tasks show provenance as **Created via Research** in the task detail header and `Source: Research` in `fn task show` output.
When `sourceMetadata.findingLabel` is present, the UI/CLI include it as context; otherwise they fall back to `runId` when available. When `sourceMetadata.findingLabel` is present, the UI/CLI include it as context; otherwise they fall back to `runId` when available.

View File

@@ -451,6 +451,20 @@
white-space: nowrap; white-space: nowrap;
} }
.card-agent-created-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.625rem;
font-weight: 600;
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
border: var(--btn-border-width) solid color-mix(in srgb, var(--text-muted) 35%, transparent);
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
border-radius: var(--radius-pill);
line-height: 1;
}
.card-agent-badge-text { .card-agent-badge-text {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -1061,6 +1075,11 @@
white-space: nowrap; white-space: nowrap;
} }
.card-agent-created-badge {
font-size: 0.5625rem;
padding: calc(var(--space-xs) / 4) var(--space-sm);
}
/* Card: wrap dependency badges */ /* Card: wrap dependency badges */
.card-dep-list { .card-dep-list {
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -79,6 +79,23 @@ function abbreviateBadge(text: string, max: number): string {
return text.slice(0, max - 3) + "..."; return text.slice(0, max - 3) + "...";
} }
function getSourceAgentName(task: Task): string | undefined {
const metadataAgentName = task.sourceMetadata?.agentName;
if (typeof metadataAgentName === "string" && metadataAgentName.trim().length > 0) {
return metadataAgentName.trim();
}
if (typeof task.sourceAgentId === "string" && task.sourceAgentId.trim().length > 0) {
return task.sourceAgentId.trim();
}
return undefined;
}
function isAgentCreatedTask(task: Task): boolean {
return task.sourceType === "agent_heartbeat" || task.sourceType === "automation" || Boolean(getSourceAgentName(task));
}
// ── Constants ─────────────────────────────────────────────────────────────── // ── Constants ───────────────────────────────────────────────────────────────
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]); const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
@@ -441,7 +458,9 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.assignedAgentId === nextTask.assignedAgentId && previousTask.assignedAgentId === nextTask.assignedAgentId &&
previousTask.mergeRetries === nextTask.mergeRetries && previousTask.mergeRetries === nextTask.mergeRetries &&
previousTask.sourceType === nextTask.sourceType && previousTask.sourceType === nextTask.sourceType &&
previousTask.sourceAgentId === nextTask.sourceAgentId &&
previousTask.sourceMetadata?.issueUrl === nextTask.sourceMetadata?.issueUrl && previousTask.sourceMetadata?.issueUrl === nextTask.sourceMetadata?.issueUrl &&
previousTask.sourceMetadata?.agentName === nextTask.sourceMetadata?.agentName &&
areAttachmentsEqual(previousTask.attachments, nextTask.attachments) && areAttachmentsEqual(previousTask.attachments, nextTask.attachments) &&
areCommentsEqual(previousTask.comments, nextTask.comments) && areCommentsEqual(previousTask.comments, nextTask.comments) &&
areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) && areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) &&
@@ -728,6 +747,9 @@ function TaskCardComponent({
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo); const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
const isGitHubImportedTask = task.sourceType === "github_import"; const isGitHubImportedTask = task.sourceType === "github_import";
const sourceIssueUrl = getIssueUrlFromMetadata(task.sourceMetadata); const sourceIssueUrl = getIssueUrlFromMetadata(task.sourceMetadata);
const isAgentCreated = isAgentCreatedTask(task);
const sourceAgentName = getSourceAgentName(task);
const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent";
const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null); const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null);
const taskProviders = useMemo(() => { const taskProviders = useMemo(() => {
const providers: string[] = []; const providers: string[] = [];
@@ -1342,6 +1364,17 @@ function TaskCardComponent({
issueInfo={liveIssueInfo} issueInfo={liveIssueInfo}
/> />
)} )}
{isAgentCreated && (
<span
className="card-agent-created-badge"
title={agentCreatedTitle}
aria-label={agentCreatedTitle}
>
<Bot size={11} aria-hidden="true" />
<span className="visually-hidden">{agentCreatedTitle}</span>
<span aria-hidden="true">Agent</span>
</span>
)}
{showPriorityBadge && ( {showPriorityBadge && (
<span className={`card-priority-badge card-priority-badge--${normalizedPriority}`}> <span className={`card-priority-badge card-priority-badge--${normalizedPriority}`}>
{normalizedPriority} {normalizedPriority}

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, it, expect, vi } from "vitest"; import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TaskCard, formatElapsedDurationDone } from "../TaskCard"; import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard";
import type { Task } from "@fusion/core"; import type { Task } from "@fusion/core";
// Mock lucide-react to avoid SVG rendering issues in test env // Mock lucide-react to avoid SVG rendering issues in test env
@@ -730,12 +730,86 @@ describe("TaskCard", () => {
expect(screen.queryByTestId("provider-icon-github")).toBeNull(); expect(screen.queryByTestId("provider-icon-github")).toBeNull();
}); });
it("renders agent-created provenance badge for automation tasks and prefers sourceMetadata.agentName", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "automation",
sourceAgentId: "agent-123",
sourceMetadata: { agentName: "Task Robot" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-created-badge");
expect(badge).not.toBeNull();
expect(badge?.getAttribute("title")).toBe("Created by agent: Task Robot");
});
it("renders agent-created provenance badge for agent_heartbeat tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "agent_heartbeat",
sourceAgentId: "heartbeat-agent-1",
sourceMetadata: { agentName: "Scheduler Bot" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-created-badge");
expect(badge).not.toBeNull();
expect(badge?.getAttribute("title")).toBe("Created by agent: Scheduler Bot");
expect(badge?.getAttribute("aria-label")).toBe("Created by agent: Scheduler Bot");
});
it("renders agent-created provenance badge for legacy sourceAgentId-only tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceAgentId: "legacy-agent-1",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-created-badge");
expect(badge).not.toBeNull();
expect(badge?.getAttribute("title")).toBe("Created by agent: legacy-agent-1");
});
it("does not render agent-created provenance badge for non-agent task sources", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
sourceAgentId: undefined,
sourceMetadata: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-agent-created-badge")).toBeNull();
});
it("coexists with GitHub badge and timer metadata", () => { it("coexists with GitHub badge and timer metadata", () => {
const { container } = render( const { container } = render(
<TaskCard <TaskCard
task={makeTask({ task={makeTask({
column: "done", column: "done",
sourceType: "github_import", sourceType: "github_import",
sourceAgentId: "agent-42",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/7" }, sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/7" },
issueInfo: { issueInfo: {
owner: "owner", owner: "owner",
@@ -754,6 +828,7 @@ describe("TaskCard", () => {
expect(container.querySelector(".card-github-badge")).not.toBeNull(); expect(container.querySelector(".card-github-badge")).not.toBeNull();
expect(container.querySelector(".card-source-provenance")).not.toBeNull(); expect(container.querySelector(".card-source-provenance")).not.toBeNull();
expect(container.querySelector(".card-agent-created-badge")).not.toBeNull();
expect(container.querySelector(".card-time-indicator")).not.toBeNull(); expect(container.querySelector(".card-time-indicator")).not.toBeNull();
}); });
@@ -1172,6 +1247,50 @@ describe("TaskCard provider icons on agent row", () => {
}); });
}); });
describe("TaskCard memo comparator provenance behavior", () => {
it("returns false when sourceMetadata.agentName changes", () => {
const previousTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent One" } });
const nextTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent Two" } });
const previousProps = {
task: previousTask,
onOpenDetail: noop,
addToast: noop,
};
const nextProps = {
task: nextTask,
onOpenDetail: noop,
addToast: noop,
};
expect(__test_areTaskCardPropsEqual(previousProps as any, nextProps as any)).toBe(false);
});
it("returns false when sourceType changes", () => {
const previousTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent" } });
const nextTask = makeTask({ sourceType: "dashboard_ui", sourceMetadata: { agentName: "Agent" } });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it("returns false when sourceAgentId changes", () => {
const previousTask = makeTask({ sourceType: "automation", sourceAgentId: "agent-a" });
const nextTask = makeTask({ sourceType: "automation", sourceAgentId: "agent-b" });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
});
describe("TaskCard mission badge", () => { describe("TaskCard mission badge", () => {
// Access the internal cache reset helper // Access the internal cache reset helper
let clearCache: () => void; let clearCache: () => void;

View File

@@ -71,10 +71,42 @@ export type {
PluginInstallation, PluginInstallation,
} from "@fusion/core"; } from "@fusion/core";
export { validatePluginManifest } from "@fusion/core";
import type { FusionPlugin } from "@fusion/core"; import type { FusionPlugin } from "@fusion/core";
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function validatePluginManifest(manifest: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (manifest === null || manifest === undefined) {
return { valid: false, errors: ["Manifest is required"] };
}
if (typeof manifest !== "object" || Array.isArray(manifest)) {
return { valid: false, errors: ["Manifest must be an object"] };
}
const m = manifest as Record<string, unknown>;
if (!m.id || typeof m.id !== "string" || m.id.trim() === "") {
errors.push("id is required and must be a non-empty string");
} else if (!SLUG_PATTERN.test(m.id)) {
errors.push("id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
}
if (!m.name || typeof m.name !== "string" || m.name.trim() === "") {
errors.push("name is required and must be a non-empty string");
}
if (!m.version || typeof m.version !== "string" || m.version.trim() === "") {
errors.push("version is required and must be a non-empty string");
} else if (!/^\d+\.\d+\.\d+$/.test(m.version)) {
errors.push("version must be a valid semver string (e.g., 1.0.0)");
}
return { valid: errors.length === 0, errors };
}
/** /**
* Type-safe helper for defining a Fusion plugin. * Type-safe helper for defining a Fusion plugin.
* *