FN-5898: add unlinked mission goal indicator
Document the manual no-backfill mission-goal policy and flag active missions that still need explicit goal links. - document the mission→goal linkage model, explicit no-backfill decision, and manual linkage workflow in missions docs - include linkedGoalCount in mission summaries and cover single-summary/batched-summary behavior in MissionStore tests - show an Unlinked badge for active non-interview missions with zero linked goals and add MissionManager coverage Files changed: .changeset/fn-5898-mission-goal-unlinked-indicator.md | 5 ++ docs/missions.md | 18 ++++-- packages/core/src/__tests__/mission-store.test.ts | 32 +++++++++- packages/core/src/mission-store.ts | 25 +++++++- packages/dashboard/app/components/MissionManager.css | 6 ++ packages/dashboard/app/components/MissionManager.tsx | 14 ++++ packages/dashboard/app/components/__tests__/MissionManager.test.tsx | 74 ++++++++++++++++++++++ packages/dashboard/app/components/mission-types.ts | 1 + 8 files changed, 166 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-5898 Fusion-Task-Lineage: e31e9544-4e03-4b52-afbb-e372314b997e
This commit is contained in:
5
.changeset/fn-5898-mission-goal-unlinked-indicator.md
Normal file
5
.changeset/fn-5898-mission-goal-unlinked-indicator.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Document mission-to-goal linkage behavior, including the explicit no-backfill decision for existing missions, and surface an Unlinked badge for active missions without linked goals in Mission Manager.
|
||||
@@ -23,9 +23,9 @@ Mission: Improve Reliability
|
||||
Task: FN-214
|
||||
```
|
||||
|
||||
## Mission ↔ Goal persistence
|
||||
## Mission → Goal linkage
|
||||
|
||||
Missions and goals are stored independently, but Fusion now persists an optional many-to-many linkage in the `mission_goals` join table.
|
||||
Missions and goals are stored independently, with an optional many-to-many linkage persisted in the `mission_goals` join table.
|
||||
|
||||
- Columns: `missionId`, `goalId`, `createdAt`
|
||||
- Primary key: `(missionId, goalId)`
|
||||
@@ -33,14 +33,24 @@ Missions and goals are stored independently, but Fusion now persists an optional
|
||||
- Delete behavior: both foreign keys use `ON DELETE CASCADE`, so removing either parent deletes only the corresponding join rows
|
||||
- Reverse lookups are indexed via `idxMissionGoalsGoalId`
|
||||
|
||||
`MissionStore` owns the linkage CRUD surface:
|
||||
`MissionStore` owns the persisted linkage CRUD surface:
|
||||
|
||||
- `linkGoal(missionId, goalId)` — idempotently create a link and return `{ missionId, goalId, createdAt }`
|
||||
- `unlinkGoal(missionId, goalId)` — remove a link and report whether anything changed
|
||||
- `listGoalIdsForMission(missionId)` — list linked goals in deterministic creation order
|
||||
- `listMissionIdsForGoal(goalId)` — list linked missions in deterministic creation order
|
||||
|
||||
Existing missions are **not** backfilled with goal links as part of this schema change; that decision is deferred to FN-5898.
|
||||
### No-backfill decision
|
||||
|
||||
Existing missions are intentionally **not** auto-linked to any goals. Fusion does not run a migration backfill for pre-existing missions, so a mission with no links should be treated as genuinely unlinked until an operator or agent associates it with one or more goals.
|
||||
|
||||
### Manual linkage workflow
|
||||
|
||||
Mission ↔ goal links are created and removed deliberately as part of normal planning and operations work. Read surfaces can show current associations, and operator-facing write surfaces can add or remove links when a mission should explicitly support a goal. The workflow is intentionally manual so teams can choose the correct strategic relationship per mission instead of inheriting guessed links from older data.
|
||||
|
||||
### Unlinked mission indicator
|
||||
|
||||
Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association.
|
||||
|
||||
## Creating Missions
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ function createTaskInDb(
|
||||
).run(taskId, description, options?.column ?? "triage", status ?? null, now, now, options?.deletedAt ?? null);
|
||||
}
|
||||
|
||||
function createGoalInDb(database: Database, goalId: string, title = "Test goal"): void {
|
||||
const now = new Date().toISOString();
|
||||
database.prepare(
|
||||
"INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
).run(goalId, title, null, "active", now, now);
|
||||
}
|
||||
|
||||
describe("MissionStore", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
@@ -238,6 +245,7 @@ describe("MissionStore", () => {
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
progressPercent: 0,
|
||||
});
|
||||
});
|
||||
@@ -303,6 +311,19 @@ describe("MissionStore", () => {
|
||||
expect(summary.progressPercent).toBe(33);
|
||||
});
|
||||
|
||||
it("getMissionSummary reports linked goal counts", () => {
|
||||
const mission = store.createMission({ title: "Goal-linked mission" });
|
||||
createGoalInDb(db, "G-001", "North Star");
|
||||
createGoalInDb(db, "G-002", "Reliability");
|
||||
|
||||
expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(0);
|
||||
|
||||
store.linkGoal(mission.id, "G-001");
|
||||
store.linkGoal(mission.id, "G-002");
|
||||
|
||||
expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(2);
|
||||
});
|
||||
|
||||
it("findNextPendingSlice skips completed slices in earlier milestones", () => {
|
||||
const mission = store.createMission({ title: "Next pending" });
|
||||
const m1 = store.addMilestone(mission.id, { title: "M1" });
|
||||
@@ -376,6 +397,7 @@ describe("MissionStore", () => {
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
progressPercent: 0,
|
||||
});
|
||||
|
||||
@@ -386,6 +408,7 @@ describe("MissionStore", () => {
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
progressPercent: 0,
|
||||
});
|
||||
|
||||
@@ -396,6 +419,7 @@ describe("MissionStore", () => {
|
||||
completedMilestones: 1,
|
||||
totalFeatures: 2,
|
||||
completedFeatures: 1,
|
||||
linkedGoalCount: 0,
|
||||
progressPercent: 50,
|
||||
});
|
||||
});
|
||||
@@ -408,8 +432,11 @@ describe("MissionStore", () => {
|
||||
store.updateFeature(f1.id, { status: "done" });
|
||||
const f2 = store.addFeature(slice.id, { title: "F2" });
|
||||
store.updateFeature(f2.id, { status: "done" });
|
||||
const f3 = store.addFeature(slice.id, { title: "F3" });
|
||||
// f3 not done
|
||||
store.addFeature(slice.id, { title: "F3" });
|
||||
createGoalInDb(db, "G-003", "North Star");
|
||||
createGoalInDb(db, "G-004", "Reliability");
|
||||
store.linkGoal(mission.id, "G-003");
|
||||
store.linkGoal(mission.id, "G-004");
|
||||
|
||||
const singleSummary = store.getMissionSummary(mission.id);
|
||||
const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!;
|
||||
@@ -418,6 +445,7 @@ describe("MissionStore", () => {
|
||||
expect(batchedResult.summary.completedMilestones).toBe(singleSummary.completedMilestones);
|
||||
expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures);
|
||||
expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures);
|
||||
expect(batchedResult.summary.linkedGoalCount).toBe(singleSummary.linkedGoalCount);
|
||||
expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent);
|
||||
});
|
||||
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface MissionSummary {
|
||||
totalFeatures: number;
|
||||
/** Number of features with status "done" */
|
||||
completedFeatures: number;
|
||||
/** Number of goals linked to the mission */
|
||||
linkedGoalCount: number;
|
||||
/** Computed progress percentage (0–100), based on features or milestones */
|
||||
progressPercent: number;
|
||||
}
|
||||
@@ -765,6 +767,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
const linkedGoalRow = this.db
|
||||
.prepare("SELECT COUNT(*) AS count FROM mission_goals WHERE missionId = ?")
|
||||
.get(missionId) as { count?: number | bigint } | undefined;
|
||||
const linkedGoalCount = Number(linkedGoalRow?.count ?? 0);
|
||||
|
||||
let progressPercent = 0;
|
||||
if (totalFeatures > 0) {
|
||||
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
|
||||
@@ -777,6 +784,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
completedMilestones,
|
||||
totalFeatures,
|
||||
completedFeatures,
|
||||
linkedGoalCount,
|
||||
progressPercent,
|
||||
};
|
||||
}
|
||||
@@ -813,7 +821,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
).all() as unknown as FeatureRow[];
|
||||
const allFeatures = featureRows.map((row) => this.rowToFeature(row));
|
||||
|
||||
// 5. Group in-memory: slices by milestoneId, features by sliceId
|
||||
// 5. Batch query linked goal counts
|
||||
const linkedGoalRows = this.db.prepare(
|
||||
"SELECT missionId, COUNT(*) AS count FROM mission_goals GROUP BY missionId"
|
||||
).all() as Array<{ missionId: string; count?: number | bigint }>;
|
||||
const linkedGoalCountByMissionId = new Map(
|
||||
linkedGoalRows.map((row) => [row.missionId, Number(row.count ?? 0)]),
|
||||
);
|
||||
|
||||
// 6. Group in-memory: slices by milestoneId, features by sliceId
|
||||
const slicesByMilestoneId = new Map<string, Slice[]>();
|
||||
for (const slice of allSlices) {
|
||||
const list = slicesByMilestoneId.get(slice.milestoneId) || [];
|
||||
@@ -828,7 +844,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
featuresBySliceId.set(feature.sliceId, list);
|
||||
}
|
||||
|
||||
// 6. Group milestones by missionId
|
||||
// 7. Group milestones by missionId
|
||||
const milestonesByMissionId = new Map<string, Milestone[]>();
|
||||
for (const milestone of allMilestones) {
|
||||
const list = milestonesByMissionId.get(milestone.missionId) || [];
|
||||
@@ -836,7 +852,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
milestonesByMissionId.set(milestone.missionId, list);
|
||||
}
|
||||
|
||||
// 7. Compute summary for each mission using grouped data
|
||||
// 8. Compute summary for each mission using grouped data
|
||||
return missions.map((mission) => {
|
||||
const milestones = milestonesByMissionId.get(mission.id) || [];
|
||||
const totalMilestones = milestones.length;
|
||||
@@ -854,6 +870,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
const linkedGoalCount = linkedGoalCountByMissionId.get(mission.id) ?? 0;
|
||||
|
||||
let progressPercent = 0;
|
||||
if (totalFeatures > 0) {
|
||||
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
|
||||
@@ -868,6 +886,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
completedMilestones,
|
||||
totalFeatures,
|
||||
completedFeatures,
|
||||
linkedGoalCount,
|
||||
progressPercent,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -371,6 +371,12 @@
|
||||
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
|
||||
}
|
||||
|
||||
.mission-status-badge--unlinked {
|
||||
color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ── Shared Controls ── */
|
||||
.mission-icon-btn {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -4174,6 +4174,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
const progressPercent = health?.estimatedCompletionPercent ?? summary?.progressPercent ?? 0;
|
||||
const showSummaryBlock = hasContent || totalTasks > 0 || tasksFailed > 0 || Boolean(health?.lastActivityAt);
|
||||
const isInterviewStyle = options?.interviewStyle === true;
|
||||
const showUnlinkedIndicator =
|
||||
m.status === "active" &&
|
||||
!isInterviewStyle &&
|
||||
(mission.summary?.linkedGoalCount ?? 0) === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -4219,6 +4223,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
>
|
||||
{m.status}
|
||||
</span>
|
||||
{showUnlinkedIndicator && (
|
||||
<span
|
||||
className="mission-status-badge mission-status-badge--sm mission-status-badge--unlinked"
|
||||
title="No goals linked to this mission"
|
||||
aria-label="No goals linked to this mission"
|
||||
data-testid={`mission-unlinked-indicator-${m.id}`}
|
||||
>
|
||||
Unlinked
|
||||
</span>
|
||||
)}
|
||||
{isInterviewStyle && (
|
||||
<span className="mission-status-badge mission-status-badge--sm mission-interview-status mission-interview-status--awaiting_input">
|
||||
Interview in progress
|
||||
|
||||
@@ -109,6 +109,7 @@ const mockMissions = [
|
||||
completedMilestones: 1,
|
||||
totalFeatures: 5,
|
||||
completedFeatures: 3,
|
||||
linkedGoalCount: 1,
|
||||
progressPercent: 60,
|
||||
},
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
@@ -883,6 +884,79 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the unlinked indicator only for active missions without linked goals", async () => {
|
||||
const missions = [
|
||||
{
|
||||
id: "M-U1",
|
||||
title: "Needs goal link",
|
||||
description: "Active mission without linked goals",
|
||||
status: "active",
|
||||
interviewState: "not_started",
|
||||
milestones: [],
|
||||
summary: {
|
||||
totalMilestones: 0,
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
progressPercent: 0,
|
||||
},
|
||||
createdAt: "2026-01-03T00:00:00.000Z",
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "M-U2",
|
||||
title: "Already linked",
|
||||
description: "Active mission with linked goals",
|
||||
status: "active",
|
||||
interviewState: "not_started",
|
||||
milestones: [],
|
||||
summary: {
|
||||
totalMilestones: 0,
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 2,
|
||||
progressPercent: 0,
|
||||
},
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "M-U3",
|
||||
title: "Planning mission",
|
||||
description: "Non-active mission without linked goals",
|
||||
status: "planning",
|
||||
interviewState: "not_started",
|
||||
milestones: [],
|
||||
summary: {
|
||||
totalMilestones: 0,
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
progressPercent: 0,
|
||||
},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
globalThis.fetch = createFetchMockWithHealth(missions as Array<Record<string, unknown>>, {
|
||||
"M-U1": getMockMissionHealth("M-U1"),
|
||||
"M-U2": getMockMissionHealth("M-U2"),
|
||||
"M-U3": getMockMissionHealth("M-U3"),
|
||||
});
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-unlinked-indicator-M-U1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("mission-unlinked-indicator-M-U2")).toBeNull();
|
||||
expect(screen.queryByTestId("mission-unlinked-indicator-M-U3")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows summary stats when mission has summary data", async () => {
|
||||
globalThis.fetch = createFetchMock();
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
@@ -259,6 +259,7 @@ export interface MissionSummary {
|
||||
completedMilestones: number;
|
||||
totalFeatures: number;
|
||||
completedFeatures: number;
|
||||
linkedGoalCount?: number;
|
||||
progressPercent: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user