FN-5959: bridge task goal provenance from mission links
Bridge mission-linked task provenance into goal diagnostics. - derive task goal ids and goal records from the linked mission hierarchy with a task mission fallback - record provenanceGoalIds in goal-injection diagnostics, run-audit metadata, and executor task logs - add regression coverage and mission/diagnostics docs for derived task-to-goal provenance Files changed: docs/diagnostics.md | 5 +- docs/missions.md | 19 ++++ packages/core/src/__tests__/mission-store.test.ts | 123 +++++++++++++++++++++ packages/core/src/mission-store.ts | 55 ++++++++- packages/engine/src/__tests__/goal-injection-diagnostics-wiring.test.ts | 35 +++++- packages/engine/src/goal-injection-diagnostics.ts | 21 +++- 6 files changed, 247 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-5959 Fusion-Task-Lineage: 7c76ae98-37b1-421c-b477-a0147d034a4c
This commit is contained in:
@@ -4,12 +4,13 @@
|
||||
|
||||
Executor, heartbeat, and planning runs emit one goal-injection diagnostic with outcome `applied`, `no-goals`, or `disabled-or-failed`.
|
||||
|
||||
- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`.
|
||||
- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, provenanceGoalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`.
|
||||
- Goal anchoring events also persist `metadata.goalIds` (alongside existing count/tool fields):
|
||||
- `goal:injection-applied` / `goal:injection-skipped` → `{ lane, count, goalIds, truncated?, reason? }`
|
||||
- `goal:retrieval-invoked` → `{ toolName, count, goalIds, notFound }`
|
||||
- Run cited-goals read path: `GET /api/agents/:id/runs/:runId/cited-goals` returns `{ runId, taskId?, injectedGoalIds, retrievedGoalIds, citedGoalIds }` aggregated from `goal:*` + `prompt:goal-injection` run-audit events.
|
||||
- Task log (executor lane with `taskId`): `[goal-injection] <outcome> count=<n> ids=<json-array> truncated=<bool> ...`.
|
||||
- Task log (executor lane with `taskId`): `[goal-injection] <outcome> count=<n> ids=<json-array> provenance=<json-array> truncated=<bool> ...`.
|
||||
- `goalIds` / `goalCount` describe the active goals injected into the prompt; `provenanceGoalIds` additively records mission-derived task provenance and does not affect prompt selection.
|
||||
- Guardrail: diagnostics persist goal IDs/counts only; never prompt text, goal titles, or goal descriptions.
|
||||
|
||||
## Insight run sweeper (`[insight-sweeper]`)
|
||||
|
||||
@@ -52,6 +52,25 @@ Mission ↔ goal links are created and removed deliberately as part of normal pl
|
||||
|
||||
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.
|
||||
|
||||
### Task → Goal provenance
|
||||
|
||||
When a mission feature is linked or triaged into a task, Fusion does **not** copy goal ids onto the task row. Instead, task goal provenance is always derived from the mission link owned by `MissionStore`:
|
||||
|
||||
- `listGoalIdsForTask(taskId)` resolves the owning mission from the linked feature hierarchy first (`feature -> slice -> milestone -> mission`), then falls back to the live task row's `missionId` when needed.
|
||||
- `listGoalsForTask(taskId)` maps those ids back to full `Goal` records using the same goals-table read path as `getMissionWithHierarchy`, so mission reads and task provenance stay in sync.
|
||||
- Unknown, unlinked, or partially missing hierarchy state resolves fail-soft to `[]`.
|
||||
- Archived goals remain part of provenance; only missing goal rows are dropped.
|
||||
|
||||
This derived bridge lets downstream systems recover which strategic goals a task serves without duplicating mission-goal linkage during task creation.
|
||||
|
||||
### Goal-injection diagnostics provenance field
|
||||
|
||||
The engine's `resolveAndEmitGoalContext` seam still injects only the always-on active-goal context into prompts, but diagnostics now add `provenanceGoalIds: string[]` alongside the existing injected `goalIds` / `goalCount` fields.
|
||||
|
||||
- `goalIds` / `goalCount` continue to describe the active goals injected into the prompt.
|
||||
- `provenanceGoalIds` records which mission-linked goals the task serves.
|
||||
- Diagnostics and run-audit metadata persist ids/counts only — never goal titles, descriptions, or prompt text.
|
||||
|
||||
## Creating Missions
|
||||
|
||||
### Mission base branch defaults
|
||||
|
||||
@@ -1940,6 +1940,129 @@ describe("MissionStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task goal provenance", () => {
|
||||
async function createStoreWithTaskStore() {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
return { ts, ms: ts.getMissionStore(), goals: ts.getGoalStore() };
|
||||
}
|
||||
|
||||
it("returns empty arrays for unknown and unlinked tasks", async () => {
|
||||
const { ts, ms } = await createStoreWithTaskStore();
|
||||
const task = await ts.createTask({ title: "Standalone task", description: "No mission link" });
|
||||
|
||||
expect(ms.listGoalIdsForTask("FN-DOES-NOT-EXIST")).toEqual([]);
|
||||
expect(ms.listGoalsForTask("FN-DOES-NOT-EXIST")).toEqual([]);
|
||||
expect(ms.listGoalIdsForTask(task.id)).toEqual([]);
|
||||
expect(ms.listGoalsForTask(task.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array for mission-linked tasks when the mission has no goals", async () => {
|
||||
const { ts, ms } = await createStoreWithTaskStore();
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = ms.addFeature(slice.id, { title: "Feature" });
|
||||
const task = await ts.createTask({ title: "Task", description: "Linked task" });
|
||||
|
||||
ms.linkFeatureToTask(feature.id, task.id);
|
||||
|
||||
expect(ms.listGoalIdsForTask(task.id)).toEqual([]);
|
||||
expect(ms.listGoalsForTask(task.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves stable ordering for multiple linked goals and matches hierarchy mapping", async () => {
|
||||
const { ts, ms, goals } = await createStoreWithTaskStore();
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = ms.addFeature(slice.id, { title: "Feature" });
|
||||
const goalA = goals.createGoal({ title: "Goal A" });
|
||||
const goalB = goals.createGoal({ title: "Goal B" });
|
||||
|
||||
ms.linkGoal(mission.id, goalA.id);
|
||||
ms.linkGoal(mission.id, goalB.id);
|
||||
|
||||
const task = await ts.createTask({ title: "Task", description: "Linked task" });
|
||||
ms.linkFeatureToTask(feature.id, task.id);
|
||||
|
||||
expect(ms.listGoalIdsForTask(task.id)).toEqual([goalA.id, goalB.id]);
|
||||
expect(ms.listGoalsForTask(task.id)).toEqual(ms.getMissionWithHierarchy(mission.id)?.linkedGoals ?? []);
|
||||
});
|
||||
|
||||
it("keeps archived linked goals in task provenance", async () => {
|
||||
const { ts, ms, goals } = await createStoreWithTaskStore();
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = ms.addFeature(slice.id, { title: "Feature" });
|
||||
const goal = goals.createGoal({ title: "Archived goal" });
|
||||
ms.linkGoal(mission.id, goal.id);
|
||||
const archivedGoal = goals.archiveGoal(goal.id);
|
||||
|
||||
const task = await ts.createTask({ title: "Task", description: "Linked task" });
|
||||
ms.linkFeatureToTask(feature.id, task.id);
|
||||
|
||||
expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]);
|
||||
expect(ms.listGoalsForTask(task.id)).toEqual([archivedGoal]);
|
||||
});
|
||||
|
||||
it("falls back through feature linkage when tasks.missionId is unset", async () => {
|
||||
const { ts, ms, goals } = await createStoreWithTaskStore();
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = ms.addFeature(slice.id, { title: "Feature" });
|
||||
const goal = goals.createGoal({ title: "Fallback goal" });
|
||||
ms.linkGoal(mission.id, goal.id);
|
||||
|
||||
const task = await ts.createTask({ title: "Task", description: "Linked task" });
|
||||
ms.linkFeatureToTask(feature.id, task.id);
|
||||
db.prepare("UPDATE tasks SET missionId = NULL WHERE id = ?").run(task.id);
|
||||
|
||||
expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]);
|
||||
expect(ms.listGoalsForTask(task.id)).toEqual([goal]);
|
||||
});
|
||||
|
||||
it("resolves provenance for triaged tasks without storing goal ids on the task row", async () => {
|
||||
const { ts, ms, goals } = await createStoreWithTaskStore();
|
||||
const goal = goals.createGoal({ title: "Goal title" });
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
ms.linkGoal(mission.id, goal.id);
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = ms.addFeature(slice.id, { title: "Feature", description: "Desc" });
|
||||
|
||||
const triaged = await ms.triageFeature(feature.id);
|
||||
const task = await ts.getTask(triaged.taskId!);
|
||||
|
||||
expect(ms.listGoalsForTask(triaged.taskId!)).toEqual([
|
||||
expect.objectContaining({ id: goal.id, title: goal.title }),
|
||||
]);
|
||||
expect(task?.missionId).toBe(mission.id);
|
||||
expect(task).not.toHaveProperty("goalId");
|
||||
expect(task).not.toHaveProperty("goalIds");
|
||||
});
|
||||
|
||||
it("resolves provenance identically for manual feature linkage", async () => {
|
||||
const { ts, ms, goals } = await createStoreWithTaskStore();
|
||||
const goal = goals.createGoal({ title: "Manual goal" });
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
ms.linkGoal(mission.id, goal.id);
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = ms.addFeature(slice.id, { title: "Feature" });
|
||||
const task = await ts.createTask({ title: "Manual task", description: "Manual" });
|
||||
|
||||
ms.linkFeatureToTask(feature.id, task.id);
|
||||
|
||||
expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]);
|
||||
expect(ms.listGoalsForTask(task.id)).toEqual([
|
||||
expect.objectContaining({ id: goal.id, title: goal.title }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Transaction Tests ────────────────────────────────────────────────
|
||||
|
||||
describe("Transaction Handling", () => {
|
||||
|
||||
@@ -487,6 +487,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
private listGoalsByIds(goalIds: string[]): Goal[] {
|
||||
return goalIds
|
||||
.map((goalId) => this.db
|
||||
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
|
||||
.get(goalId) as GoalRow | undefined)
|
||||
.filter((row): row is GoalRow => Boolean(row))
|
||||
.map((row) => this.rowToGoal(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a MissionContractAssertion object.
|
||||
*/
|
||||
@@ -703,12 +712,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
const mission = this.getMission(id);
|
||||
if (!mission) return undefined;
|
||||
|
||||
const linkedGoals = this.listGoalIdsForMission(id)
|
||||
.map((goalId) => this.db
|
||||
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
|
||||
.get(goalId) as GoalRow | undefined)
|
||||
.filter((row): row is GoalRow => Boolean(row))
|
||||
.map((row) => this.rowToGoal(row));
|
||||
const linkedGoals = this.listGoalsByIds(this.listGoalIdsForMission(id));
|
||||
|
||||
const milestones = this.listMilestones(id);
|
||||
const milestonesWithSlices = milestones.map((milestone) => {
|
||||
@@ -1416,6 +1420,45 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
return rows.map((row) => row.missionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve task → goal provenance by deriving the owning mission from mission linkage.
|
||||
* Goal IDs are never duplicated onto the task row; provenance is always recovered from mission links.
|
||||
*/
|
||||
listGoalIdsForTask(taskId: string): string[] {
|
||||
const feature = this.getFeatureByTaskId(taskId);
|
||||
const missionIdFromFeature = feature
|
||||
? (() => {
|
||||
const slice = this.getSlice(feature.sliceId);
|
||||
if (!slice) {
|
||||
return undefined;
|
||||
}
|
||||
const milestone = this.getMilestone(slice.milestoneId);
|
||||
return milestone?.missionId;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const missionId = missionIdFromFeature ?? (() => {
|
||||
const row = this.db
|
||||
.prepare('SELECT missionId FROM tasks WHERE id = ? AND "deletedAt" IS NULL')
|
||||
.get(taskId) as { missionId?: string | null } | undefined;
|
||||
return row?.missionId ?? undefined;
|
||||
})();
|
||||
|
||||
if (!missionId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.listGoalIdsForMission(missionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve task → goal provenance to full Goal records derived from the owning mission.
|
||||
* Goal rows are read on demand so archived goals remain visible without storing duplicate task-level goal data.
|
||||
*/
|
||||
listGoalsForTask(taskId: string): Goal[] {
|
||||
return this.listGoalsByIds(this.listGoalIdsForTask(taskId));
|
||||
}
|
||||
|
||||
// ── Milestone Operations ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("goal injection diagnostics wiring seam", () => {
|
||||
for (const lane of lanes) {
|
||||
const store = {
|
||||
getGoalStore: () => ({ listGoals: () => [goal("G-1", "one", "2026-01-01T00:00:00.000Z")] }),
|
||||
getMissionStore: () => ({ listGoalIdsForTask: () => ["G-PROV-1", "G-PROV-2"] }),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
@@ -86,12 +87,17 @@ describe("goal injection diagnostics wiring seam", () => {
|
||||
expect(audit.database).toHaveBeenCalledTimes(1);
|
||||
expect(audit.database.mock.calls[0][0].metadata.lane).toBe(lane);
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata.lane).toBe(lane);
|
||||
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({
|
||||
lane,
|
||||
provenanceGoalIds: ["G-PROV-1", "G-PROV-2"],
|
||||
});
|
||||
expect(store.logEntry.mock.calls[0][1]).toContain('provenance=["G-PROV-1","G-PROV-2"]');
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveAndEmitGoalContext handles missing getGoalStore with disabled classification", async () => {
|
||||
const store = {
|
||||
getMissionStore: () => ({ listGoalIdsForTask: () => [] }),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
@@ -109,6 +115,7 @@ describe("goal injection diagnostics wiring seam", () => {
|
||||
expect(resolution.classification).toMatchObject({ outcome: "disabled-or-failed", reason: "store-unavailable" });
|
||||
expect(audit.database).toHaveBeenCalledTimes(1);
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ provenanceGoalIds: [] });
|
||||
});
|
||||
it("emits applied audit metadata for positive injection", async () => {
|
||||
const goals = [goal("G-1", "one", "2026-01-01T00:00:00.000Z"), goal("G-2", "two", "2026-01-02T00:00:00.000Z")];
|
||||
@@ -151,6 +158,32 @@ describe("goal injection diagnostics wiring seam", () => {
|
||||
expect(recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ outcome: "no-goals", goalCount: 0, goalIds: [] });
|
||||
});
|
||||
|
||||
it("fails soft when provenance resolution throws", async () => {
|
||||
const store = {
|
||||
getGoalStore: () => ({ listGoals: () => [goal("G-1", "one", "2026-01-01T00:00:00.000Z")] }),
|
||||
getMissionStore: () => ({
|
||||
listGoalIdsForTask: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
const audit = { database: vi.fn().mockResolvedValue(undefined) } as any;
|
||||
|
||||
await expect(resolveAndEmitGoalContext({
|
||||
lane: "executor",
|
||||
store,
|
||||
audit,
|
||||
taskId: "FN-1",
|
||||
runContext: { runId: "exec-run", agentId: "agent-1", taskId: "FN-1", phase: "execute" },
|
||||
})).resolves.toMatchObject({
|
||||
classification: { outcome: "applied", goalIds: ["G-1"] },
|
||||
});
|
||||
|
||||
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ provenanceGoalIds: [] });
|
||||
});
|
||||
|
||||
it("classifies list failure and keeps prompt construction alive", async () => {
|
||||
const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const store = { logEntry: vi.fn().mockResolvedValue(undefined), recordRunAuditEvent } as any;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface GoalInjectionDiagnostic {
|
||||
outcome: GoalInjectionOutcome;
|
||||
goalCount: number;
|
||||
goalIds: string[];
|
||||
provenanceGoalIds: string[];
|
||||
truncated: boolean;
|
||||
reason?: GoalInjectionDisabledReason;
|
||||
errorClass?: string;
|
||||
@@ -31,7 +32,8 @@ export interface GoalInjectionDiagnostic {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface GoalInjectionDiagnosticInput extends Omit<GoalInjectionDiagnostic, "timestamp"> {
|
||||
export interface GoalInjectionDiagnosticInput extends Omit<GoalInjectionDiagnostic, "timestamp" | "provenanceGoalIds"> {
|
||||
provenanceGoalIds?: string[];
|
||||
store?: TaskStore;
|
||||
runContext?: EngineRunContext | null;
|
||||
}
|
||||
@@ -140,6 +142,17 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let provenanceGoalIds: string[] = [];
|
||||
if (input.taskId && typeof input.store.getMissionStore === "function") {
|
||||
try {
|
||||
provenanceGoalIds = input.store.getMissionStore().listGoalIdsForTask(input.taskId);
|
||||
} catch (error) {
|
||||
diagnosticsLog.warn(
|
||||
`failed to resolve goal provenance for task ${input.taskId} in ${input.lane}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await emitGoalAnchoringAudit(input.audit, {
|
||||
lane: input.lane,
|
||||
taskId: input.taskId,
|
||||
@@ -152,6 +165,7 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext
|
||||
await emitGoalInjectionDiagnostic({
|
||||
lane: input.lane,
|
||||
...resolution.classification,
|
||||
provenanceGoalIds,
|
||||
runId: input.runContext?.runId,
|
||||
agentId: input.runContext?.agentId,
|
||||
taskId: input.taskId,
|
||||
@@ -164,9 +178,10 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext
|
||||
|
||||
function formatAgentLogLine(input: GoalInjectionDiagnostic): string {
|
||||
const ids = JSON.stringify(input.goalIds);
|
||||
const provenanceIds = JSON.stringify(input.provenanceGoalIds);
|
||||
const reason = input.reason ? ` reason=${input.reason}` : "";
|
||||
const errorClass = input.errorClass ? ` err=${input.errorClass}` : "";
|
||||
return `[goal-injection] ${input.outcome} count=${input.goalCount} ids=${ids} truncated=${String(input.truncated)}${reason}${errorClass}`;
|
||||
return `[goal-injection] ${input.outcome} count=${input.goalCount} ids=${ids} provenance=${provenanceIds} truncated=${String(input.truncated)}${reason}${errorClass}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +206,7 @@ export async function emitGoalInjectionDiagnostic(
|
||||
outcome: input.outcome,
|
||||
goalCount: input.goalCount,
|
||||
goalIds: [...input.goalIds],
|
||||
provenanceGoalIds: [...(input.provenanceGoalIds ?? [])],
|
||||
truncated: input.truncated,
|
||||
...(input.reason ? { reason: input.reason } : {}),
|
||||
...(input.errorClass ? { errorClass: input.errorClass } : {}),
|
||||
@@ -232,6 +248,7 @@ export async function emitGoalInjectionDiagnostic(
|
||||
outcome: record.outcome,
|
||||
goalCount: record.goalCount,
|
||||
goalIds: record.goalIds,
|
||||
provenanceGoalIds: record.provenanceGoalIds,
|
||||
truncated: record.truncated,
|
||||
...(record.reason ? { reason: record.reason } : {}),
|
||||
...(record.errorClass ? { errorClass: record.errorClass } : {}),
|
||||
|
||||
Reference in New Issue
Block a user