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:
@@ -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)
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user