diff --git a/.changeset/fn-8297-research-mission-bridge.md b/.changeset/fn-8297-research-mission-bridge.md new file mode 100644 index 0000000000..ec62558a28 --- /dev/null +++ b/.changeset/fn-8297-research-mission-bridge.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Promote completed research findings into mission roadmap features. +category: feature +dev: Persists stable finding and citation provenance with idempotent slice-level promotion. diff --git a/docs/missions.md b/docs/missions.md index cf0120bc63..d5b9280896 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -678,3 +678,7 @@ For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" } ## Ideation handoff [Persisted ideation](./ideation/persisted-diverge-converge.md) converges a selected candidate into this canonical hierarchy. It atomically creates or attaches a Mission and persists that linkage, rather than maintaining a parallel roadmap document. + +## Research-derived features + +A completed cited research finding may become a normal Mission Feature. Its feature retains research run, stable finding, and source-URL provenance; optional triage uses the normal feature task flow. Linked task changes reconcile through the existing feature → slice → milestone → mission rollups, and task completion remains subject to assertion validation. diff --git a/docs/research.md b/docs/research.md index 1216dcf76d..c38fcfac92 100644 --- a/docs/research.md +++ b/docs/research.md @@ -457,3 +457,7 @@ When all retries are exhausted, the run transitions to `retry_exhausted`. | All retries exhausted | Persistent provider error | Check provider status; create a fresh run | | Research view not visible in dashboard | Feature flag disabled | Set `experimentalFeatures.researchView` to `true` | | Settings modal missing Research sections | Feature flag disabled | Enable `researchView` feature flag first | + +## Promoting findings to mission features + +Completed findings can be promoted into a destination slice through **Promote to roadmap** or the action-gated `fn_research_promote_finding` tool. Promotion stores the research run ID, a position-independent finding ID, and that finding's cited source URLs on the canonical feature. Repeating the same run/finding promotion in the same slice reuses the existing feature. Pending, failed, cancelled, timed-out, retry-exhausted, disabled, and unconfigured research cannot mutate a mission. diff --git a/packages/core/src/__tests__/research-finding-identity.test.ts b/packages/core/src/__tests__/research-finding-identity.test.ts new file mode 100644 index 0000000000..890bdbc6a7 --- /dev/null +++ b/packages/core/src/__tests__/research-finding-identity.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { resolveResearchFindingId } from "../research-types.js"; + +describe("resolveResearchFindingId", () => { + it("is independent of result ordering and source ordering", () => { + const finding = { heading: "Latency", content: "Cache responses.", sources: ["https://b.example", "https://a.example"] }; + const reorderedResultSet = [{ heading: "Other", content: "Other result", sources: [] }, { ...finding, sources: [...finding.sources].reverse() }]; + + expect(resolveResearchFindingId(finding)).toBe(resolveResearchFindingId(reorderedResultSet[1]!)); + }); + + it("preserves a provider-persisted identity", () => { + expect(resolveResearchFindingId({ id: "provider-7", heading: "ignored", content: "ignored", sources: [] })).toBe("provider-7"); + }); +}); diff --git a/packages/core/src/async-mission-store-queries.ts b/packages/core/src/async-mission-store-queries.ts index 23fa1ede66..df913e2b3a 100644 --- a/packages/core/src/async-mission-store-queries.ts +++ b/packages/core/src/async-mission-store-queries.ts @@ -182,6 +182,9 @@ interface FeatureRow { lastValidatorStatus: string | null; generatedFromFeatureId: string | null; generatedFromRunId: string | null; + researchRunId: string | null; + researchFindingId: string | null; + researchSourceUrls: string[] | null; } interface MissionEventRow { @@ -334,6 +337,9 @@ const featureColumns = { lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus, generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId, generatedFromRunId: schema.project.missionFeatures.generatedFromRunId, + researchRunId: schema.project.missionFeatures.researchRunId, + researchFindingId: schema.project.missionFeatures.researchFindingId, + researchSourceUrls: schema.project.missionFeatures.researchSourceUrls, }; const eventColumns = { @@ -490,6 +496,7 @@ function rowToFeature(row: FeatureRow): MissionFeature { lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined, generatedFromFeatureId: row.generatedFromFeatureId ?? undefined, generatedFromRunId: row.generatedFromRunId ?? undefined, + researchProvenance: row.researchRunId && row.researchFindingId ? { researchRunId: row.researchRunId, findingId: row.researchFindingId, sourceUrls: row.researchSourceUrls ?? [] } : undefined, }; } @@ -932,11 +939,24 @@ export async function createFeature(handle: QueryHandle, feature: MissionFeature lastValidatorStatus: feature.lastValidatorStatus ?? null, generatedFromFeatureId: feature.generatedFromFeatureId ?? null, generatedFromRunId: feature.generatedFromRunId ?? null, + researchRunId: feature.researchProvenance?.researchRunId ?? null, + researchFindingId: feature.researchProvenance?.findingId ?? null, + researchSourceUrls: feature.researchProvenance?.sourceUrls ?? null, }); return (await getFeature(handle, feature.id))!; } /** Get a single feature by id. */ +export async function getFeatureByResearchProvenance(handle: QueryHandle, sliceId: string, researchRunId: string, findingId: string): Promise { + const rows = await handle.select(featureColumns).from(schema.project.missionFeatures).where(and( + missionProjectScope(schema.project.missionFeatures.projectId), + eq(schema.project.missionFeatures.sliceId, sliceId), + eq(schema.project.missionFeatures.researchRunId, researchRunId), + eq(schema.project.missionFeatures.researchFindingId, findingId), + )); + return rows[0] ? rowToFeature(rows[0] as FeatureRow) : undefined; +} + export async function getFeature(handle: QueryHandle, id: string): Promise { const rows = await handle .select(featureColumns) @@ -1014,6 +1034,9 @@ export async function updateFeature(handle: QueryHandle, feature: MissionFeature lastValidatorStatus: feature.lastValidatorStatus ?? null, generatedFromFeatureId: feature.generatedFromFeatureId ?? null, generatedFromRunId: feature.generatedFromRunId ?? null, + researchRunId: feature.researchProvenance?.researchRunId ?? null, + researchFindingId: feature.researchProvenance?.findingId ?? null, + researchSourceUrls: feature.researchProvenance?.sourceUrls ?? null, }) .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.id, feature.id))); } @@ -1869,6 +1892,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature lastValidatorStatus: feature.lastValidatorStatus ?? null, generatedFromFeatureId: feature.generatedFromFeatureId ?? null, generatedFromRunId: feature.generatedFromRunId ?? null, + researchRunId: feature.researchProvenance?.researchRunId ?? null, + researchFindingId: feature.researchProvenance?.findingId ?? null, + researchSourceUrls: feature.researchProvenance?.sourceUrls ?? null, }) .onConflictDoUpdate({ target: [schema.project.missionFeatures.projectId, schema.project.missionFeatures.id], diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index aceb3228d3..1b098b4bed 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -22,6 +22,7 @@ import type { MilestoneCreateInput, SliceCreateInput, FeatureCreateInput, + ResearchFeatureCreateInput, MissionWithHierarchy, MissionHealth, MissionEvent, @@ -89,6 +90,7 @@ import { reorderSlices, createFeature, getFeature, + getFeatureByResearchProvenance, listFeaturesByIds, listFeatures, listFeaturesForMilestone, @@ -813,6 +815,7 @@ export class AsyncMissionStore extends EventEmitter { title: input.title, description: input.description, acceptanceCriteria: input.acceptanceCriteria, + researchProvenance: (input as Partial).researchProvenance, status: "defined", createdAt: now, updatedAt: now, @@ -828,6 +831,19 @@ export class AsyncMissionStore extends EventEmitter { return (await getFeature(this.db, feature.id)) ?? feature; } + /** + * FNXC:ResearchMissionBridge 2026-07-18-12:00: + * Research promotion creates canonical features through this facade and + * reuses a project/slice/run/finding match before writing. The composite + * database index is the concurrent retry backstop for this invariant. + */ + async addResearchFeature(sliceId: string, input: ResearchFeatureCreateInput): Promise<{ feature: MissionFeature; reused: boolean }> { + const existing = await getFeatureByResearchProvenance(this.db, sliceId, input.researchProvenance.researchRunId, input.researchProvenance.findingId); + if (existing) return { feature: existing, reused: true }; + const feature = await this.addFeature(sliceId, input); + return { feature, reused: false }; + } + async getFeature(id: string): Promise { return getFeature(this.db, id); } diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 65685fc48b..d12a8cffde 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1877,6 +1877,7 @@ export { RESEARCH_ORCHESTRATION_PHASES, RESEARCH_ORCHESTRATION_STEP_STATUSES, RESEARCH_RUN_FAILURE_CLASSES, + resolveResearchFindingId, } from "./research-types.js"; export type { ResearchRunStatus, @@ -2223,3 +2224,5 @@ export { upsertWorkflowStepResult, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS, } from "./workflow-step-results.js"; +export { promoteResearchFinding } from "./research-feature-promotion.js"; +export type { ResearchFeaturePromotionInput } from "./research-feature-promotion.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 647e8d2761..31cb8124e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1953,6 +1953,7 @@ export { RESEARCH_ORCHESTRATION_PHASES, RESEARCH_ORCHESTRATION_STEP_STATUSES, RESEARCH_RUN_FAILURE_CLASSES, + resolveResearchFindingId, } from "./research-types.js"; export type { ResearchRunStatus, @@ -2507,3 +2508,5 @@ export { localeDisplayName, } from "./detect-content-language.js"; export type { LanguageFamily, DetectedContentLanguage } from "./detect-content-language.js"; +export { promoteResearchFinding } from "./research-feature-promotion.js"; +export type { ResearchFeaturePromotionInput } from "./research-feature-promotion.js"; diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index e9ad8d56c0..1615fc97fc 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -231,6 +231,13 @@ export interface Slice { * A MissionFeature represents a deliverable within a slice. * Features can be linked to fn Tasks for implementation. */ +export interface ResearchFeatureProvenance { + researchRunId: string; + findingId: string; + /** Finding-specific cited source URLs; an empty array explicitly means uncited. */ + sourceUrls: string[]; +} + export interface MissionFeature { /** Unique identifier (e.g., "F-J6K9AB-G7H3") */ id: string; @@ -246,6 +253,8 @@ export interface MissionFeature { acceptanceCriteria?: string; /** Current lifecycle status */ status: FeatureStatus; + /** Durable lineage when this canonical feature came from Fusion Research. */ + researchProvenance?: ResearchFeatureProvenance; /** ISO-8601 timestamp of creation */ createdAt: string; /** ISO-8601 timestamp of last update */ @@ -425,6 +434,10 @@ export interface SliceCreateInput { } /** Input for creating a new Feature */ +export interface ResearchFeatureCreateInput extends FeatureCreateInput { + researchProvenance: ResearchFeatureProvenance; +} + export interface FeatureCreateInput { /** Display name of the feature (required) */ title: string; diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index da174caf7b..dbba5a32a1 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -978,6 +978,9 @@ CREATE TABLE IF NOT EXISTS project.mission_features ( last_validator_status text, generated_from_feature_id text, generated_from_run_id text, + research_run_id text, + research_finding_id text, + research_source_urls jsonb, CONSTRAINT mission_features_slice_id_fkey FOREIGN KEY (slice_id) REFERENCES project.slices(id) ON DELETE CASCADE, CONSTRAINT mission_features_task_id_fkey @@ -1969,3 +1972,4 @@ CREATE INDEX IF NOT EXISTS "idxArchivedTasksCreatedAt" -- GIN index on the archive search_vector (VAL-SEARCH-005). CREATE INDEX IF NOT EXISTS "idxArchivedTasksSearchVector" ON archive.archived_tasks USING gin(search_vector); + diff --git a/packages/core/src/postgres/migrations/0023_research_feature_provenance.sql b/packages/core/src/postgres/migrations/0023_research_feature_provenance.sql new file mode 100644 index 0000000000..d05d387536 --- /dev/null +++ b/packages/core/src/postgres/migrations/0023_research_feature_provenance.sql @@ -0,0 +1,8 @@ +-- FNXC:ResearchMissionBridge 2026-07-18-12:00: +-- Persist finding-level lineage and enforce retry-safe promotion per project/slice. +ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS research_run_id text; +ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS research_finding_id text; +ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS research_source_urls jsonb; +CREATE UNIQUE INDEX IF NOT EXISTS mission_features_research_promotion_unique + ON project.mission_features (project_id, slice_id, research_run_id, research_finding_id) + WHERE research_run_id IS NOT NULL AND research_finding_id IS NOT NULL; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 54d6f8210c..98be8d6f38 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -32,7 +32,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin FNXC:GitHubImportTranslate 2026-07-17-23:48: Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves. */ -export const SCHEMA_BASELINE_VERSION = "0022"; +export const SCHEMA_BASELINE_VERSION = "0023"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -109,6 +109,8 @@ export const TASK_PROPOSAL_CLAIM_VERSION = "0020"; export const CONFIGURATION_REVISIONS_VERSION = "0021"; /** FNXC:Ideation 2026-07-30-15:30: Persisted ideation needs its own forward migration because configuration revisions already own 0021. */ export const IDEATION_SCHEMA_VERSION = "0022"; +/** FNXC:ResearchMissionBridge 2026-07-18-12:00: forward migration stores stable research finding provenance on canonical features. */ +export const RESEARCH_FEATURE_PROVENANCE_VERSION = "0023"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; @@ -218,6 +220,7 @@ const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bul const TASK_PROPOSAL_CLAIM_MIGRATION_PATH = join(MIGRATIONS_DIR, "0020_task_proposal_claim.sql"); const CONFIGURATION_REVISIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0021_configuration_revisions.sql"); const IDEATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0022_ideation.sql"); +const RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0023_research_feature_provenance.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -309,6 +312,7 @@ export async function applySchemaBaseline( const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION); const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION); const ideationAlreadyApplied = applied.includes(IDEATION_SCHEMA_VERSION); + const researchFeatureProvenanceAlreadyApplied = applied.includes(RESEARCH_FEATURE_PROVENANCE_VERSION); let schemaChanged = false; if (!baselineAlreadyApplied) { @@ -663,6 +667,13 @@ export async function applySchemaBaseline( schemaChanged = true; } + if (!researchFeatureProvenanceAlreadyApplied) { + const migrationSql = await readFile(RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${RESEARCH_FEATURE_PROVENANCE_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + if (!importTranslationCacheScopeFixAlreadyApplied) { const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8"); await tx.execute(sql.raw(migrationSql)); diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 8ca5fda3c6..62ffda9013 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1398,10 +1398,15 @@ export const missionFeatures = projectSchema.table("mission_features", { lastValidatorStatus: text("last_validator_status"), generatedFromFeatureId: text("generated_from_feature_id"), generatedFromRunId: text("generated_from_run_id"), + // FNXC:ResearchMissionBridge 2026-07-18-12:00: Store run/finding/source lineage as columns so duplicate promotion is project+slice scoped and citations remain queryable. + researchRunId: text("research_run_id"), + researchFindingId: text("research_finding_id"), + researchSourceUrls: jsonb("research_source_urls"), }, (t) => [ primaryKey({ columns: [t.projectId, t.id] }), foreignKey({ columns: [t.projectId, t.sliceId], foreignColumns: [slices.projectId, slices.id] }).onDelete("cascade"), foreignKey({ columns: [t.projectId, t.taskId], foreignColumns: [tasks.projectId, tasks.id] }).onDelete("set null"), + uniqueIndex("mission_features_research_promotion_unique").on(t.projectId, t.sliceId, t.researchRunId, t.researchFindingId), ]); /* diff --git a/packages/core/src/research-feature-promotion.ts b/packages/core/src/research-feature-promotion.ts new file mode 100644 index 0000000000..eb146e647d --- /dev/null +++ b/packages/core/src/research-feature-promotion.ts @@ -0,0 +1,38 @@ +import type { AsyncResearchStore } from "./async-research-store.js"; +import type { AsyncMissionStore } from "./async-mission-store.js"; +import { resolveResearchFindingId } from "./research-types.js"; + +export type ResearchFeaturePromotionInput = { + runId: string; + findingId: string; + sliceId: string; + title?: string; + description?: string; + acceptanceCriteria?: string; +}; + +/** + * FNXC:ResearchMissionBridge 2026-07-18-12:00: + * Engine tools and dashboard routes share this completed-run gate so a route + * cannot create roadmap work for a nonterminal or position-shifted finding. + */ +export async function promoteResearchFinding( + researchStore: Pick, + missionStore: Pick, + input: ResearchFeaturePromotionInput, +) { + const run = await researchStore.getRun(input.runId); + if (!run) throw new Error(`Research run ${input.runId} not found`); + if (run.status !== "completed") throw new Error(`Research run ${input.runId} is not completed`); + const finding = (run.results?.findings ?? []).find((candidate) => resolveResearchFindingId(candidate) === input.findingId); + if (!finding) throw new Error(`Finding ${input.findingId} not found`); + const findingId = resolveResearchFindingId(finding); + const sourceUrls = [...new Set((finding.sources ?? []).map((url) => url.trim()).filter(Boolean))]; + const promoted = await missionStore.addResearchFeature(input.sliceId, { + title: input.title?.trim() || finding.heading?.trim() || "Research finding", + description: input.description?.trim() || finding.content?.trim() || undefined, + acceptanceCriteria: input.acceptanceCriteria?.trim() || undefined, + researchProvenance: { researchRunId: run.id, findingId, sourceUrls }, + }); + return { ...promoted, runId: run.id, findingId, citations: sourceUrls }; +} diff --git a/packages/core/src/research-types.ts b/packages/core/src/research-types.ts index 34f0515ab8..989578eeed 100644 --- a/packages/core/src/research-types.ts +++ b/packages/core/src/research-types.ts @@ -122,7 +122,28 @@ export interface ResearchEvent { metadata?: Record; } +/** + * FNXC:ResearchMissionBridge 2026-07-18-12:00: + * A finding identity must be stable when providers reorder synthesis output. It + * is intentionally derived from finding-owned content and cited URLs, never an + * array position, so every promotion surface can reuse the same roadmap work. + */ +export function resolveResearchFindingId(finding: Pick & { id?: unknown }): string { + const explicitId = typeof finding.id === "string" ? finding.id.trim() : ""; + if (explicitId) return explicitId; + const normalize = (value: string) => value.trim().replace(/\s+/g, " ").toLowerCase(); + const canonical = [normalize(finding.heading ?? ""), normalize(finding.content ?? ""), ...[...(finding.sources ?? [])].map(normalize).filter(Boolean).sort()].join("\n"); + let hash = 0x811c9dc5; + for (let index = 0; index < canonical.length; index += 1) { + hash ^= canonical.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `finding-${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + export interface ResearchFinding { + /** Optional provider-persisted identity; legacy findings use resolveResearchFindingId. */ + id?: string; heading: string; content: string; sources: string[]; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bbf0f568c3..72fed288e6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7082,6 +7082,7 @@ export { } from "./model-resolution.js"; export type { ResolvedModelSelection } from "./model-resolution.js"; export { resolveResearchSettings } from "./research-settings.js"; +export { resolveResearchFindingId } from "./research-types.js"; export type { ResolvedResearchSettings } from "./research-settings.js"; /* diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 6e4d59f791..bb8c49a6b0 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10271,3 +10271,24 @@ export function fetchSystemLogs(limit?: number): Promise<{ entries: SystemLogEnt const suffix = limit ? `?limit=${limit}` : ""; return api<{ entries: SystemLogEntryDto[] }>(`/system/logs${suffix}`); } + +export type ResearchFindingPromotionInput = { + findingId: string; + sliceId: string; + title?: string; + description?: string; + acceptanceCriteria?: string; + triage?: boolean; + taskId?: string; +}; + +export function promoteResearchFinding( + runId: string, + input: ResearchFindingPromotionInput, + projectId?: string, +): Promise<{ runId: string; findingId: string; feature: { id: string; status: string; taskId?: string }; citations: string[]; reused: boolean }> { + return api(withProjectId(`/research/runs/${encodeURIComponent(runId)}/findings/${encodeURIComponent(input.findingId)}/promote`, projectId), { + method: "POST", + body: JSON.stringify(input), + }); +} diff --git a/packages/dashboard/app/components/ResearchView.tsx b/packages/dashboard/app/components/ResearchView.tsx index 5912307d8e..1f05b5408b 100644 --- a/packages/dashboard/app/components/ResearchView.tsx +++ b/packages/dashboard/app/components/ResearchView.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { resolveResearchSettings, type Settings } from "@fusion/core"; +import { resolveResearchFindingId, resolveResearchSettings, type Settings } from "@fusion/core"; import { Loader2, Search } from "lucide-react"; import { fetchAuthStatus, fetchSettings } from "../api"; import { useResearch } from "../hooks/useResearch"; @@ -81,6 +81,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer exportRun, createTaskFromRun, attachRunToTask, + promoteFinding, statusCounts, refresh, uiError, @@ -93,6 +94,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer const [selectedProviders, setSelectedProviders] = useState([]); const [actionLoading, setActionLoading] = useState(null); const [modalState, setModalState] = useState(null); + const [promotionSliceId, setPromotionSliceId] = useState(""); const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS; const selectedSearchProvider = effectiveSettings.searchProvider; @@ -418,9 +420,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer )} {Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
- {selectedRun.results.findings.map((finding, index) => { - const findingRecord = finding as { id?: string }; - const findingId = findingRecord.id?.trim() || `finding-${index + 1}`; + {selectedRun.results.findings.map((finding) => { + const findingId = resolveResearchFindingId(finding); return (

{finding.heading}

@@ -440,6 +441,10 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer > {t("research.enrichTask", "Enrich Task")} + setPromotionSliceId(event.target.value)} placeholder={t("research.destinationSlice", "Destination slice ID")} /> +
); @@ -478,10 +483,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer )} {selectedRun && modalState && (() => { - const findingIndex = selectedRun.results?.findings?.findIndex((entry, idx) => { - const findingRecord = entry as { id?: string }; - const id = findingRecord.id?.trim() || `finding-${idx + 1}`; - return id === modalState.findingId; + const findingIndex = selectedRun.results?.findings?.findIndex((entry) => { + return resolveResearchFindingId(entry) === modalState.findingId; }) ?? -1; const finding = findingIndex >= 0 ? selectedRun.results!.findings[findingIndex] : null; if (!finding) return null; diff --git a/packages/dashboard/app/hooks/useResearch.ts b/packages/dashboard/app/hooks/useResearch.ts index e966071aa3..f37c59e981 100644 --- a/packages/dashboard/app/hooks/useResearch.ts +++ b/packages/dashboard/app/hooks/useResearch.ts @@ -6,6 +6,7 @@ import { cancelResearchRun, createResearchRun, createTaskFromResearchRun, + promoteResearchFinding, exportResearchRun, getResearchRun, listResearchRuns, @@ -307,6 +308,8 @@ export function useResearch(options?: { projectId?: string }) { ) => createTaskFromResearchRun(runId, { title, findingId, description, priority, attachExport }, projectId), attachRunToTask: (runId: string, taskId: string, findingId?: string, attachExport?: boolean) => attachResearchRunToTask(runId, { taskId, findingId, attachExport }, projectId), + promoteFinding: (runId: string, input: { findingId: string; sliceId: string; title?: string; description?: string; acceptanceCriteria?: string; triage?: boolean; taskId?: string }) => + promoteResearchFinding(runId, input, projectId), uiError, runActionState: getRunActionState(selectedRun), statusCounts: runs.reduce>( diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index ac8975dd60..911862e7ea 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -463,8 +463,8 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions) createTaskShowTool(taskStore), createTaskSearchTool(taskStore), createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }), - /* FNXC:MissionToolParity 2026-07-29-15:30: Dashboard chat uses the engine factory, but only bound permanent-agent sessions with both policy contexts receive hierarchy mutations. */ - ...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)), + /* FNXC:ResearchMissionBridge 2026-07-18-12:00: Promotion is a mission mutation because it creates canonical roadmap work; dashboard chat exposes it only through the same permanent-agent action gate as all hierarchy writes. */ + ...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)), /* FNXC:Ideation 2026-07-30-15:30: Unbound or ephemeral chat exposes only positive ideation reads; mutations require the same durable gate context as Mission writes. */ ...createIdeationTools(taskStore).filter((tool) => missionMutationGated || CHAT_IDEATION_READ_TOOL_NAMES.has(tool.name)), ...createGoalRetrievalTools(taskStore), diff --git a/packages/dashboard/src/research-routes.ts b/packages/dashboard/src/research-routes.ts index f18eb76dd0..8e0201e74e 100644 --- a/packages/dashboard/src/research-routes.ts +++ b/packages/dashboard/src/research-routes.ts @@ -9,6 +9,8 @@ import { RESEARCH_EVENT_TYPES, ResearchLifecycleError, buildResearchDocumentKey, + resolveResearchFindingId, + promoteResearchFinding, type ResearchRunListOptions, type ResearchRunStatus, } from "@fusion/core"; @@ -57,16 +59,14 @@ function toRunDetail(run: ResearchRun) { }; } -function getFindingId(finding: NonNullable["findings"][number], index: number): string { - const maybeFinding = finding as { id?: unknown }; - const explicitId = typeof maybeFinding.id === "string" ? maybeFinding.id.trim() : ""; - return explicitId || `finding-${index + 1}`; +function getFindingId(finding: NonNullable["findings"][number]): string { + return resolveResearchFindingId(finding); } function getFindingById(run: ResearchRun, findingId: string) { const findings = run.results?.findings ?? []; - for (const [index, finding] of findings.entries()) { - if (getFindingId(finding, index) === findingId) { + for (const finding of findings) { + if (getFindingId(finding) === findingId) { return { finding, findingId }; } } @@ -382,6 +382,31 @@ export function createResearchRouter(store: TaskStore, options?: ServerOptions): } }); + router.post("/runs/:runId/findings/:findingId/promote", async (req, res) => { + try { + const scopedStore = requestContext.getStore(); + if (!scopedStore) throw new ApiError(500, "Task store context not available"); + const sliceId = typeof req.body?.sliceId === "string" ? req.body.sliceId.trim() : ""; + if (!sliceId) throw badRequest("sliceId is required"); + const missionStore = scopedStore.getMissionStore(); + if (!("addResearchFeature" in missionStore)) throw new ApiError(409, "Research promotion requires the PostgreSQL mission store"); + const promoted = await promoteResearchFinding(getStore() as never, missionStore, { + runId: req.params.runId, + findingId: req.params.findingId, + sliceId, + title: typeof req.body?.title === "string" ? req.body.title : undefined, + description: typeof req.body?.description === "string" ? req.body.description : undefined, + acceptanceCriteria: typeof req.body?.acceptanceCriteria === "string" ? req.body.acceptanceCriteria : undefined, + }); + let feature = promoted.feature; + if (typeof req.body?.taskId === "string" && req.body.taskId.trim()) feature = await missionStore.linkFeatureToTask(feature.id, req.body.taskId.trim()); + if (req.body?.triage === true) feature = await missionStore.triageFeature(feature.id); + res.status(promoted.reused ? 200 : 201).json({ runId: promoted.runId, findingId: promoted.findingId, feature, sliceId, citations: promoted.citations, reused: promoted.reused, taskId: feature.taskId ?? null, status: feature.status }); + } catch (error) { + rethrowAsApiError(error, "Failed to promote research finding"); + } + }); + router.post("/runs/:runId/findings/:findingId/tasks/:taskId/enrich", async (req, res) => { try { const scopedStore = requestContext.getStore(); diff --git a/packages/engine/src/__tests__/agent-mission-tools.test.ts b/packages/engine/src/__tests__/agent-mission-tools.test.ts index 09f6aefc71..0d950f69b3 100644 --- a/packages/engine/src/__tests__/agent-mission-tools.test.ts +++ b/packages/engine/src/__tests__/agent-mission-tools.test.ts @@ -12,7 +12,7 @@ describe("createMissionTools", () => { expect(createMissionTools(store).map((tool) => tool.name)).toEqual([ "fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_delete", "fn_milestone_add", "fn_milestone_update", "fn_milestone_delete", "fn_slice_add", "fn_slice_activate", - "fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_delete", "fn_feature_link_task", + "fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_delete", "fn_feature_link_task", "fn_research_promote_finding", ]); }); @@ -25,6 +25,18 @@ describe("createMissionTools", () => { expect(result.details).toMatchObject({ feature: { taskId: "FN-1", status: "triaged" } }); }); + it("promotes completed findings through the idempotent mission-store facade", async () => { + const addResearchFeature = vi.fn().mockResolvedValue({ reused: false, feature: { id: "F-1", status: "defined" } }); + const store = { + getResearchStore: () => ({ getRun: vi.fn().mockResolvedValue({ id: "R-1", status: "completed", results: { findings: [{ heading: "Finding", content: "Evidence", sources: ["https://source.example"] }] } }) }), + getMissionStore: () => ({ addResearchFeature }), + } as never; + const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_research_promote_finding")!; + const result = await tool.execute("call", { runId: "R-1", findingId: "finding-b481c893", sliceId: "SL-1" }); + expect(addResearchFeature).toHaveBeenCalledWith("SL-1", expect.objectContaining({ researchProvenance: expect.objectContaining({ researchRunId: "R-1" }) })); + expect(result.details).toMatchObject({ feature: { id: "F-1" }, reused: false }); + }); + it("returns a structured error for missing hierarchy records", async () => { const store = { getMissionStore: () => ({ getMissionWithHierarchy: vi.fn().mockResolvedValue(undefined) }) } as never; const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!; diff --git a/packages/engine/src/__tests__/mission-feature-sync.test.ts b/packages/engine/src/__tests__/mission-feature-sync.test.ts new file mode 100644 index 0000000000..ac0e9b2dcf --- /dev/null +++ b/packages/engine/src/__tests__/mission-feature-sync.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { reconcileMissionFeatureState } from "../mission-feature-sync.js"; + +describe("reconcileMissionFeatureState", () => { + it("keeps assertion validation as the completion gate for research-derived features", async () => { + const decision = await reconcileMissionFeatureState( + { getTask: async () => undefined } as never, + { id: "FN-1", column: "done", status: "completed" } as never, + { id: "F-1", status: "in-progress", lastValidatorStatus: "failed" } as never, + { hasLinkedAssertions: true }, + ); + expect(decision).toEqual(expect.objectContaining({ kind: "noop" })); + }); + + it("moves a linked feature through canonical triage and in-progress states", async () => { + const taskStore = { getTask: async () => undefined } as never; + await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "todo", status: "pending" } as never, { id: "F-1", status: "in-progress" } as never)).resolves.toMatchObject({ kind: "update", status: "triaged" }); + await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "in-review", status: "in-progress" } as never, { id: "F-1", status: "triaged" } as never)).resolves.toMatchObject({ kind: "update", status: "in-progress" }); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 45eb514e1e..8130f4a5de 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -3511,6 +3511,16 @@ export const featureAddParams = Type.Object({ sliceId: Type.String(), title: Typ export const featureUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) }); export const featureDeleteParams = Type.Object({ featureId: Type.String(), force: Type.Optional(Type.Boolean()) }); export const featureLinkTaskParams = Type.Object({ featureId: Type.String(), taskId: Type.String() }); +export const researchFindingPromoteParams = Type.Object({ + runId: Type.String(), + findingId: Type.String(), + sliceId: Type.String(), + title: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + acceptanceCriteria: Type.Optional(Type.String()), + triage: Type.Optional(Type.Boolean()), + taskId: Type.Optional(Type.String()), +}); const missionToolResult = (text: string, details: Record, isError = false) => ({ content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}), @@ -3545,6 +3555,22 @@ export function createMissionTools(store: TaskStore): ToolDefinition[] { tool("fn_feature_update", "Update Feature", "Partially update a feature.", featureUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description", "acceptanceCriteria"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const feature = await store.getMissionStore().updateFeature(p.id, updates); return missionToolResult(`Updated ${feature.id}`, { feature }); }), tool("fn_feature_delete", "Delete Feature", "Delete a feature, respecting linked-task guards.", featureDeleteParams, async (p) => { await store.getMissionStore().deleteFeature(p.featureId, p.force ===true); return missionToolResult(`Deleted ${p.featureId}`, { featureId: p.featureId }); }), tool("fn_feature_link_task", "Link Feature to Task", "Link a feature to a live project-scoped task.", featureLinkTaskParams, async (p) => { const feature = await store.getMissionStore().linkFeatureToTask(p.featureId, p.taskId); return missionToolResult(`Linked ${feature.id} to ${p.taskId}`, { feature }); }), + tool("fn_research_promote_finding", "Promote Research Finding", "Promote a completed research finding into a canonical mission feature.", researchFindingPromoteParams, async (p) => { + const missionStore = store.getMissionStore(); + if (!("addResearchFeature" in missionStore)) return missionToolResult("Research promotion requires the PostgreSQL mission store", { code: "POSTGRES_REQUIRED" }, true); + let promoted: Awaited>; + try { + promoted = await fusionCore.promoteResearchFinding(store.getResearchStore() as never, missionStore, p); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return missionToolResult(message, { code: message.includes("not completed") ? "RUN_NOT_COMPLETED" : message.includes("not found") ? "FINDING_OR_RUN_NOT_FOUND" : "PROMOTION_FAILED", runId: p.runId, findingId: p.findingId }, true); + } + /* FNXC:ResearchMissionBridge 2026-07-18-12:00: All agent promotion flows use the shared completed-run gate and AsyncMissionStore facade; never create a substitute task. */ + let feature = promoted.feature; + if (p.taskId) feature = await store.getMissionStore().linkFeatureToTask(feature.id, p.taskId); + if (p.triage) feature = await store.getMissionStore().triageFeature(feature.id); + return missionToolResult(`${promoted.reused ? "Reused" : "Promoted"} ${promoted.findingId} as ${feature.id}`, { runId: promoted.runId, findingId: promoted.findingId, feature, sliceId: p.sliceId, citations: promoted.citations, reused: promoted.reused, taskId: feature.taskId ?? null, status: feature.status }); + }), ]; } diff --git a/packages/engine/src/mission-feature-sync.ts b/packages/engine/src/mission-feature-sync.ts index e961f91beb..a9fd8f8d35 100644 --- a/packages/engine/src/mission-feature-sync.ts +++ b/packages/engine/src/mission-feature-sync.ts @@ -26,6 +26,7 @@ export async function reconcileMissionFeatureState( }; } + /* FNXC:ResearchMissionBridge 2026-07-18-12:00: Research-derived features use this same reconciliation decision, so task completion never bypasses assertion validation or parent-roadmap rollups. */ const hasUnvalidatedAssertions = context.hasLinkedAssertions === true && feature.lastValidatorStatus !== "passed";