From 2cdd843e63fba397e373d80cafaf6e22bfce0f7f Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Tue, 28 Jul 2026 11:28:01 +0800 Subject: [PATCH 01/32] feat(session): link sessions to projects and harden side queries (cherry picked from commit 8121d4c92d87f5cf51b11e6abd373d23f538c8ae) --- .../src/core/session/persistence/crud/mod.rs | 4 +- .../src/core/session/persistence/crud/ops.rs | 33 +++ .../session/persistence/crud/ops_tests.rs | 32 ++- .../src/core/session/persistence/mod.rs | 4 +- .../src/state/commands/session/persistence.rs | 60 ++++++ src-tauri/src/commands/handler_list.inc | 1 + src/api/tauri/agent/session.ts | 7 + src/api/tauri/rpc/procedures/agentSession.ts | 4 + src/api/tauri/rpc/schemas/agentSession.ts | 5 + src/engines/ChatPanel/ChatPanelHeader.tsx | 3 + .../components/SessionHeaderActionsMenu.tsx | 16 ++ .../hooks/useSessionActionModals.tsx | 18 ++ src/engines/ChatPanel/index.tsx | 2 + .../panels/LinkSessionToProjectModal.tsx | 202 ++++++++++++++++++ .../TabContent/renderers/chatSession.tsx | 1 + 15 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 src/engines/ChatPanel/panels/LinkSessionToProjectModal.tsx diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs index 9019cbfda6..dfb14fdb5e 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs @@ -29,8 +29,8 @@ pub use ops::{ register_session_delete_mirror_hook, register_session_mirror_hook, update_account_id, update_agent_exec_mode, update_draft_text, update_mode_axes, update_model, update_model_and_account, update_name, update_org_member_id, update_pinned, - update_product_mode, update_reply_target_event_id, update_status, update_work_item_link, - upsert_session, + update_product_mode, update_project_link, update_reply_target_event_id, update_status, + update_work_item_link, upsert_session, }; pub(crate) use ops::{ delete_session_with_connection, finish_session_delete, prepare_session_delete, diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs index 26f2754f39..6bcd1849e2 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs @@ -504,6 +504,39 @@ pub fn link_bootstrap_work_item(session_id: &str, work_item_id: &str) -> SqliteR }) } +/// Associate a session with a Project without manufacturing a Work Item link. +/// +/// Project association is canonical session metadata, so it also switches the +/// product mode and clears any prior Work Item id. Conversation activity time +/// is intentionally left unchanged. +pub fn update_project_link( + session_id: &str, + org_id: &str, + project_id: &str, + project_name: &str, + project_slug: &str, +) -> SqliteResult { + let changed = with_sessions_writer(|| -> SqliteResult { + let conn = get_connection()?; + let updated = conn.execute( + "UPDATE agent_sessions + SET org_id = ?2, + project_id = ?3, + project_name = ?4, + project_slug = ?5, + work_item_id = NULL, + product_mode = 'project' + WHERE session_id = ?1", + params![session_id, org_id, project_id, project_name, project_slug], + )?; + Ok(updated > 0) + })?; + if changed { + notify_session_mirror(session_id); + } + Ok(changed) +} + /// Set the canonical Agent Org roster member id for a session. pub fn update_org_member_id(session_id: &str, org_member_id: &str) -> SqliteResult { let changed = with_sessions_writer(|| -> SqliteResult { diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops_tests.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops_tests.rs index 8da6c238ba..335f09a078 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops_tests.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops_tests.rs @@ -21,7 +21,7 @@ use super::ops::{ delete_session, finalize_terminal_turn_status, list_sessions, - reconcile_sessions_with_terminal_turn_markers, upsert_session, + reconcile_sessions_with_terminal_turn_markers, update_project_link, upsert_session, }; use super::record::UnifiedSessionRecord; use crate::session::persistence; @@ -211,6 +211,36 @@ fn terminal_turn_finalize_updates_session_status_and_marker() { assert_eq!(row.updated_at, "2026-06-05T12:00:00.000Z"); } +#[test] +fn project_link_persists_project_metadata_and_clears_work_item() { + let _sandbox = test_env::sandbox(); + seed_session("sid-project", SessionStatus::Idle); + + let mut record = super::ops::get_session("sid-project") + .expect("get seed") + .expect("seed exists"); + record.work_item_id = Some("OLD-1".to_string()); + upsert_session(&record).expect("seed work item link"); + + assert!(update_project_link( + "sid-project", + "org-1", + "proj-1", + "Project One", + "project-one", + ) + .expect("link project")); + let linked = super::ops::get_session("sid-project") + .expect("get linked") + .expect("linked exists"); + assert_eq!(linked.org_id.as_deref(), Some("org-1")); + assert_eq!(linked.project_id.as_deref(), Some("proj-1")); + assert_eq!(linked.project_name.as_deref(), Some("Project One")); + assert_eq!(linked.project_slug.as_deref(), Some("project-one")); + assert_eq!(linked.product_mode.as_deref(), Some("project")); + assert_eq!(linked.work_item_id, None); +} + #[test] fn reconcile_repairs_all_in_flight_rows_with_terminal_turn_markers() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index ffaf217141..64ceb9e1c6 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -31,8 +31,8 @@ pub use crud::{ register_session_mirror_hook, save_workspace, save_worktree_metadata, session_type, update_account_id, update_agent_exec_mode, update_draft_text, update_mode_axes, update_model, update_model_and_account, update_name, update_org_member_id, update_pinned, - update_product_mode, update_reply_target_event_id, update_status, update_work_item_link, - update_worktree_merge_status, upsert_session, UnifiedSessionRecord, + update_product_mode, update_project_link, update_reply_target_event_id, update_status, + update_work_item_link, update_worktree_merge_status, upsert_session, UnifiedSessionRecord, }; pub(crate) use crud::{ delete_session_with_connection, finish_session_delete, prepare_session_delete, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs index 54ac3ac472..dab209ddb0 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs @@ -861,6 +861,66 @@ pub async fn agent_link_session_to_work_item( shared::to_json_value(updated_record).map_err(|err| err.to_string()) } +/// Associate an existing session with a Project without requiring a Work Item. +#[tauri::command] +pub async fn agent_link_session_to_project( + app: tauri::AppHandle, + session_id: String, + project_slug: String, +) -> Result { + let updated_record = tokio::task::spawn_blocking(move || { + let session = session_persistence::get_session(&session_id) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("Session not found: {session_id}"))?; + let project = project_management::projects::io::read_project(&project_slug) + .map_err(|err| format!("Failed to read project {project_slug}: {err}"))?; + + if let (Some(old_project_slug), Some(old_work_item_id)) = ( + session.project_slug.as_deref(), + session.work_item_id.as_deref(), + ) { + remove_linked_session_from_work_item( + old_project_slug, + old_work_item_id, + &session.session_id, + )?; + } + + session_persistence::update_project_link( + &session.session_id, + &project.meta.org_id, + &project.meta.id, + &project.meta.name, + &project_slug, + ) + .map_err(|err| err.to_string())? + .then_some(()) + .ok_or_else(|| format!("Session not found: {}", session.session_id))?; + + session_persistence::get_session(&session.session_id) + .map_err(|err| err.to_string())? + .ok_or_else(|| { + format!( + "Session not found after project link: {}", + session.session_id + ) + }) + }) + .await + .map_err(|err| err.to_string())??; + + { + use tauri::Emitter; + let ts = chrono::Utc::now().to_rfc3339(); + let _ = app.emit( + project_management::projects::events::DATA_CHANGED_EVENT, + &ts, + ); + } + + shared::to_json_value(updated_record).map_err(|err| err.to_string()) +} + /// `Track this` (orgtrack/v1 §7.2, Build→Project) and /// `Convert to Project` (Plan→Project): switch the session onto the /// Project product mode, derive the runtime exec mode the same way the diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 95f0ae41c7..28844ab693 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -1253,6 +1253,7 @@ agent_core::state::commands::agent_check_snapshot_changes, agent_core::state::commands::agent_update_session_status, agent_core::state::commands::agent_save_session, agent_core::state::commands::agent_link_session_to_work_item, +agent_core::state::commands::agent_link_session_to_project, agent_core::state::commands::agent_track_session_as_project, agent_core::state::commands::agent_get_session_workspace_path, // Unified Agent Interaction commands diff --git a/src/api/tauri/agent/session.ts b/src/api/tauri/agent/session.ts index 5cc9606b39..3741c55192 100644 --- a/src/api/tauri/agent/session.ts +++ b/src/api/tauri/agent/session.ts @@ -187,6 +187,13 @@ export async function linkSessionToWorkItem(input: { return rpc.agentSession.linkSessionToWorkItem(input); } +export async function linkSessionToProject(input: { + sessionId: string; + projectSlug: string; +}): Promise { + return rpc.agentSession.linkSessionToProject(input); +} + /** Track this / Convert to Project (orgtrack/v1 §7.2): switch the * session to the Project product mode, invalidate any pending Plan * snapshot, and create-or-replay the root WorkItem from the recorded diff --git a/src/api/tauri/rpc/procedures/agentSession.ts b/src/api/tauri/rpc/procedures/agentSession.ts index 84b496a21c..6bca527d3a 100644 --- a/src/api/tauri/rpc/procedures/agentSession.ts +++ b/src/api/tauri/rpc/procedures/agentSession.ts @@ -81,6 +81,10 @@ export const agentSession = { .input(schemas.agentSession.LinkSessionToWorkItemInput) .output(schemas.agentSession.SessionMetaSchema) .build(), + linkSessionToProject: defineProcedure("agent_link_session_to_project") + .input(schemas.agentSession.LinkSessionToProjectInput) + .output(schemas.agentSession.SessionMetaSchema) + .build(), trackSessionAsProject: defineProcedure("agent_track_session_as_project") .input(schemas.agentSession.SessionIdInput) .output(schemas.agentSession.TrackSessionAsProjectResult) diff --git a/src/api/tauri/rpc/schemas/agentSession.ts b/src/api/tauri/rpc/schemas/agentSession.ts index f80149e382..236fd6f135 100644 --- a/src/api/tauri/rpc/schemas/agentSession.ts +++ b/src/api/tauri/rpc/schemas/agentSession.ts @@ -153,6 +153,11 @@ export const SessionMetaSchema = z }) .catchall(z.unknown()) as unknown as z.ZodType; +export const LinkSessionToProjectInput = z.object({ + sessionId: z.string(), + projectSlug: z.string(), +}); + export const CancelReasonSchema = z.enum([ "user_stop", "force_send", diff --git a/src/engines/ChatPanel/ChatPanelHeader.tsx b/src/engines/ChatPanel/ChatPanelHeader.tsx index db411c3eef..23bdbf637c 100644 --- a/src/engines/ChatPanel/ChatPanelHeader.tsx +++ b/src/engines/ChatPanel/ChatPanelHeader.tsx @@ -47,6 +47,7 @@ interface ChatPanelHeaderProps { handleCopyEventJson: () => void; handleMoveToWorkstation: () => void; handleOpenExportSessionJson: () => void; + handleOpenLinkProject: () => void; handleOpenLinkWorkItem: () => void; handleOpenCloudShareSettings: () => void; handleOpenRawTranscript: () => void; @@ -101,6 +102,7 @@ export function ChatPanelHeader({ handleCopyEventJson, handleMoveToWorkstation, handleOpenExportSessionJson, + handleOpenLinkProject, handleOpenLinkWorkItem, handleOpenCloudShareSettings, handleOpenRawTranscript, @@ -215,6 +217,7 @@ export function ChatPanelHeader({ handleMoveSession={handleMoveToWorkstation} handleOpenCloudShareSettings={handleOpenCloudShareSettings} handleOpenExportSessionJson={handleOpenExportSessionJson} + handleOpenLinkProject={handleOpenLinkProject} handleOpenLinkWorkItem={handleOpenLinkWorkItem} handleOpenRawTranscript={handleOpenRawTranscript} handleOpenSearch={handleOpenSearch} diff --git a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx index 43d13810b6..529e7e82cf 100644 --- a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx +++ b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx @@ -45,6 +45,7 @@ export interface SessionHeaderActionsMenuProps { handleMoveSession: () => void; handleOpenCloudShareSettings: () => void; handleOpenExportSessionJson: () => void; + handleOpenLinkProject: () => void; handleOpenLinkWorkItem: () => void; handleOpenRawTranscript: () => void; handleOpenSearch: () => void; @@ -81,6 +82,7 @@ export const SessionHeaderActionsMenu: React.FC< handleMoveSession, handleOpenCloudShareSettings, handleOpenExportSessionJson, + handleOpenLinkProject, handleOpenLinkWorkItem, handleOpenRawTranscript, handleOpenSearch, @@ -259,6 +261,20 @@ export const SessionHeaderActionsMenu: React.FC< {t("sessions:chat.trackAsProject.menuItem")} + + ))} + {matches.length === 0 ? ( +

No matching projects.

+ ) : null} + +
+

Create Project

+
+ + +
+
+ + + , + document.body + ); +} diff --git a/src/modules/WorkStation/TabContent/renderers/chatSession.tsx b/src/modules/WorkStation/TabContent/renderers/chatSession.tsx index b5f267fa26..15c1f498da 100644 --- a/src/modules/WorkStation/TabContent/renderers/chatSession.tsx +++ b/src/modules/WorkStation/TabContent/renderers/chatSession.tsx @@ -134,6 +134,7 @@ const ChatSessionTabRenderer: React.FC = memo( handleOpenExportSessionJson={ sessionActions.handleOpenExportSessionJson } + handleOpenLinkProject={sessionActions.handleOpenLinkProject} handleOpenLinkWorkItem={sessionActions.handleOpenLinkWorkItem} handleOpenRawTranscript={sessionView.showRaw} handleOpenSearch={headerActions.handleOpenSearch} From 81b3902987eb08dddbdb1fa06e76aa75012ef441 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Tue, 28 Jul 2026 11:29:45 +0800 Subject: [PATCH 02/32] fix(session): handle project search input and add sidebar link (cherry picked from commit 240867cec1e195d789f340efca01b710e8a6c138) --- .../WorkstationSidebarConnector/SidebarDialogs.tsx | 13 +++++++++++++ .../WorkstationSidebarConnector/index.tsx | 7 +++++++ .../sidebarConnector.menuDecoration.ts | 3 +++ .../connectors/useWorkstationSidebarContextMenu.ts | 10 ++++++++++ 4 files changed, 33 insertions(+) diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx index c0df940036..430152a9b4 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx @@ -2,6 +2,7 @@ import { RenameModal } from "@/src/scaffold/ModalSystem/variants"; import React from "react"; import { useTranslation } from "react-i18next"; +import LinkSessionToProjectModal from "@src/engines/ChatPanel/panels/LinkSessionToProjectModal"; import CloudSessionShareDialog from "@src/features/Org2Cloud/CloudSessionShareDialog"; import type { useCloudSessionShareDialog } from "@src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog"; import CloudShareImportDialog from "@src/features/Org2Cloud/CloudShareImportDialog"; @@ -26,6 +27,9 @@ interface SidebarDialogsProps { moveToOrg: ReturnType; rename: ReturnType; sessionMap: ReadonlyMap; + linkProjectSessionId: string | null; + onCloseLinkProject: () => void; + onProjectLinked: () => void; } export const SidebarDialogs: React.FC = ({ @@ -37,6 +41,9 @@ export const SidebarDialogs: React.FC = ({ moveToOrg, rename, sessionMap, + linkProjectSessionId, + onCloseLinkProject, + onProjectLinked, }) => { const { t } = useTranslation("navigation"); const { t: tCommon } = useTranslation(); @@ -52,6 +59,12 @@ export const SidebarDialogs: React.FC = ({ onCancel={rename.onCancel} onConfirm={(newName) => rename.onConfirm(newName, sessionMap)} /> + { useState("workstation"); const [channelsOpen, setChannelsOpen] = useState(false); const [activeSessionMoreMenuId, setActiveSessionMoreMenuId] = useState(""); + const [linkProjectSessionId, setLinkProjectSessionId] = useState< + string | null + >(null); const [projectsSelectedMenuItemId, setProjectsSelectedMenuItemId] = useState(""); const [workItemsOpen, setWorkItemsOpen] = useState(false); @@ -514,6 +517,7 @@ export const WorkstationSidebarConnector: React.FC = () => { handleOpenInNewTab, handleOpenInMyStation, handleTogglePin, + onLinkToProject: setLinkProjectSessionId, handleToggleSubagentExpansion, handleCloudRemoteItemRemove, t, @@ -871,6 +875,9 @@ export const WorkstationSidebarConnector: React.FC = () => { moveToOrg={moveToOrg} rename={rename} sessionMap={sessionMap} + linkProjectSessionId={linkProjectSessionId} + onCloseLinkProject={() => setLinkProjectSessionId(null)} + onProjectLinked={() => void loadSessionRoster()} /> ); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuDecoration.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuDecoration.ts index 26e9e432f6..34e4bcc4ff 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuDecoration.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuDecoration.ts @@ -55,6 +55,7 @@ interface UseWorkstationSidebarMenuDecorationParams { handleOpenInNewTab: ContextMenuParams["handleOpenInNewTab"]; handleOpenInMyStation: ContextMenuParams["handleOpenInMyStation"]; handleTogglePin: ContextMenuParams["handleTogglePin"]; + onLinkToProject: NonNullable; handleToggleSubagentExpansion: DecorateRowActionsParams["handleToggleSubagentExpansion"]; handleCloudRemoteItemRemove: ContextMenuParams["handleCloudRemoteItemRemove"]; t: (key: string) => string; @@ -102,6 +103,7 @@ export function useWorkstationSidebarMenuDecoration({ handleOpenInNewTab, handleOpenInMyStation, handleTogglePin, + onLinkToProject, handleToggleSubagentExpansion, handleCloudRemoteItemRemove, t, @@ -152,6 +154,7 @@ export function useWorkstationSidebarMenuDecoration({ handleOpenInNewTab, handleOpenInMyStation, handleTogglePin, + onLinkToProject, isMoveEligible: moveToOrg.isMoveEligible, handleOpenMoveToOrg: moveToOrg.openMoveToOrg, moveToOrgLabel: t("cloud.moveToOrg.menuItem"), diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts index 5ddd9d4179..e05990d258 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts @@ -30,6 +30,7 @@ interface UseWorkstationSidebarContextMenuParams { handleOpenInNewTab: (sessionId: string) => void; handleOpenInMyStation: (sessionId: string) => void; handleTogglePin: (sessionId: string) => Promise; + onLinkToProject?: (sessionId: string) => void; /** Owner-side share dialog gate + opener (design §6.3, M4b). */ /** Move-to-cloud-org (session→org tag) gate + opener. */ isMoveEligible: (session: Session) => boolean; @@ -65,6 +66,7 @@ export function useWorkstationSidebarContextMenu({ handleOpenInNewTab, handleOpenInMyStation, handleTogglePin, + onLinkToProject, isMoveEligible, handleOpenMoveToOrg, moveToOrgLabel, @@ -156,6 +158,13 @@ export function useWorkstationSidebarContextMenu({ text: tCommon("sessions:chat.exportAsMarkdown", "Export as Markdown"), action: () => handleExportMarkdown(item.id), }); + primaryItems.push({ + text: tCommon( + "sessions:chat.linkProject.menuItem", + "Link to Project…" + ), + action: () => onLinkToProject?.(item.id), + }); } // Move (tag) the session into a managed cloud org, independent of // repo-scope auto-sharing. Owner's own pushable sessions only. @@ -202,6 +211,7 @@ export function useWorkstationSidebarContextMenu({ handleOpenInNewTab, handleOpenInMyStation, handleTogglePin, + onLinkToProject, handleOpenMoveToOrg, isMoveEligible, moveToOrgLabel, From 967fb0ba2a77000604bb87cfab855489328d6645 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Wed, 29 Jul 2026 02:27:12 +0800 Subject: [PATCH 03/32] feat(project): add org2-patch project tree and journey map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Workspace→Project→WorkItem→Session/Todo tree and project journey view with mainline pin, file category blink, and soft prune. Ship as org2-patch parallel to production ORG2. (cherry picked from commit 647d98150381bb52756997a27defd85dd0f14b23) --- src/i18n/locales/en/projects.json | 4 +- src/i18n/locales/zh/projects.json | 4 +- .../content/OrgSidebarTreeContent.tsx | 2 + .../content/WorkspaceOrgTreeContent.tsx | 73 ++- .../content/orgSidebarUtils.ts | 2 + .../Panels/ProjectManagerSidebar/index.tsx | 8 + .../tabs/ProjectsTab.tsx | 10 + .../ProjectJourney/ProjectJourneyPage.tsx | 442 +++++++++++++++++ .../ProjectJourney/ProjectTreePage.tsx | 261 ++++++++++ .../__tests__/projectJourney.test.ts | 121 +++++ .../ProjectManager/ProjectJourney/index.ts | 4 + .../ProjectJourney/loadProjectTree.ts | 158 ++++++ .../ProjectJourney/model/buildJourney.ts | 449 ++++++++++++++++++ .../ProjectJourney/model/buildTree.ts | 160 +++++++ .../ProjectJourney/model/demoFixture.ts | 159 +++++++ .../ProjectJourney/model/fileCategory.ts | 124 +++++ .../ProjectJourney/model/index.ts | 6 + .../ProjectJourney/model/journeyState.ts | 93 ++++ .../ProjectJourney/model/types.ts | 168 +++++++ .../ProjectManagerContentRouter.tsx | 2 + .../hooks/useProjectManagerSidebarConfig.tsx | 12 + .../hooks/useProjectTabActions.ts | 22 + .../ProjectManagerLayout/index.tsx | 4 + .../ProjectManagerLayout/types.ts | 2 + .../WorkStation/TabContent/registry.ts | 12 + .../WorkStation/TabContent/renderers/index.ts | 2 + .../TabContent/renderers/projectJourney.tsx | 24 + .../TabContent/renderers/projectTree.tsx | 70 +++ .../tabs/__tests__/workspaceState.test.ts | 4 +- src/store/workstation/tabs/factories/index.ts | 5 + .../workstation/tabs/factories/project.ts | 52 ++ src/store/workstation/tabs/index.ts | 6 + src/store/workstation/tabs/tabFactory.ts | 2 + src/store/workstation/tabs/types.ts | 6 + 34 files changed, 2464 insertions(+), 9 deletions(-) create mode 100644 src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx create mode 100644 src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx create mode 100644 src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/index.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/model/buildTree.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/model/fileCategory.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/model/index.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/model/journeyState.ts create mode 100644 src/modules/ProjectManager/ProjectJourney/model/types.ts create mode 100644 src/modules/WorkStation/TabContent/renderers/projectJourney.tsx create mode 100644 src/modules/WorkStation/TabContent/renderers/projectTree.tsx diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index 4fa9f2b1f1..3c2bdf94a2 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -975,7 +975,9 @@ "empty": "No workspace items found", "issues": "Issues", "views": "Views", - "more": "More" + "more": "More", + "projectTree": "Project Tree", + "projectJourney": "Project Journey" }, "teams": { "title": "Your teams", diff --git a/src/i18n/locales/zh/projects.json b/src/i18n/locales/zh/projects.json index 1a3168400a..4d5af97161 100644 --- a/src/i18n/locales/zh/projects.json +++ b/src/i18n/locales/zh/projects.json @@ -976,7 +976,9 @@ "empty": "未找到 Workspace 项目", "issues": "Issues", "views": "Views", - "more": "More" + "more": "More", + "projectTree": "项目树", + "projectJourney": "项目旅程" }, "teams": { "title": "你的团队", diff --git a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx index ab6661af21..0f34244dd3 100644 --- a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx +++ b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx @@ -57,6 +57,8 @@ interface ProjectSidebarTreeContentProps { activeRepoView: | "projects" | "work-items" + | "project-tree" + | "project-journey" | "linear-projects" | "linear-work-items" | "settings" diff --git a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx index 10d9c55687..ee162cd9c2 100644 --- a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx +++ b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx @@ -6,7 +6,14 @@ * * Extracted from OrgSidebarTreeContent.tsx to reduce file size. */ -import { Box, Layers, ListChecks, MoreHorizontal } from "lucide-react"; +import { + BookOpen, + Box, + GitBranch, + Layers, + ListChecks, + MoreHorizontal, +} from "lucide-react"; import React, { memo, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { VirtuosoHandle } from "react-virtuoso"; @@ -27,9 +34,13 @@ import { interface WorkspaceOrgTreeContentProps { onOpenProjects: () => void; onOpenWorkItems: () => void; + onOpenProjectTree?: () => void; + onOpenProjectJourney?: () => void; activeRepoView: | "projects" | "work-items" + | "project-tree" + | "project-journey" | "linear-projects" | "linear-work-items" | "settings" @@ -39,12 +50,45 @@ interface WorkspaceOrgTreeContentProps { export const WorkspaceOrgTreeContent: React.FC = memo( - ({ onOpenProjects, onOpenWorkItems, activeRepoView, activeOrgScope }) => { + ({ + onOpenProjects, + onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, + activeRepoView, + activeOrgScope, + }) => { const { t } = useTranslation("projects"); const virtuosoRef = useRef(null); const flattenedNodes = useMemo[]>( () => [ + { + depth: 0, + node: { + id: "project-sidebar:workspace:project-tree", + name: t("workspace.projectTree"), + path: "project-sidebar:workspace:project-tree", + type: "file", + icon: ( + + ), + kind: "workspace-project-tree", + }, + }, + { + depth: 0, + node: { + id: "project-sidebar:workspace:project-journey", + name: t("workspace.projectJourney"), + path: "project-sidebar:workspace:project-journey", + type: "file", + icon: ( + + ), + kind: "workspace-project-journey", + }, + }, { depth: 0, node: { @@ -117,16 +161,26 @@ export const WorkspaceOrgTreeContent: React.FC = isWorkspaceScope) || (node.kind === "workspace-work-items" && activeRepoView === "work-items" && - isWorkspaceScope); + isWorkspaceScope) || + (node.kind === "workspace-project-tree" && + activeRepoView === "project-tree") || + (node.kind === "workspace-project-journey" && + activeRepoView === "project-journey"); const isActionable = node.kind === "workspace-projects" || - node.kind === "workspace-work-items"; + node.kind === "workspace-work-items" || + node.kind === "workspace-project-tree" || + node.kind === "workspace-project-journey"; const handleClick = node.kind === "workspace-projects" ? onOpenProjects : node.kind === "workspace-work-items" ? onOpenWorkItems - : undefined; + : node.kind === "workspace-project-tree" + ? onOpenProjectTree + : node.kind === "workspace-project-journey" + ? onOpenProjectJourney + : undefined; return ( = /> ); }, - [activeOrgScope, activeRepoView, onOpenProjects, onOpenWorkItems] + [ + activeOrgScope, + activeRepoView, + onOpenProjectJourney, + onOpenProjectTree, + onOpenProjects, + onOpenWorkItems, + ] ); return ( diff --git a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/orgSidebarUtils.ts b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/orgSidebarUtils.ts index 89898445c7..625cabd25a 100644 --- a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/orgSidebarUtils.ts +++ b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/orgSidebarUtils.ts @@ -14,6 +14,8 @@ export interface WorkspaceTreeNode extends TreeRowNode { kind: | "workspace-projects" | "workspace-work-items" + | "workspace-project-tree" + | "workspace-project-journey" | "workspace-views" | "workspace-more"; } diff --git a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/index.tsx b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/index.tsx index 660532593a..8912e59deb 100644 --- a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/index.tsx +++ b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/index.tsx @@ -28,6 +28,8 @@ export interface ProjectManagerSidebarProps { onImportOrgs: () => void; onOpenProjects: () => void; onOpenWorkItems: () => void; + onOpenProjectTree?: () => void; + onOpenProjectJourney?: () => void; onOpenPersonalOrg: ( view?: import("@src/store/workstation/tabs").ProjectOrgSurfaceView ) => void; @@ -41,6 +43,8 @@ export interface ProjectManagerSidebarProps { activeRepoView: | "projects" | "work-items" + | "project-tree" + | "project-journey" | "linear-projects" | "linear-work-items" | "settings" @@ -61,6 +65,8 @@ export const ProjectManagerSidebar: React.FC = memo( onImportOrgs, onOpenProjects, onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, onOpenPersonalOrg, onOpenProjectOrg, onOpenLinearProjects, @@ -84,6 +90,8 @@ export const ProjectManagerSidebar: React.FC = memo( onImportOrgs, onOpenProjects, onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, onOpenPersonalOrg, onOpenProjectOrg, onOpenLinearProjects, diff --git a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/tabs/ProjectsTab.tsx b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/tabs/ProjectsTab.tsx index bc78d73232..bdf552eee3 100644 --- a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/tabs/ProjectsTab.tsx +++ b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/tabs/ProjectsTab.tsx @@ -305,6 +305,8 @@ export interface UseProjectsTabConfigProps { onImportOrgs: () => void; onOpenProjects: () => void; onOpenWorkItems: () => void; + onOpenProjectTree?: () => void; + onOpenProjectJourney?: () => void; onOpenPersonalOrg: (view?: ProjectOrgSurfaceView) => void; onOpenProjectOrg: (org: ProjectOrg, view?: ProjectOrgSurfaceView) => void; onOpenLinearProjects: (selection?: LinearProjectSelection) => void; @@ -317,6 +319,8 @@ export interface UseProjectsTabConfigProps { activeRepoView: | "projects" | "work-items" + | "project-tree" + | "project-journey" | "linear-projects" | "linear-work-items" | "settings" @@ -333,6 +337,8 @@ export function useProjectsTabConfig({ onImportOrgs, onOpenProjects, onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, onOpenPersonalOrg, onOpenProjectOrg, onOpenLinearProjects, @@ -450,6 +456,8 @@ export function useProjectsTabConfig({ @@ -493,6 +501,8 @@ export function useProjectsTabConfig({ projectsActions, orgActions, onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, onOpenPersonalOrg, onOpenProjectOrg, onOpenLinearProjects, diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx new file mode 100644 index 0000000000..6c5cc55f02 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx @@ -0,0 +1,442 @@ +import { + GitFork, + Pin, + PinOff, + RefreshCw, + Scissors, + Sparkles, +} from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { loadProjectTreeBundle } from "./loadProjectTree"; +import { + DEMO_PROJECT, + DEMO_WORK_ITEMS, + type JourneyFileCategory, + type JourneyNode, + type ProjectJourneyGraph, + type ProjectJourneyState, + type ProjectLike, + type WorkItemLike, + buildProjectJourneyGraph, + emptyJourneyState, + filterFiles, + loadJourneyState, + nodesTouchedByFile, + saveJourneyState, + togglePinNode, + togglePruneNode, +} from "./model"; + +export interface ProjectJourneyPageProps { + projectId?: string; + projectSlug?: string; + projectName?: string; + forceDemo?: boolean; +} + +type FileFilter = "all" | JourneyFileCategory; + +const statusColor: Record = { + todo: "border-border-2 bg-fill-2", + doing: "border-primary-5 bg-primary-1", + done: "border-success-5 bg-success-1", + abandoned: "border-danger-5 bg-danger-1 opacity-70", +}; + +const ProjectJourneyPage: React.FC = ({ + projectId, + projectSlug, + projectName, + forceDemo, +}) => { + const [graph, setGraph] = useState(null); + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [usedDemo, setUsedDemo] = useState(false); + const [fileFilter, setFileFilter] = useState("all"); + const [selectedFile, setSelectedFile] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [workItems, setWorkItems] = useState([]); + const [project, setProject] = useState(null); + + const rebuild = useCallback( + (proj: ProjectLike, items: WorkItemLike[], st: ProjectJourneyState) => { + const next = buildProjectJourneyGraph({ + project: proj, + workItems: items, + state: st, + }); + setGraph(next); + setState(st); + }, + [] + ); + + const reload = useCallback( + async (demo = false) => { + setLoading(true); + setError(null); + try { + if (demo || forceDemo) { + const st = loadJourneyState(DEMO_PROJECT.id); + setProject(DEMO_PROJECT); + setWorkItems(DEMO_WORK_ITEMS); + setUsedDemo(true); + rebuild(DEMO_PROJECT, DEMO_WORK_ITEMS, st); + return; + } + const bundle = await loadProjectTreeBundle({ forceDemo: false }); + setUsedDemo(bundle.usedDemo); + const projects = bundle.projects; + const match = + projects.find((p) => p.id === projectId) || + projects.find((p) => p.slug === projectSlug) || + projects.find((p) => p.id === DEMO_PROJECT.id) || + projects[0] || + DEMO_PROJECT; + const items = + bundle.workItemsByProject[match.id] || + bundle.workItemsByProject[match.slug ?? ""] || + (match.id === DEMO_PROJECT.id ? DEMO_WORK_ITEMS : []); + const st = loadJourneyState(match.id); + setProject(match); + setWorkItems(items); + rebuild(match, items, st); + if (bundle.error) setError(bundle.error); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + const st = loadJourneyState(DEMO_PROJECT.id); + setProject(DEMO_PROJECT); + setWorkItems(DEMO_WORK_ITEMS); + setUsedDemo(true); + rebuild(DEMO_PROJECT, DEMO_WORK_ITEMS, st); + } finally { + setLoading(false); + } + }, + [forceDemo, projectId, projectSlug, rebuild] + ); + + useEffect(() => { + void reload(Boolean(forceDemo)); + }, [forceDemo, reload]); + + const highlighted = useMemo(() => { + if (!graph || !selectedFile) return new Set(); + return new Set(nodesTouchedByFile(graph, selectedFile)); + }, [graph, selectedFile]); + + const files = useMemo( + () => (graph ? filterFiles(graph, fileFilter) : []), + [fileFilter, graph] + ); + + const mainline = graph?.nodes.filter( + (n) => n.kind === "work_item" && n.isMainline && !n.pruned + ); + const branches = graph?.nodes.filter( + (n) => (n.kind === "session" || n.kind === "fork") && !n.pruned + ); + const pruned = graph?.nodes.filter((n) => n.pruned); + + const onPin = (nodeId: string) => { + if (!state || !project) return; + const next = togglePinNode(state, nodeId); + rebuild(project, workItems, next); + }; + + const onPrune = (nodeId: string) => { + if (!state || !project) return; + const next = togglePruneNode(state, nodeId); + rebuild(project, workItems, next); + }; + + const selectedNode: JourneyNode | null = + graph?.nodes.find((n) => n.id === selectedNodeId) ?? null; + + return ( +
+
+ +
+
+ Project Journey + {project || projectName ? ` · ${project?.name ?? projectName}` : ""} +
+
+ Mainline pin + work-product suggestions · file blink · soft prune + {usedDemo ? " · demo" : ""} +
+
+ {graph && ( +
+ mainline {Math.round(graph.mainlineProgress * 100)}% · sess{" "} + {graph.stats.sessionCount} · files {graph.files.length} · redundant{" "} + {graph.stats.redundantNodeCount} +
+ )} + + +
+ + {error && ( +
Warning: {error}
+ )} + {loading && ( +
Loading journey…
+ )} + + {!loading && graph && ( +
+
+
+
+ Mainline +
+
+ {(mainline ?? []).map((node, idx) => ( + setSelectedNodeId(node.id)} + onPin={() => onPin(node.id)} + onPrune={() => onPrune(node.id)} + /> + ))} + {(mainline?.length ?? 0) === 0 && ( +
+ No mainline yet — pin a work item or add work products. +
+ )} +
+
+ +
+
+ Branches / Sessions +
+
+ {(branches ?? []).map((node) => ( + setSelectedNodeId(node.id)} + onPin={() => onPin(node.id)} + onPrune={() => onPrune(node.id)} + /> + ))} +
+
+ + {(pruned?.length ?? 0) > 0 && ( +
+
+ Pruned (archived edges) +
+
+ {pruned!.map((node) => ( + setSelectedNodeId(node.id)} + onPin={() => onPin(node.id)} + onPrune={() => onPrune(node.id)} + /> + ))} +
+
+ )} + + {selectedNode && ( +
+
+ {selectedNode.title} +
+
{selectedNode.summary}
+
+ sessions: {selectedNode.sessionIds.join(", ") || "—"} +
+
+ files: {selectedNode.resultFileRefs.length} +
+
+ )} +
+ +
+
+
+ Result files +
+
+ {( + [ + "all", + "produced", + "touched_production", + "other", + ] as FileFilter[] + ).map((key) => ( + + ))} +
+
+
+ {files.map((f) => { + const active = selectedFile === f.path; + return ( + + ); + })} + {files.length === 0 && ( +
+ No files in filter +
+ )} +
+
+
+ )} +
+ ); +}; + +function NodeCard({ + node, + index, + selected, + highlighted, + onSelect, + onPin, + onPrune, +}: { + node: JourneyNode; + index?: number; + selected: boolean; + highlighted: boolean; + onSelect: () => void; + onPin: () => void; + onPrune: () => void; +}) { + return ( +
+ +
+ + +
+
+ ); +} + +// silence unused import if tree shaker complains in some builds +void saveJourneyState; +void emptyJourneyState; + +export default ProjectJourneyPage; diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx new file mode 100644 index 0000000000..4135aa59e9 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx @@ -0,0 +1,261 @@ +import { + Box, + ChevronDown, + ChevronRight, + CircleDot, + FolderTree, + GitBranch, + ListTodo, + RefreshCw, +} from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { loadProjectTreeBundle } from "./loadProjectTree"; +import type { ProjectTreeNode } from "./model"; + +export interface ProjectTreePageProps { + onOpenJourney?: ( + projectId: string, + projectSlug?: string, + projectName?: string + ) => void; + onOpenWorkItem?: (workItemId: string, projectSlug?: string) => void; + publishToWorkstationHeader?: boolean; +} + +function kindIcon(kind: ProjectTreeNode["kind"]) { + switch (kind) { + case "workspace": + return ; + case "project": + return ; + case "work_item": + return ; + case "todo": + return ; + case "session": + return ; + case "unassigned": + return ; + default: + return null; + } +} + +const ProjectTreePage: React.FC = ({ + onOpenJourney, + onOpenWorkItem, +}) => { + const [root, setRoot] = useState(null); + const [usedDemo, setUsedDemo] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState>({ + "workspace:root": true, + }); + const [filter, setFilter] = useState(""); + + const reload = useCallback(async (forceDemo = false) => { + setLoading(true); + setError(null); + try { + const bundle = await loadProjectTreeBundle({ forceDemo }); + setRoot(bundle.tree); + setUsedDemo(bundle.usedDemo); + setError(bundle.error ?? null); + setExpanded((prev) => ({ + ...prev, + "workspace:root": true, + ...Object.fromEntries( + (bundle.tree.children ?? []).map((c) => [c.id, true]) + ), + })); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(false); + }, [reload]); + + const rows = useMemo(() => { + if (!root) return [] as Array<{ node: ProjectTreeNode; depth: number }>; + const out: Array<{ node: ProjectTreeNode; depth: number }> = []; + const q = filter.trim().toLowerCase(); + const walk = (node: ProjectTreeNode, depth: number) => { + const selfMatch = + !q || + node.title.toLowerCase().includes(q) || + node.kind.includes(q) || + node.status?.toLowerCase().includes(q); + const childRows: Array<{ node: ProjectTreeNode; depth: number }> = []; + if (expanded[node.id] || q) { + for (const child of node.children) { + // when filtering, auto-include matching descendants + const before = childRows.length; + walkCollect(child, depth + 1, childRows, q); + if (childRows.length === before && q) { + // no match in subtree + } + } + } + if (selfMatch || childRows.length > 0 || !q) { + out.push({ node, depth }); + out.push(...childRows); + } + }; + const walkCollect = ( + node: ProjectTreeNode, + depth: number, + acc: Array<{ node: ProjectTreeNode; depth: number }>, + query: string + ) => { + const selfMatch = + !query || + node.title.toLowerCase().includes(query) || + node.kind.includes(query) || + node.status?.toLowerCase().includes(query); + const childAcc: Array<{ node: ProjectTreeNode; depth: number }> = []; + const open = expanded[node.id] || Boolean(query); + if (open) { + for (const child of node.children) { + walkCollect(child, depth + 1, childAcc, query); + } + } + if (selfMatch || childAcc.length > 0) { + acc.push({ node, depth }); + acc.push(...childAcc); + } + }; + walk(root, 0); + return out; + }, [expanded, filter, root]); + + const toggle = (id: string) => + setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); + + return ( +
+
+ +
+
Project Tree
+
+ Workspace → Project → Work Item → Session / Task + {usedDemo ? " · demo data" : ""} +
+
+ + +
+ +
+ setFilter(e.target.value)} + placeholder="Filter nodes…" + className="w-full rounded-md border border-border-2 bg-fill-1 px-2 py-1.5 text-xs text-text-1 outline-none focus:border-primary-6" + data-testid="project-tree-filter" + /> +
+ + {error && ( +
+ Load warning: {error} (showing available/demo data) +
+ )} + +
+ {loading && ( +
Loading tree…
+ )} + {!loading && + rows.map(({ node, depth }) => { + const hasChildren = node.children.length > 0; + const open = expanded[node.id]; + return ( +
+ + {kindIcon(node.kind)} + + {node.title} + + {node.status && ( + + {node.status} + + )} + {node.kind === "project" && onOpenJourney && ( + + )} + {node.kind === "work_item" && onOpenWorkItem && ( + + )} +
+ ); + })} +
+
+ ); +}; + +export default ProjectTreePage; diff --git a/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts b/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts new file mode 100644 index 0000000000..075991a334 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { + buildProjectJourneyGraph, + filterFiles, + nodesTouchedByFile, +} from "../model/buildJourney"; +import { buildWorkspaceProjectTree, countByKind } from "../model/buildTree"; +import { DEMO_PROJECT, DEMO_WORK_ITEMS } from "../model/demoFixture"; +import { classifyPath } from "../model/fileCategory"; +import { + emptyJourneyState, + loadJourneyState, + saveJourneyState, + togglePinNode, + togglePruneNode, +} from "../model/journeyState"; + +describe("classifyPath", () => { + it("classifies produced vs production sources", () => { + expect(classifyPath("docs/org2-patch/PRD.md")).toBe("produced"); + expect(classifyPath("reports/a/report.html")).toBe("produced"); + expect( + classifyPath( + "src/modules/ProjectManager/ProjectJourney/model/buildTree.ts" + ) + ).toBe("touched_production"); + expect(classifyPath("random.bin")).toBe("other"); + }); +}); + +describe("buildWorkspaceProjectTree", () => { + it("builds workspace → project → work item → todo/session", () => { + const tree = buildWorkspaceProjectTree({ + projects: [DEMO_PROJECT], + workItemsByProject: { [DEMO_PROJECT.id]: DEMO_WORK_ITEMS }, + }); + expect(tree.kind).toBe("workspace"); + expect(tree.children[0]?.kind).toBe("project"); + const counts = countByKind(tree); + expect(counts.work_item).toBe(2); + expect(counts.session).toBeGreaterThanOrEqual(3); + expect(counts.todo).toBeGreaterThanOrEqual(5); + }); + + it("puts standalone items into Unassigned", () => { + const tree = buildWorkspaceProjectTree({ + projects: [], + workItemsByProject: {}, + standaloneWorkItems: DEMO_WORK_ITEMS, + }); + expect(tree.children[0]?.kind).toBe("unassigned"); + expect(tree.children[0]?.children).toHaveLength(2); + }); +}); + +describe("buildProjectJourneyGraph", () => { + it("suggests mainline from work products and marks forks", () => { + const graph = buildProjectJourneyGraph({ + project: DEMO_PROJECT, + workItems: DEMO_WORK_ITEMS, + }); + expect(graph.nodes.some((n) => n.isMainline)).toBe(true); + expect(graph.nodes.some((n) => n.kind === "fork")).toBe(true); + expect(graph.stats.producedFileCount).toBeGreaterThan(0); + expect(graph.stats.touchedProductionFileCount).toBeGreaterThan(0); + expect(graph.mainlineProgress).toBeGreaterThan(0); + }); + + it("highlights sessions for overlapping file paths", () => { + const graph = buildProjectJourneyGraph({ + project: DEMO_PROJECT, + workItems: DEMO_WORK_ITEMS, + }); + const path = + "src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts"; + const nodeIds = nodesTouchedByFile(graph, path); + expect(nodeIds.length).toBeGreaterThanOrEqual(2); + const produced = filterFiles(graph, "produced"); + expect(produced.every((f) => f.category === "produced")).toBe(true); + }); + + it("applies pin and prune state", () => { + let state = emptyJourneyState(DEMO_PROJECT.id); + state = togglePinNode(state, "wi:WI-DEMO-2"); + state = togglePruneNode(state, "session:sess-fork-dead"); + const graph = buildProjectJourneyGraph({ + project: DEMO_PROJECT, + workItems: DEMO_WORK_ITEMS, + state, + }); + expect(graph.nodes.find((n) => n.id === "wi:WI-DEMO-2")?.isMainline).toBe( + true + ); + expect( + graph.nodes.find((n) => n.id === "session:sess-fork-dead")?.pruned + ).toBe(true); + }); +}); + +describe("journeyState storage", () => { + it("round-trips through a memory storage", () => { + const mem = new Map(); + const storage = { + getItem: (k: string) => mem.get(k) ?? null, + setItem: (k: string, v: string) => { + mem.set(k, v); + }, + }; + const saved = saveJourneyState( + { + ...emptyJourneyState("p1"), + pinnedMainlineNodeIds: ["a"], + }, + storage + ); + const loaded = loadJourneyState("p1", storage); + expect(loaded.pinnedMainlineNodeIds).toEqual(["a"]); + expect(loaded.updatedAt).toBe(saved.updatedAt); + }); +}); diff --git a/src/modules/ProjectManager/ProjectJourney/index.ts b/src/modules/ProjectManager/ProjectJourney/index.ts new file mode 100644 index 0000000000..a1a431a3ff --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/index.ts @@ -0,0 +1,4 @@ +export { default as ProjectTreePage } from "./ProjectTreePage"; +export { default as ProjectJourneyPage } from "./ProjectJourneyPage"; +export * from "./model"; +export * from "./loadProjectTree"; diff --git a/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts new file mode 100644 index 0000000000..a90416cfc3 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts @@ -0,0 +1,158 @@ +/** + * Load real projects/work items and fall back to demo fixture. + */ +import { + type ProjectData, + enrichedWorkItemToUI, + projectApi, +} from "@src/api/http/project"; + +import { + DEMO_PROJECT, + DEMO_WORK_ITEMS, + type ProjectLike, + type ProjectTreeNode, + type WorkItemLike, + buildWorkspaceProjectTree, +} from "./model"; + +export interface ProjectTreeBundle { + tree: ProjectTreeNode; + projects: ProjectLike[]; + workItemsByProject: Record; + standaloneWorkItems: WorkItemLike[]; + usedDemo: boolean; + error?: string; +} + +function projectDataToLike(p: ProjectData): ProjectLike { + return { + id: p.meta.id, + name: p.meta.name, + slug: p.slug, + status: p.meta.status, + description: p.description, + }; +} + +export async function loadProjectTreeBundle(options?: { + forceDemo?: boolean; + workspaceName?: string; +}): Promise { + if (options?.forceDemo) { + const workItemsByProject = { + [DEMO_PROJECT.id]: DEMO_WORK_ITEMS, + [DEMO_PROJECT.slug!]: DEMO_WORK_ITEMS, + }; + return { + tree: buildWorkspaceProjectTree({ + workspaceName: options.workspaceName ?? "Workspace", + projects: [DEMO_PROJECT], + workItemsByProject, + }), + projects: [DEMO_PROJECT], + workItemsByProject, + standaloneWorkItems: [], + usedDemo: true, + }; + } + + try { + const projectsRaw = await projectApi.readProjects(); + const projects = (projectsRaw ?? []).map(projectDataToLike); + const workItemsByProject: Record = {}; + + await Promise.all( + projects.map(async (project) => { + const slug = project.slug || project.id; + try { + const enriched = await projectApi.readWorkItemsEnriched(slug); + const items = (enriched ?? []).map((item) => + enrichedWorkItemToUI(item) + ) as unknown as WorkItemLike[]; + workItemsByProject[project.id] = items; + workItemsByProject[slug] = items; + } catch { + workItemsByProject[project.id] = []; + workItemsByProject[slug] = []; + } + }) + ); + + let standaloneWorkItems: WorkItemLike[] = []; + try { + const standalone = await projectApi.readStandaloneWorkItems(); + standaloneWorkItems = (standalone ?? []).map((item) => { + const anyItem = item as unknown as Record; + return { + session_id: String( + anyItem.session_id ?? anyItem.id ?? anyItem.short_id ?? "" + ), + name: String(anyItem.name ?? anyItem.title ?? "work item"), + status: String(anyItem.status ?? ""), + workItemStatus: String( + anyItem.workItemStatus ?? anyItem.status ?? "" + ), + linkedSessions: (anyItem.linkedSessions ?? + anyItem.linked_sessions ?? + []) as WorkItemLike["linkedSessions"], + todos: (anyItem.todos as WorkItemLike["todos"]) ?? [], + workProducts: + (anyItem.workProducts as WorkItemLike["workProducts"]) ?? + (anyItem.work_products as WorkItemLike["workProducts"]) ?? + [], + created_time: String( + anyItem.created_time ?? anyItem.created_at ?? "" + ), + updated_time: String( + anyItem.updated_time ?? anyItem.updated_at ?? "" + ), + }; + }); + } catch { + standaloneWorkItems = []; + } + + const hasLinks = Object.values(workItemsByProject).some((items) => + items.some( + (wi) => + (wi.linkedSessions?.length ?? 0) > 0 || (wi.todos?.length ?? 0) > 0 + ) + ); + + if (projects.length === 0 && standaloneWorkItems.length === 0) { + return loadProjectTreeBundle({ + forceDemo: true, + workspaceName: options?.workspaceName, + }); + } + + const finalProjects = hasLinks ? projects : [...projects, DEMO_PROJECT]; + if (!hasLinks) { + workItemsByProject[DEMO_PROJECT.id] = DEMO_WORK_ITEMS; + workItemsByProject[DEMO_PROJECT.slug!] = DEMO_WORK_ITEMS; + } + + return { + tree: buildWorkspaceProjectTree({ + workspaceName: options?.workspaceName ?? "Workspace", + projects: finalProjects, + workItemsByProject, + standaloneWorkItems, + }), + projects: finalProjects, + workItemsByProject, + standaloneWorkItems, + usedDemo: !hasLinks, + }; + } catch (error) { + const demo = await loadProjectTreeBundle({ + forceDemo: true, + workspaceName: options?.workspaceName, + }); + return { + ...demo, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts b/src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts new file mode 100644 index 0000000000..598a37a64f --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts @@ -0,0 +1,449 @@ +/** + * Build project journey graph from work items + optional narrative state. + */ +import { + classifyPath, + classifyWorkProduct, + workProductPath, +} from "./fileCategory"; +import type { + JourneyEdge, + JourneyFileRef, + JourneyNode, + JourneyNodeStatus, + LinkedSessionLike, + ProjectJourneyGraph, + ProjectJourneyState, + ProjectLike, + WorkItemLike, +} from "./types"; + +export interface BuildJourneyInput { + project: ProjectLike; + workItems: WorkItemLike[]; + state?: ProjectJourneyState | null; + /** Extra file hits from orgtrack: path → sessionIds */ + orgtrackFiles?: Array<{ + path: string; + sessionIds: string[]; + additions?: number; + deletions?: number; + }>; +} + +function wiStatus(wi: WorkItemLike): JourneyNodeStatus { + const raw = (wi.workItemStatus ?? wi.status ?? "").toLowerCase(); + if ( + raw === "completed" || + raw === "done" || + raw === "closed" || + raw === "cancelled" || + raw === "canceled" || + raw === "duplicate" + ) { + return raw === "cancelled" || raw === "canceled" || raw === "duplicate" + ? "abandoned" + : "done"; + } + if (raw === "in_progress" || raw === "in_review" || raw === "open") { + return "doing"; + } + return "todo"; +} + +function sessionNodeStatus(raw?: string): JourneyNodeStatus { + const s = (raw ?? "").toLowerCase(); + if (s === "completed" || s === "done" || s === "success") return "done"; + if (s === "failed" || s === "cancelled" || s === "canceled") + return "abandoned"; + if (s === "running" || s === "in_progress" || s === "active") return "doing"; + return "todo"; +} + +function summarizeWorkItem(wi: WorkItemLike): string { + const sessions = wi.linkedSessions?.length ?? 0; + const todos = wi.todos ?? []; + const doneTodos = todos.filter((t) => + ["completed", "done"].includes(t.status.toLowerCase()) + ).length; + const products = wi.workProducts?.length ?? 0; + const parts = [ + sessions ? `${sessions} sessions` : null, + todos.length ? `tasks ${doneTodos}/${todos.length}` : null, + products ? `${products} products` : null, + ].filter(Boolean); + const spec = (wi.spec ?? "").trim(); + if (spec) { + const one = + spec + .split("\n") + .find((l) => l.trim()) + ?.trim() ?? ""; + if (one) parts.unshift(one.slice(0, 80)); + } + return parts.join(" · ") || "No sessions yet"; +} + +function pushFile( + map: Map, + path: string, + category: JourneyFileRef["category"], + sessionId: string | undefined, + workItemId: string, + source: JourneyFileRef["source"], + additions?: number, + deletions?: number +) { + const key = path.replace(/\\/g, "/"); + const existing = map.get(key); + if (!existing) { + map.set(key, { + path: key, + category, + sessionIds: sessionId ? [sessionId] : [], + workItemIds: [workItemId], + additions, + deletions, + source, + }); + return; + } + if (sessionId && !existing.sessionIds.includes(sessionId)) { + existing.sessionIds.push(sessionId); + } + if (!existing.workItemIds.includes(workItemId)) { + existing.workItemIds.push(workItemId); + } + if (additions != null) { + existing.additions = (existing.additions ?? 0) + additions; + } + if (deletions != null) { + existing.deletions = (existing.deletions ?? 0) + deletions; + } + // Prefer stronger category: produced > touched > other + const rank = { produced: 2, touched_production: 1, other: 0 } as const; + if (rank[category] > rank[existing.category]) { + existing.category = category; + } +} + +function collectFiles( + workItems: WorkItemLike[], + orgtrackFiles: BuildJourneyInput["orgtrackFiles"] +): JourneyFileRef[] { + const map = new Map(); + + for (const wi of workItems) { + const fallbackSession = wi.linkedSessions?.[0]?.session_id; + for (const product of wi.workProducts ?? []) { + const path = workProductPath(product); + if (!path) continue; + pushFile( + map, + path, + classifyWorkProduct(product), + product.sessionId ?? fallbackSession, + wi.session_id, + "work_product" + ); + } + + const files = + wi.proofOfWork?.diff_stats?.files ?? + wi.proofOfWork?.diffStats?.files ?? + []; + for (const f of files) { + if (!f.path) continue; + pushFile( + map, + f.path, + classifyPath(f.path), + fallbackSession, + wi.session_id, + "proof_of_work", + f.additions, + f.deletions + ); + } + } + + for (const hit of orgtrackFiles ?? []) { + for (const sid of hit.sessionIds) { + pushFile( + map, + hit.path, + classifyPath(hit.path), + sid, + // orgtrack may not know WI; leave empty and fill later via session map + "", + "orgtrack", + hit.additions, + hit.deletions + ); + } + } + + // Drop empty workItem id placeholders + for (const file of map.values()) { + file.workItemIds = file.workItemIds.filter(Boolean); + } + + return [...map.values()].sort((a, b) => a.path.localeCompare(b.path)); +} + +function hasWorkProduct(wi: WorkItemLike): boolean { + if ((wi.workProducts?.length ?? 0) > 0) return true; + const files = + wi.proofOfWork?.diff_stats?.files ?? wi.proofOfWork?.diffStats?.files ?? []; + return files.length > 0; +} + +function computeRedundancy( + nodes: JourneyNode[], + files: JourneyFileRef[] +): void { + const pathToSessions = new Map>(); + for (const f of files) { + const set = pathToSessions.get(f.path) ?? new Set(); + f.sessionIds.forEach((s) => set.add(s)); + pathToSessions.set(f.path, set); + } + + const sessionOverlap = new Map(); + for (const sessions of pathToSessions.values()) { + if (sessions.size < 2) continue; + for (const s of sessions) { + sessionOverlap.set(s, (sessionOverlap.get(s) ?? 0) + 1); + } + } + + for (const node of nodes) { + if (node.kind !== "session" && node.kind !== "fork") continue; + let score = 0; + for (const sid of node.sessionIds) { + score += sessionOverlap.get(sid) ?? 0; + } + // No products and is fork → bump + if ( + node.kind === "fork" && + node.resultFileRefs.length === 0 && + !node.isMainline + ) { + score += 1; + } + node.redundancyScore = score; + } +} + +function filesForSessions( + files: JourneyFileRef[], + sessionIds: string[] +): JourneyFileRef[] { + if (sessionIds.length === 0) return []; + const set = new Set(sessionIds); + return files.filter((f) => f.sessionIds.some((s) => set.has(s))); +} + +export function buildProjectJourneyGraph( + input: BuildJourneyInput +): ProjectJourneyGraph { + const { project, workItems } = input; + const state = input.state; + const pinned = new Set(state?.pinnedMainlineNodeIds ?? []); + const prunedNodes = new Set(state?.prunedNodeIds ?? []); + const prunedEdges = new Set(state?.prunedEdgeIds ?? []); + + const files = collectFiles(workItems, input.orgtrackFiles); + const nodes: JourneyNode[] = []; + const edges: JourneyEdge[] = []; + + const sorted = [...workItems].sort((a, b) => { + const at = a.created_time ?? a.updated_time ?? ""; + const bt = b.created_time ?? b.updated_time ?? ""; + return at.localeCompare(bt); + }); + + // Suggested mainline: work items that produced artifacts, else chronological spine + const suggestedWiIds = new Set( + sorted.filter(hasWorkProduct).map((w) => w.session_id) + ); + if (suggestedWiIds.size === 0) { + sorted.forEach((w) => suggestedWiIds.add(w.session_id)); + } + + let prevMainWiNodeId: string | null = null; + + for (const wi of sorted) { + const wiNodeId = `wi:${wi.session_id}`; + const suggested = suggestedWiIds.has(wi.session_id); + const isMainline = pinned.size > 0 ? pinned.has(wiNodeId) : suggested; + + const sessionIds = (wi.linkedSessions ?? []).map((s) => s.session_id); + const wiFiles = files.filter( + (f) => + f.workItemIds.includes(wi.session_id) || + f.sessionIds.some((s) => sessionIds.includes(s)) + ); + + nodes.push({ + id: wiNodeId, + title: wi.name || wi.session_id, + summary: summarizeWorkItem(wi), + status: wiStatus(wi), + workItemIds: [wi.session_id], + sessionIds, + resultFileRefs: wiFiles, + isMainline, + suggestedMainline: suggested, + redundancyScore: 0, + pruned: prunedNodes.has(wiNodeId), + kind: "work_item", + startedAt: wi.created_time, + completedAt: wi.updated_time, + }); + + if (prevMainWiNodeId && isMainline) { + const edgeId = `${prevMainWiNodeId}->${wiNodeId}`; + edges.push({ + id: edgeId, + from: prevMainWiNodeId, + to: wiNodeId, + kind: "main", + pruned: prunedEdges.has(edgeId), + }); + } + if (isMainline) prevMainWiNodeId = wiNodeId; + + // Session nodes as branches under work item + const sessions = [...(wi.linkedSessions ?? [])].sort((a, b) => + (a.started_at ?? "").localeCompare(b.started_at ?? "") + ); + + const sessionNodeIds = new Map(); + for (const ls of sessions) { + const sid = ls.session_id; + const nodeId = `session:${sid}`; + sessionNodeIds.set(sid, nodeId); + const sFiles = filesForSessions(files, [sid]); + const isFork = Boolean(ls.parent_session_id); + const sessionMain = + pinned.size > 0 + ? pinned.has(nodeId) + : !isFork && sessions[0]?.session_id === sid; + + nodes.push({ + id: nodeId, + title: ls.sub_agent_name || ls.agent_role || sid.slice(0, 10), + summary: buildSessionSummary(ls, sFiles.length), + status: sessionNodeStatus(ls.status), + workItemIds: [wi.session_id], + sessionIds: [sid], + resultFileRefs: sFiles, + isMainline: sessionMain && isMainline, + suggestedMainline: !isFork, + parentNodeId: ls.parent_session_id + ? (sessionNodeIds.get(ls.parent_session_id) ?? wiNodeId) + : wiNodeId, + branchOf: isFork + ? (sessionNodeIds.get(ls.parent_session_id ?? "") ?? wiNodeId) + : wiNodeId, + redundancyScore: 0, + pruned: prunedNodes.has(nodeId), + kind: isFork ? "fork" : "session", + startedAt: ls.started_at, + completedAt: ls.completed_at, + }); + + const parentId = + ls.parent_session_id && sessionNodeIds.has(ls.parent_session_id) + ? sessionNodeIds.get(ls.parent_session_id)! + : wiNodeId; + const edgeId = `${parentId}->${nodeId}`; + edges.push({ + id: edgeId, + from: parentId, + to: nodeId, + kind: isFork ? "fork" : "main", + pruned: prunedEdges.has(edgeId), + weight: sFiles.reduce( + (n, f) => n + (f.additions ?? 0) + (f.deletions ?? 0), + 1 + ), + }); + } + } + + computeRedundancy(nodes, files); + + const mainlineNodes = nodes.filter( + (n) => n.isMainline && !n.pruned && n.kind === "work_item" + ); + const doneMain = mainlineNodes.filter((n) => n.status === "done").length; + const mainlineProgress = + mainlineNodes.length === 0 ? 0 : doneMain / mainlineNodes.length; + + return { + projectId: project.id, + projectSlug: project.slug, + projectName: project.name, + nodes, + edges, + files, + mainlineProgress, + stats: { + workItemCount: workItems.length, + sessionCount: nodes.filter( + (n) => n.kind === "session" || n.kind === "fork" + ).length, + producedFileCount: files.filter((f) => f.category === "produced").length, + touchedProductionFileCount: files.filter( + (f) => f.category === "touched_production" + ).length, + redundantNodeCount: nodes.filter( + (n) => n.redundancyScore > 0 && !n.pruned + ).length, + prunedNodeCount: nodes.filter((n) => n.pruned).length, + }, + }; +} + +function buildSessionSummary(ls: LinkedSessionLike, fileCount: number): string { + const parts = [ + ls.status ?? "unknown", + ls.session_type, + fileCount ? `${fileCount} files` : null, + ls.parent_session_id ? "fork" : null, + ].filter(Boolean); + return parts.join(" · "); +} + +/** Sessions to highlight when a file is selected. */ +export function sessionsForFile( + graph: ProjectJourneyGraph, + filePath: string +): string[] { + const file = graph.files.find((f) => f.path === filePath); + return file?.sessionIds ?? []; +} + +export function nodesTouchedByFile( + graph: ProjectJourneyGraph, + filePath: string +): string[] { + const sessionIds = new Set(sessionsForFile(graph, filePath)); + return graph.nodes + .filter( + (n) => + n.sessionIds.some((s) => sessionIds.has(s)) || + n.resultFileRefs.some((f) => f.path === filePath) + ) + .map((n) => n.id); +} + +export function filterFiles( + graph: ProjectJourneyGraph, + category: "all" | "produced" | "touched_production" | "other" +): JourneyFileRef[] { + if (category === "all") return graph.files; + return graph.files.filter((f) => f.category === category); +} diff --git a/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts b/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts new file mode 100644 index 0000000000..cc26d87eb4 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts @@ -0,0 +1,160 @@ +/** + * Build Workspace → Project → WorkItem → Session/Todo tree. + */ +import type { ProjectLike, ProjectTreeNode, WorkItemLike } from "./types"; + +export interface BuildTreeInput { + workspaceName?: string; + projects: ProjectLike[]; + /** projectId or slug → work items */ + workItemsByProject: Record; + /** Orphan work items (no project) */ + standaloneWorkItems?: WorkItemLike[]; + includeTodos?: boolean; +} + +function sessionStatus(raw?: string): string | undefined { + return raw?.toLowerCase(); +} + +function mapWorkItemNode( + wi: WorkItemLike, + projectId?: string, + projectSlug?: string, + includeTodos = true +): ProjectTreeNode { + const children: ProjectTreeNode[] = []; + + if (includeTodos) { + for (const todo of wi.todos ?? []) { + children.push({ + id: `todo:${wi.session_id}:${todo.id}`, + kind: "todo", + title: todo.content || todo.id, + status: todo.status, + projectId, + projectSlug, + workItemId: wi.session_id, + children: [], + meta: { todoId: todo.id }, + }); + } + } + + for (const ls of wi.linkedSessions ?? []) { + children.push({ + id: `session:${ls.session_id}`, + kind: "session", + title: + ls.sub_agent_name || + ls.agent_role || + ls.session_id.slice(0, 8) || + "session", + status: sessionStatus(ls.status), + projectId, + projectSlug, + workItemId: wi.session_id, + sessionId: ls.session_id, + children: [], + meta: { + parentSessionId: ls.parent_session_id ?? null, + sessionType: ls.session_type, + agentRole: ls.agent_role, + }, + }); + } + + return { + id: `work_item:${wi.session_id}`, + kind: "work_item", + title: wi.name || wi.session_id, + status: wi.workItemStatus ?? wi.status, + projectId, + projectSlug, + workItemId: wi.session_id, + children, + meta: { + linkedSessionCount: wi.linkedSessions?.length ?? 0, + todoCount: wi.todos?.length ?? 0, + }, + }; +} + +function resolveWorkItems( + project: ProjectLike, + workItemsByProject: Record +): WorkItemLike[] { + const byId = workItemsByProject[project.id]; + if (byId?.length) return byId; + if (project.slug) { + const bySlug = workItemsByProject[project.slug]; + if (bySlug?.length) return bySlug; + } + return []; +} + +export function buildWorkspaceProjectTree( + input: BuildTreeInput +): ProjectTreeNode { + const includeTodos = input.includeTodos !== false; + const projectNodes: ProjectTreeNode[] = input.projects.map((project) => { + const items = resolveWorkItems(project, input.workItemsByProject); + return { + id: `project:${project.id}`, + kind: "project" as const, + title: project.name, + status: project.status, + projectId: project.id, + projectSlug: project.slug, + children: items.map((wi) => + mapWorkItemNode(wi, project.id, project.slug, includeTodos) + ), + meta: { + workItemCount: items.length, + slug: project.slug, + }, + }; + }); + + const standalone = input.standaloneWorkItems ?? []; + if (standalone.length > 0) { + projectNodes.push({ + id: "bucket:unassigned", + kind: "unassigned", + title: "Unassigned", + children: standalone.map((wi) => + mapWorkItemNode(wi, undefined, undefined, includeTodos) + ), + meta: { workItemCount: standalone.length }, + }); + } + + return { + id: "workspace:root", + kind: "workspace", + title: input.workspaceName ?? "Workspace", + children: projectNodes, + meta: { + projectCount: input.projects.length, + standaloneCount: standalone.length, + }, + }; +} + +export function flattenTree(root: ProjectTreeNode): ProjectTreeNode[] { + const out: ProjectTreeNode[] = []; + const walk = (node: ProjectTreeNode) => { + out.push(node); + node.children.forEach(walk); + }; + walk(root); + return out; +} + +export function countByKind(root: ProjectTreeNode): Record { + const counts: Record = {}; + for (const node of flattenTree(root)) { + counts[node.kind] = (counts[node.kind] ?? 0) + 1; + } + return counts; +} diff --git a/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts b/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts new file mode 100644 index 0000000000..094b34b4f5 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts @@ -0,0 +1,159 @@ +/** + * Demo fixture so Journey/Tree are usable even when local DB has sparse links. + */ +import type { ProjectLike, WorkItemLike } from "./types"; + +export const DEMO_PROJECT: ProjectLike = { + id: "proj-org2-patch-demo", + name: "ORG2 Patch Demo", + slug: "org2-patch-demo", + status: "in_progress", + description: "Synthetic project for journey/tree smoke demo", +}; + +export const DEMO_WORK_ITEMS: WorkItemLike[] = [ + { + session_id: "WI-DEMO-1", + name: "四级树可感知化", + status: "completed", + workItemStatus: "completed", + spec: "Workspace→Project→WorkItem→Session 树只读聚合", + created_time: "2026-07-28T10:00:00.000Z", + updated_time: "2026-07-28T18:00:00.000Z", + todos: [ + { id: "t1", content: "对齐类型真源", status: "completed" }, + { id: "t2", content: "树聚合 API", status: "completed" }, + { id: "t3", content: "侧栏入口", status: "completed" }, + ], + linkedSessions: [ + { + session_id: "sess-main-tree", + session_type: "native", + agent_role: "implementer", + status: "completed", + started_at: "2026-07-28T10:05:00.000Z", + completed_at: "2026-07-28T12:00:00.000Z", + }, + ], + workProducts: [ + { + id: "wp1", + productType: "document", + path: "docs/org2-patch/PRD.md", + sessionId: "sess-main-tree", + }, + { + id: "wp2", + productType: "file_change", + path: "src/modules/ProjectManager/ProjectJourney/model/buildTree.ts", + sessionId: "sess-main-tree", + }, + ], + proofOfWork: { + diff_stats: { + files: [ + { + path: "src/modules/ProjectManager/ProjectJourney/model/buildTree.ts", + additions: 120, + deletions: 0, + }, + { + path: "docs/org2-patch/PRD.md", + additions: 200, + deletions: 0, + }, + ], + }, + }, + }, + { + session_id: "WI-DEMO-2", + name: "项目旅程思维导图", + status: "in_progress", + workItemStatus: "in_progress", + spec: "主线/分叉/文件闪点/冗余剪枝", + created_time: "2026-07-28T13:00:00.000Z", + updated_time: "2026-07-29T01:00:00.000Z", + todos: [ + { id: "t4", content: "journey graph builder", status: "completed" }, + { id: "t5", content: "file category + blink", status: "in_progress" }, + { id: "t6", content: "prune UX", status: "pending" }, + ], + linkedSessions: [ + { + session_id: "sess-main-journey", + session_type: "native", + agent_role: "implementer", + status: "running", + started_at: "2026-07-28T13:10:00.000Z", + }, + { + session_id: "sess-fork-explore", + session_type: "native", + agent_role: "explorer", + status: "completed", + started_at: "2026-07-28T14:00:00.000Z", + completed_at: "2026-07-28T15:00:00.000Z", + parent_session_id: "sess-main-journey", + sub_agent_name: "explore-alt-layout", + }, + { + session_id: "sess-fork-dead", + session_type: "cli", + agent_role: "experiment", + status: "cancelled", + started_at: "2026-07-28T16:00:00.000Z", + completed_at: "2026-07-28T16:20:00.000Z", + parent_session_id: "sess-main-journey", + sub_agent_name: "dead-end-reactflow", + }, + ], + workProducts: [ + { + id: "wp3", + productType: "file_change", + path: "src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts", + sessionId: "sess-main-journey", + }, + { + id: "wp4", + productType: "screenshot", + path: "reports/org2-patch/journey-preview.png", + sessionId: "sess-main-journey", + }, + { + id: "wp5", + productType: "file_change", + path: "src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts", + sessionId: "sess-fork-explore", + }, + ], + proofOfWork: { + diff_stats: { + files: [ + { + path: "src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts", + additions: 80, + deletions: 12, + }, + { + path: "reports/org2-patch/journey-preview.png", + additions: 1, + deletions: 0, + }, + ], + }, + }, + }, +]; + +export function getDemoTreeBundle() { + return { + projects: [DEMO_PROJECT], + workItemsByProject: { + [DEMO_PROJECT.id]: DEMO_WORK_ITEMS, + [DEMO_PROJECT.slug!]: DEMO_WORK_ITEMS, + }, + standaloneWorkItems: [] as WorkItemLike[], + }; +} diff --git a/src/modules/ProjectManager/ProjectJourney/model/fileCategory.ts b/src/modules/ProjectManager/ProjectJourney/model/fileCategory.ts new file mode 100644 index 0000000000..f5984d7909 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/fileCategory.ts @@ -0,0 +1,124 @@ +/** + * Classify journey files into produced results vs production-source touches. + */ +import type { JourneyFileCategory, WorkProductLike } from "./types"; + +const PRODUCED_EXT = new Set([ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", + ".pdf", + ".html", + ".htm", + ".md", + ".csv", + ".xlsx", + ".json", + ".svg", + ".mp4", + ".webm", +]); + +const PRODUCED_PATH_RE = + /(^|\/)(dist|build|out|output|reports?|artifacts?|release|screenshots?|preview)(\/|$)/i; + +const PRODUCED_NAME_RE = /(report|artifact|screenshot|preview|结果|报告|交付)/i; + +const TOUCHED_EXT = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".rs", + ".go", + ".py", + ".java", + ".kt", + ".swift", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".css", + ".scss", + ".less", + ".vue", + ".svelte", + ".sql", + ".toml", + ".yaml", + ".yml", + ".graphql", + ".proto", +]); + +const TOUCHED_PATH_RE = + /(^|\/)(src|lib|app|packages|crates|services|backend|frontend|server|client)(\/|$)/i; + +const PRODUCED_PRODUCT_TYPES = new Set([ + "screenshot", + "document", + "preview", + "deployment", + "validation", + "risk_note", +]); + +const TOUCHED_PRODUCT_TYPES = new Set(["file_change", "branch", "commit"]); + +function extname(path: string): string { + const base = path.split(/[\\/]/).pop() ?? path; + const idx = base.lastIndexOf("."); + if (idx <= 0) return ""; + return base.slice(idx).toLowerCase(); +} + +export function classifyPath(path: string): JourneyFileCategory { + const normalized = path.replace(/\\/g, "/"); + const ext = extname(normalized); + const base = normalized.split("/").pop() ?? normalized; + + if (PRODUCED_PATH_RE.test(normalized) || PRODUCED_NAME_RE.test(base)) { + return "produced"; + } + if (PRODUCED_EXT.has(ext) && !TOUCHED_PATH_RE.test(normalized)) { + return "produced"; + } + if (TOUCHED_PATH_RE.test(normalized) || TOUCHED_EXT.has(ext)) { + return "touched_production"; + } + if (PRODUCED_EXT.has(ext)) return "produced"; + return "other"; +} + +export function classifyWorkProduct( + product: WorkProductLike +): JourneyFileCategory { + const type = (product.productType ?? product.type ?? "").toLowerCase(); + if (PRODUCED_PRODUCT_TYPES.has(type)) return "produced"; + if (TOUCHED_PRODUCT_TYPES.has(type)) return "touched_production"; + const path = + product.path ?? product.uri ?? product.url ?? product.title ?? ""; + if (path) return classifyPath(path); + return "other"; +} + +export function workProductPath(product: WorkProductLike): string | null { + const raw = + product.path ?? + product.uri ?? + product.url ?? + (typeof product.metadata?.path === "string" + ? product.metadata.path + : null) ?? + product.title ?? + null; + if (!raw || typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/modules/ProjectManager/ProjectJourney/model/index.ts b/src/modules/ProjectManager/ProjectJourney/model/index.ts new file mode 100644 index 0000000000..7f84bdc738 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export * from "./fileCategory"; +export * from "./buildTree"; +export * from "./buildJourney"; +export * from "./journeyState"; +export * from "./demoFixture"; diff --git a/src/modules/ProjectManager/ProjectJourney/model/journeyState.ts b/src/modules/ProjectManager/ProjectJourney/model/journeyState.ts new file mode 100644 index 0000000000..29bd9e04d0 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/journeyState.ts @@ -0,0 +1,93 @@ +/** + * Persist journey narrative state (pins / prunes) in localStorage. + */ +import { + JOURNEY_STATE_KEY_PREFIX, + JOURNEY_STATE_VERSION, + type ProjectJourneyState, +} from "./types"; + +export function journeyStateKey(projectId: string): string { + return `${JOURNEY_STATE_KEY_PREFIX}${projectId}`; +} + +export function emptyJourneyState(projectId: string): ProjectJourneyState { + return { + version: JOURNEY_STATE_VERSION, + projectId, + pinnedMainlineNodeIds: [], + prunedNodeIds: [], + prunedEdgeIds: [], + updatedAt: new Date().toISOString(), + }; +} + +export function loadJourneyState( + projectId: string, + storage: Pick | null = typeof localStorage !== "undefined" + ? localStorage + : null +): ProjectJourneyState { + if (!storage) return emptyJourneyState(projectId); + try { + const raw = storage.getItem(journeyStateKey(projectId)); + if (!raw) return emptyJourneyState(projectId); + const parsed = JSON.parse(raw) as ProjectJourneyState; + if (parsed?.version !== JOURNEY_STATE_VERSION || !parsed.projectId) { + return emptyJourneyState(projectId); + } + return { + version: JOURNEY_STATE_VERSION, + projectId, + pinnedMainlineNodeIds: parsed.pinnedMainlineNodeIds ?? [], + prunedNodeIds: parsed.prunedNodeIds ?? [], + prunedEdgeIds: parsed.prunedEdgeIds ?? [], + updatedAt: parsed.updatedAt ?? new Date().toISOString(), + }; + } catch { + return emptyJourneyState(projectId); + } +} + +export function saveJourneyState( + state: ProjectJourneyState, + storage: Pick | null = typeof localStorage !== "undefined" + ? localStorage + : null +): ProjectJourneyState { + const next: ProjectJourneyState = { + ...state, + version: JOURNEY_STATE_VERSION, + updatedAt: new Date().toISOString(), + }; + if (storage) { + storage.setItem(journeyStateKey(state.projectId), JSON.stringify(next)); + } + return next; +} + +export function togglePinNode( + state: ProjectJourneyState, + nodeId: string +): ProjectJourneyState { + const set = new Set(state.pinnedMainlineNodeIds); + if (set.has(nodeId)) set.delete(nodeId); + else set.add(nodeId); + return saveJourneyState({ + ...state, + pinnedMainlineNodeIds: [...set], + }); +} + +export function togglePruneNode( + state: ProjectJourneyState, + nodeId: string +): ProjectJourneyState { + const set = new Set(state.prunedNodeIds); + if (set.has(nodeId)) set.delete(nodeId); + else set.add(nodeId); + return saveJourneyState({ + ...state, + prunedNodeIds: [...set], + }); +} diff --git a/src/modules/ProjectManager/ProjectJourney/model/types.ts b/src/modules/ProjectManager/ProjectJourney/model/types.ts new file mode 100644 index 0000000000..a2abfff6c2 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/types.ts @@ -0,0 +1,168 @@ +/** + * Project Journey / Tree — shared types (ORG2-patch MVP) + * + * Narrative layer only. Does NOT own project/session membership truth. + * Truth remains: Project → WorkItem → linkedSessions (+ todos as fine tasks). + */ + +export type TreeNodeKind = + | "workspace" + | "project" + | "work_item" + | "todo" + | "session" + | "unassigned"; + +export interface ProjectTreeNode { + id: string; + kind: TreeNodeKind; + title: string; + status?: string; + projectId?: string; + projectSlug?: string; + workItemId?: string; + sessionId?: string; + children: ProjectTreeNode[]; + meta?: Record; +} + +export type JourneyNodeStatus = "todo" | "doing" | "done" | "abandoned"; + +export type JourneyFileCategory = "produced" | "touched_production" | "other"; + +export interface JourneyFileRef { + path: string; + category: JourneyFileCategory; + sessionIds: string[]; + workItemIds: string[]; + additions?: number; + deletions?: number; + source?: "work_product" | "proof_of_work" | "orgtrack" | "todo" | "inferred"; +} + +export interface JourneyNode { + id: string; + title: string; + summary: string; + status: JourneyNodeStatus; + workItemIds: string[]; + sessionIds: string[]; + resultFileRefs: JourneyFileRef[]; + isMainline: boolean; + suggestedMainline: boolean; + parentNodeId?: string; + branchOf?: string; + redundancyScore: number; + pruned: boolean; + kind: "work_item" | "session" | "todo_cluster" | "fork"; + startedAt?: string; + completedAt?: string; +} + +export interface JourneyEdge { + id: string; + from: string; + to: string; + kind: "main" | "fork" | "file"; + pruned: boolean; + weight?: number; +} + +export interface ProjectJourneyGraph { + projectId: string; + projectSlug?: string; + projectName: string; + nodes: JourneyNode[]; + edges: JourneyEdge[]; + files: JourneyFileRef[]; + mainlineProgress: number; + stats: { + workItemCount: number; + sessionCount: number; + producedFileCount: number; + touchedProductionFileCount: number; + redundantNodeCount: number; + prunedNodeCount: number; + }; +} + +export interface ProjectJourneyState { + version: 1; + projectId: string; + pinnedMainlineNodeIds: string[]; + prunedNodeIds: string[]; + prunedEdgeIds: string[]; + updatedAt: string; +} + +export interface LinkedSessionLike { + session_id: string; + session_type?: string; + agent_role?: string; + status?: string; + started_at?: string; + completed_at?: string; + parent_session_id?: string | null; + sub_agent_name?: string | null; + cost_usd?: number | null; + total_tokens?: number | null; +} + +export interface TodoLike { + id: string; + content: string; + status: string; +} + +export interface WorkProductLike { + id?: string; + productType?: string; + type?: string; + title?: string; + path?: string; + uri?: string; + url?: string; + status?: string; + sessionId?: string; + metadata?: Record | null; +} + +export interface FileChangeLike { + path: string; + additions?: number; + deletions?: number; + status?: string; +} + +export interface WorkItemLike { + session_id: string; + name: string; + status?: string; + workItemStatus?: string; + spec?: string; + project?: { id: string; name: string } | null; + linkedSessions?: LinkedSessionLike[]; + todos?: TodoLike[]; + workProducts?: WorkProductLike[]; + proofOfWork?: { + diff_stats?: { + files?: FileChangeLike[]; + }; + diffStats?: { + files?: FileChangeLike[]; + }; + } | null; + created_time?: string; + updated_time?: string; +} + +export interface ProjectLike { + id: string; + name: string; + slug?: string; + status?: string; + description?: string; +} + +export const JOURNEY_STATE_VERSION = 1 as const; +export const JOURNEY_STATE_KEY_PREFIX = "org2.projectJourney.v1:"; diff --git a/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx b/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx index 1cf6fad5c4..6187dced7a 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx +++ b/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx @@ -168,6 +168,8 @@ function renderActiveContent({ case "project-org": case "project-org-settings": case "project-settings": + case "project-tree": + case "project-journey": case "workItem-detail": return ; diff --git a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectManagerSidebarConfig.tsx b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectManagerSidebarConfig.tsx index 5211994f61..0e589a0972 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectManagerSidebarConfig.tsx +++ b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectManagerSidebarConfig.tsx @@ -34,6 +34,8 @@ interface UseProjectManagerSidebarConfigOptions { onImportOrgs: () => void; onOpenProjects: () => void; onOpenWorkItems: () => void; + onOpenProjectTree: () => void; + onOpenProjectJourney: () => void; onOpenPersonalOrg: (view?: ProjectOrgSurfaceView) => void; onOpenProjectOrg: (org: ProjectOrg, view?: ProjectOrgSurfaceView) => void; onOpenLinearProjects: (selection?: LinearProjectSelection) => void; @@ -62,6 +64,8 @@ export function useProjectManagerSidebarConfig({ onImportOrgs, onOpenProjects, onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, onOpenPersonalOrg, onOpenProjectOrg, onOpenLinearProjects, @@ -106,6 +110,8 @@ export function useProjectManagerSidebarConfig({ onImportOrgs={onImportOrgs} onOpenProjects={onOpenProjects} onOpenWorkItems={onOpenWorkItems} + onOpenProjectTree={onOpenProjectTree} + onOpenProjectJourney={onOpenProjectJourney} onOpenPersonalOrg={onOpenPersonalOrg} onOpenProjectOrg={onOpenProjectOrg} onOpenLinearProjects={onOpenLinearProjects} @@ -138,6 +144,8 @@ export function useProjectManagerSidebarConfig({ onImportOrgs, onOpenProjects, onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, onOpenPersonalOrg, onOpenProjectOrg, onOpenLinearProjects, @@ -226,6 +234,10 @@ function getActiveRepoView(activeTab: WorkStationTab | null): ActiveRepoView { return "projects"; case "project-work-items": return "work-items"; + case "project-tree": + return "project-tree"; + case "project-journey": + return "project-journey"; case "project-linear-projects": return "linear-projects"; case "project-linear-work-items": diff --git a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts index 0f809e0007..7929cc2559 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts +++ b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts @@ -36,10 +36,12 @@ import { STORY_PERSONAL_ORG_FILTER_ID, STORY_PERSONAL_ORG_NAME, createProjectDashboardTab, + createProjectJourneyTab, createProjectLinearProjectsTab, createProjectLinearWorkItemsTab, createProjectOrgTab, createProjectSettingsTab, + createProjectTreeTab, createProjectWorkItemsIndexTab, createProjectWorkItemsTab, createWorkItemDetailTab, @@ -251,6 +253,24 @@ export function useProjectTabActions({ ); }, [navigateWorkspaceTab]); + const handleOpenProjectTree = useCallback(() => { + navigateWorkspaceTab(createProjectTreeTab()); + }, [navigateWorkspaceTab]); + + const handleOpenProjectJourney = useCallback( + (projectId?: string, projectSlug?: string, projectName?: string) => { + navigateWorkspaceTab( + createProjectJourneyTab({ + projectId, + projectSlug, + projectName, + forceDemo: !projectId && !projectSlug, + }) + ); + }, + [navigateWorkspaceTab] + ); + const handleOpenPersonalOrg = useCallback( (view: ProjectOrgSurfaceView = PROJECT_ORG_SURFACE_VIEW.WORK_ITEMS) => { navigateWorkspaceTab( @@ -448,6 +468,8 @@ export function useProjectTabActions({ handleCreateWorkItem, handleOpenProjects, handleOpenWorkItems, + handleOpenProjectTree, + handleOpenProjectJourney, handleOpenPersonalOrg, handleOpenProjectOrg, handleOpenPersonalOrgProjects, diff --git a/src/modules/ProjectManager/ProjectManagerLayout/index.tsx b/src/modules/ProjectManager/ProjectManagerLayout/index.tsx index c0309e9be3..38e08911b0 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/index.tsx +++ b/src/modules/ProjectManager/ProjectManagerLayout/index.tsx @@ -153,6 +153,8 @@ export const ProjectManagerLayout: React.FC = memo( handleCreateWorkItem, handleOpenProjects, handleOpenWorkItems, + handleOpenProjectTree, + handleOpenProjectJourney, handleOpenPersonalOrg, handleOpenProjectOrg, handleOpenLinearProjects, @@ -257,6 +259,8 @@ export const ProjectManagerLayout: React.FC = memo( onImportOrgs: handleImportOrgs, onOpenProjects: handleOpenProjects, onOpenWorkItems: handleOpenWorkItems, + onOpenProjectTree: handleOpenProjectTree, + onOpenProjectJourney: handleOpenProjectJourney, onOpenPersonalOrg: handleOpenPersonalOrg, onOpenProjectOrg: handleOpenProjectOrg, onOpenLinearProjects: handleOpenLinearProjects, diff --git a/src/modules/ProjectManager/ProjectManagerLayout/types.ts b/src/modules/ProjectManager/ProjectManagerLayout/types.ts index b4b2284fbb..e13f52cd2c 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/types.ts +++ b/src/modules/ProjectManager/ProjectManagerLayout/types.ts @@ -12,6 +12,8 @@ export interface ProjectManagerLayoutProps { export type ActiveRepoView = | "projects" | "work-items" + | "project-tree" + | "project-journey" | "linear-projects" | "linear-work-items" | "settings" diff --git a/src/modules/WorkStation/TabContent/registry.ts b/src/modules/WorkStation/TabContent/registry.ts index ef3a6f5874..8519dd94ef 100644 --- a/src/modules/WorkStation/TabContent/registry.ts +++ b/src/modules/WorkStation/TabContent/registry.ts @@ -203,6 +203,16 @@ const ProjectGitSyncReviewEntry: RendererEntry = { debugLabel: "project-git-sync-review", }; +const ProjectTreeEntry: RendererEntry = { + Component: lazy(() => import("./renderers/projectTree")), + debugLabel: "project-tree", +}; + +const ProjectJourneyEntry: RendererEntry = { + Component: lazy(() => import("./renderers/projectJourney")), + debugLabel: "project-journey", +}; + const WorkItemDetailEntry: RendererEntry = { Component: lazy(() => import("./renderers/workItemDetail")), debugLabel: "workItem-detail", @@ -285,6 +295,8 @@ export const REGISTRY: TabContentRegistry = { "project-org": ProjectOrgEntry, "project-org-settings": ProjectOrgSettingsEntry, "project-git-sync-review": ProjectGitSyncReviewEntry, + "project-tree": ProjectTreeEntry, + "project-journey": ProjectJourneyEntry, "project-workitems": ProjectWorkitemsEntry, "workItem-detail": WorkItemDetailEntry, "chat-session": ChatSessionEntry, diff --git a/src/modules/WorkStation/TabContent/renderers/index.ts b/src/modules/WorkStation/TabContent/renderers/index.ts index a7bc74b1e7..c3e93775e6 100644 --- a/src/modules/WorkStation/TabContent/renderers/index.ts +++ b/src/modules/WorkStation/TabContent/renderers/index.ts @@ -19,6 +19,8 @@ export { default as ProjectOrgTabRenderer } from "./projectOrg"; export { default as ProjectOrgSettingsTabRenderer } from "./projectOrgSettings"; export { default as ProjectSettingsTabRenderer } from "./projectSettings"; export { default as ProjectWorkItemsTabRenderer } from "./projectWorkItems"; +export { default as ProjectTreeTabRenderer } from "./projectTree"; +export { default as ProjectJourneyTabRenderer } from "./projectJourney"; export { default as ProjectWorkitemsCompatTabRenderer } from "./projectWorkitemsCompat"; export { default as SearchTabRenderer } from "./search"; export { default as SettingsTabRenderer } from "./settings"; diff --git a/src/modules/WorkStation/TabContent/renderers/projectJourney.tsx b/src/modules/WorkStation/TabContent/renderers/projectJourney.tsx new file mode 100644 index 0000000000..f7ea4d53b2 --- /dev/null +++ b/src/modules/WorkStation/TabContent/renderers/projectJourney.tsx @@ -0,0 +1,24 @@ +/** + * Renderer for `project-journey` tabs (ORG2-patch). + */ +import React, { memo } from "react"; + +import { ProjectJourneyPage } from "@src/modules/ProjectManager/ProjectJourney"; + +import type { UnifiedTabContentProps } from "../types"; + +const ProjectJourneyTabRenderer: React.FC = memo( + ({ tab }) => { + return ( + + ); + } +); + +ProjectJourneyTabRenderer.displayName = "ProjectJourneyTabRenderer"; +export default ProjectJourneyTabRenderer; diff --git a/src/modules/WorkStation/TabContent/renderers/projectTree.tsx b/src/modules/WorkStation/TabContent/renderers/projectTree.tsx new file mode 100644 index 0000000000..0c46ce2866 --- /dev/null +++ b/src/modules/WorkStation/TabContent/renderers/projectTree.tsx @@ -0,0 +1,70 @@ +/** + * Renderer for `project-tree` tabs (ORG2-patch). + */ +import { useSetAtom } from "jotai"; +import React, { memo, useCallback } from "react"; + +import { ProjectTreePage } from "@src/modules/ProjectManager/ProjectJourney"; +import { + createProjectJourneyTab, + createWorkItemDetailTab, + workstationLayoutAtom, +} from "@src/store/workstation/tabs"; +import type { WorkStationTab } from "@src/store/workstation/tabs/types"; + +import type { UnifiedTabContentProps } from "../types"; + +function useOpenWorkStationTab() { + const setLayout = useSetAtom(workstationLayoutAtom); + return useCallback( + (tab: WorkStationTab) => { + setLayout((prev) => { + const exists = prev.mainPane.tabs.some((item) => item.id === tab.id); + const tabs = exists + ? prev.mainPane.tabs.map((item) => + item.id === tab.id + ? { ...item, ...tab, data: { ...item.data, ...tab.data } } + : item + ) + : [...prev.mainPane.tabs, tab]; + return { + ...prev, + mainPane: { ...prev.mainPane, tabs, activeTabId: tab.id }, + }; + }); + }, + [setLayout] + ); +} + +const ProjectTreeTabRenderer: React.FC = memo(() => { + const openTab = useOpenWorkStationTab(); + + return ( + { + openTab( + createProjectJourneyTab({ + projectId, + projectSlug, + projectName, + }) + ); + }} + onOpenWorkItem={(workItemId, projectSlug) => { + openTab( + createWorkItemDetailTab( + undefined, + undefined, + workItemId, + workItemId, + projectSlug + ) + ); + }} + /> + ); +}); + +ProjectTreeTabRenderer.displayName = "ProjectTreeTabRenderer"; +export default ProjectTreeTabRenderer; diff --git a/src/store/workstation/tabs/__tests__/workspaceState.test.ts b/src/store/workstation/tabs/__tests__/workspaceState.test.ts index 023d3f38b7..d27a1d6181 100644 --- a/src/store/workstation/tabs/__tests__/workspaceState.test.ts +++ b/src/store/workstation/tabs/__tests__/workspaceState.test.ts @@ -60,6 +60,8 @@ const EXPECTED_OWNERSHIP: Record = "project-org": "shared-resource", "project-org-settings": "shared-resource", "project-git-sync-review": "shared-resource", + "project-tree": "shared-resource", + "project-journey": "shared-resource", "project-workitems": "shared-resource", "workItem-detail": "shared-resource", "chat-session": "shared-resource", @@ -119,7 +121,7 @@ describe("WorkStation tab ownership policy", () => { }) ); - expect(results).toHaveLength(39); + expect(results).toHaveLength(41); expect(results.every(({ actual, expected }) => actual === expected)).toBe( true ); diff --git a/src/store/workstation/tabs/factories/index.ts b/src/store/workstation/tabs/factories/index.ts index 2f01edf2ae..66266f3e75 100644 --- a/src/store/workstation/tabs/factories/index.ts +++ b/src/store/workstation/tabs/factories/index.ts @@ -99,6 +99,8 @@ export { projectOrgTabFactory, projectGitSyncReviewTabFactory, projectWorkItemsTabFactory, + projectTreeTabFactory, + projectJourneyTabFactory, workItemDetailTabFactory, createProjectDashboardTab, createProjectWorkItemsIndexTab, @@ -111,6 +113,8 @@ export { normalizeProjectDetailSurfaceView, createProjectGitSyncReviewTab, createProjectWorkItemsTab, + createProjectTreeTab, + createProjectJourneyTab, createWorkItemDetailTab, getProjectLinearProjectsTabChrome, getProjectLinearWorkItemsTabChrome, @@ -129,6 +133,7 @@ export type { ProjectGitSyncReviewTabData, ProjectWorkItemsTabData, WorkItemDetailTabData, + ProjectJourneyTabData, } from "./project"; export type { NewWorkItemTabData } from "../types"; diff --git a/src/store/workstation/tabs/factories/project.ts b/src/store/workstation/tabs/factories/project.ts index a3d7bc225e..5be2620383 100644 --- a/src/store/workstation/tabs/factories/project.ts +++ b/src/store/workstation/tabs/factories/project.ts @@ -98,6 +98,16 @@ export function resolveProjectManagerTabTitle( return `${orgName} ${workItemsLabel}`; } + if (tab.type === "project-tree") { + return translate("projects:workspace.projectTree"); + } + + if (tab.type === "project-journey") { + const name = tab.data.projectName as string | undefined; + const journeyLabel = translate("projects:workspace.projectJourney"); + return name ? `${name} ${journeyLabel}` : journeyLabel; + } + if ( tab.type === "project-linear-projects" || tab.type === "project-linear-work-items" @@ -507,3 +517,45 @@ export function createWorkItemDetailTab( ...(returnTabId && { returnTabId }), }); } + +// ============================================ +// ORG2-patch: Project Tree + Journey +// ============================================ + +export interface ProjectJourneyTabData { + projectId?: string; + projectSlug?: string; + projectName?: string; + forceDemo?: boolean; +} + +export const projectTreeTabFactory = defineTabFactory>({ + tabType: "project-tree", + idStrategy: { type: "singleton", id: "project-tree:main" }, + getTitle: () => "Project Tree", + icon: "FolderTree", +}); + +export function createProjectTreeTab(): WorkStationTab { + return projectTreeTabFactory({}); +} + +export const projectJourneyTabFactory = defineTabFactory( + { + tabType: "project-journey", + idStrategy: { + type: "keyed", + prefix: "project-journey", + getKey: (data) => data.projectId || data.projectSlug || "main", + }, + getTitle: (data) => + data.projectName ? `${data.projectName} Journey` : "Project Journey", + icon: "GitFork", + } +); + +export function createProjectJourneyTab( + data: ProjectJourneyTabData = {} +): WorkStationTab { + return projectJourneyTabFactory(data); +} diff --git a/src/store/workstation/tabs/index.ts b/src/store/workstation/tabs/index.ts index 6ee356b49d..d7b973b536 100644 --- a/src/store/workstation/tabs/index.ts +++ b/src/store/workstation/tabs/index.ts @@ -166,6 +166,8 @@ export { projectOrgTabFactory, projectGitSyncReviewTabFactory, projectWorkItemsTabFactory, + projectTreeTabFactory, + projectJourneyTabFactory, workItemDetailTabFactory, createProjectDashboardTab, createProjectWorkItemsIndexTab, @@ -178,6 +180,8 @@ export { normalizeProjectDetailSurfaceView, createProjectGitSyncReviewTab, createProjectWorkItemsTab, + createProjectTreeTab, + createProjectJourneyTab, createWorkItemDetailTab, getProjectLinearProjectsTabChrome, getProjectLinearWorkItemsTabChrome, @@ -274,3 +278,5 @@ export { clearAllEditorCacheAtom, switchActiveRepoAtom, } from "./editorCache"; + +export type { ProjectJourneyTabData } from "./factories/project"; diff --git a/src/store/workstation/tabs/tabFactory.ts b/src/store/workstation/tabs/tabFactory.ts index aa9b43d3eb..fc234435c4 100644 --- a/src/store/workstation/tabs/tabFactory.ts +++ b/src/store/workstation/tabs/tabFactory.ts @@ -94,6 +94,8 @@ const DEFAULT_CATEGORY_BY_TYPE: Record< devtools: "browser", "project-dashboard": "project", "project-work-items": "project", + "project-tree": "project", + "project-journey": "project", "project-linear-projects": "project", "project-linear-work-items": "project", "project-settings": "project", diff --git a/src/store/workstation/tabs/types.ts b/src/store/workstation/tabs/types.ts index 88d578d5fa..65f20577d7 100644 --- a/src/store/workstation/tabs/types.ts +++ b/src/store/workstation/tabs/types.ts @@ -49,6 +49,8 @@ export type WorkStationTabType = | "project-org" | "project-org-settings" | "project-git-sync-review" + | "project-tree" + | "project-journey" | "project-workitems" | "workItem-detail" | "chat-session" @@ -262,6 +264,8 @@ export function getWorkstationTabOwnership( case "project-org": case "project-org-settings": case "project-git-sync-review": + case "project-tree": + case "project-journey": case "project-workitems": case "workItem-detail": case "chat-session": @@ -519,6 +523,8 @@ export const TOOL_TAB_TYPES = [ "project-settings", "project-org", "project-org-settings", + "project-tree", + "project-journey", "project-workitems", "workItem-detail", "chat-session", From 5a37fd1b34c27c27d3fb8cf8a88d41ad4bbb9401 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Wed, 29 Jul 2026 12:43:51 +0800 Subject: [PATCH 04/32] chore(project): remove patch branding from merged tree and journey (cherry picked from commit 33873100767d81cb2682f91be6653631568b1f3a) --- .../__tests__/projectJourney.test.ts | 2 +- .../ProjectJourney/model/demoFixture.ts | 14 +++++++------- .../ProjectManager/ProjectJourney/model/types.ts | 2 +- .../TabContent/renderers/projectJourney.tsx | 2 +- .../TabContent/renderers/projectTree.tsx | 2 +- src/store/workstation/tabs/factories/project.ts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts b/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts index 075991a334..43aae6b1fb 100644 --- a/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts +++ b/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts @@ -18,7 +18,7 @@ import { describe("classifyPath", () => { it("classifies produced vs production sources", () => { - expect(classifyPath("docs/org2-patch/PRD.md")).toBe("produced"); + expect(classifyPath("docs/project-journey/PRD.md")).toBe("produced"); expect(classifyPath("reports/a/report.html")).toBe("produced"); expect( classifyPath( diff --git a/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts b/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts index 094b34b4f5..a7834a24bb 100644 --- a/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts +++ b/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts @@ -4,9 +4,9 @@ import type { ProjectLike, WorkItemLike } from "./types"; export const DEMO_PROJECT: ProjectLike = { - id: "proj-org2-patch-demo", - name: "ORG2 Patch Demo", - slug: "org2-patch-demo", + id: "proj-project-journey-demo", + name: "ORG2 Project Journey Demo", + slug: "project-journey-demo", status: "in_progress", description: "Synthetic project for journey/tree smoke demo", }; @@ -39,7 +39,7 @@ export const DEMO_WORK_ITEMS: WorkItemLike[] = [ { id: "wp1", productType: "document", - path: "docs/org2-patch/PRD.md", + path: "docs/project-journey/PRD.md", sessionId: "sess-main-tree", }, { @@ -58,7 +58,7 @@ export const DEMO_WORK_ITEMS: WorkItemLike[] = [ deletions: 0, }, { - path: "docs/org2-patch/PRD.md", + path: "docs/project-journey/PRD.md", additions: 200, deletions: 0, }, @@ -118,7 +118,7 @@ export const DEMO_WORK_ITEMS: WorkItemLike[] = [ { id: "wp4", productType: "screenshot", - path: "reports/org2-patch/journey-preview.png", + path: "reports/project-journey/journey-preview.png", sessionId: "sess-main-journey", }, { @@ -137,7 +137,7 @@ export const DEMO_WORK_ITEMS: WorkItemLike[] = [ deletions: 12, }, { - path: "reports/org2-patch/journey-preview.png", + path: "reports/project-journey/journey-preview.png", additions: 1, deletions: 0, }, diff --git a/src/modules/ProjectManager/ProjectJourney/model/types.ts b/src/modules/ProjectManager/ProjectJourney/model/types.ts index a2abfff6c2..922640c096 100644 --- a/src/modules/ProjectManager/ProjectJourney/model/types.ts +++ b/src/modules/ProjectManager/ProjectJourney/model/types.ts @@ -1,5 +1,5 @@ /** - * Project Journey / Tree — shared types (ORG2-patch MVP) + * Project Journey / Tree — shared types * * Narrative layer only. Does NOT own project/session membership truth. * Truth remains: Project → WorkItem → linkedSessions (+ todos as fine tasks). diff --git a/src/modules/WorkStation/TabContent/renderers/projectJourney.tsx b/src/modules/WorkStation/TabContent/renderers/projectJourney.tsx index f7ea4d53b2..fbc2f5d22a 100644 --- a/src/modules/WorkStation/TabContent/renderers/projectJourney.tsx +++ b/src/modules/WorkStation/TabContent/renderers/projectJourney.tsx @@ -1,5 +1,5 @@ /** - * Renderer for `project-journey` tabs (ORG2-patch). + * Renderer for `project-journey` tabs. */ import React, { memo } from "react"; diff --git a/src/modules/WorkStation/TabContent/renderers/projectTree.tsx b/src/modules/WorkStation/TabContent/renderers/projectTree.tsx index 0c46ce2866..7d9b6c8f7f 100644 --- a/src/modules/WorkStation/TabContent/renderers/projectTree.tsx +++ b/src/modules/WorkStation/TabContent/renderers/projectTree.tsx @@ -1,5 +1,5 @@ /** - * Renderer for `project-tree` tabs (ORG2-patch). + * Renderer for `project-tree` tabs. */ import { useSetAtom } from "jotai"; import React, { memo, useCallback } from "react"; diff --git a/src/store/workstation/tabs/factories/project.ts b/src/store/workstation/tabs/factories/project.ts index 5be2620383..5d586856bc 100644 --- a/src/store/workstation/tabs/factories/project.ts +++ b/src/store/workstation/tabs/factories/project.ts @@ -519,7 +519,7 @@ export function createWorkItemDetailTab( } // ============================================ -// ORG2-patch: Project Tree + Journey +// Project Tree + Journey // ============================================ export interface ProjectJourneyTabData { From 510db493899d3b1d39a813b6f7f51a7f82581225 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Wed, 29 Jul 2026 12:57:26 +0800 Subject: [PATCH 05/32] fix(project): require explicit demo data for journey views (cherry picked from commit 74fdc1588034ad093c6b873f5f0fa52d6fc71e7d) --- .../ProjectJourney/ProjectJourneyPage.tsx | 37 +++++++++------- .../ProjectJourney/loadProjectTree.ts | 44 +++++++------------ .../hooks/useProjectTabActions.ts | 1 - 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx index 6c5cc55f02..f821ad2a6e 100644 --- a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx +++ b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx @@ -19,11 +19,9 @@ import { type ProjectLike, type WorkItemLike, buildProjectJourneyGraph, - emptyJourneyState, filterFiles, loadJourneyState, nodesTouchedByFile, - saveJourneyState, togglePinNode, togglePruneNode, } from "./model"; @@ -93,13 +91,19 @@ const ProjectJourneyPage: React.FC = ({ const match = projects.find((p) => p.id === projectId) || projects.find((p) => p.slug === projectSlug) || - projects.find((p) => p.id === DEMO_PROJECT.id) || - projects[0] || - DEMO_PROJECT; + projects[0]; + if (!match) { + setProject(null); + setWorkItems([]); + setState(null); + setGraph(null); + if (bundle.error) setError(bundle.error); + return; + } const items = bundle.workItemsByProject[match.id] || bundle.workItemsByProject[match.slug ?? ""] || - (match.id === DEMO_PROJECT.id ? DEMO_WORK_ITEMS : []); + []; const st = loadJourneyState(match.id); setProject(match); setWorkItems(items); @@ -107,11 +111,11 @@ const ProjectJourneyPage: React.FC = ({ if (bundle.error) setError(bundle.error); } catch (e) { setError(e instanceof Error ? e.message : String(e)); - const st = loadJourneyState(DEMO_PROJECT.id); - setProject(DEMO_PROJECT); - setWorkItems(DEMO_WORK_ITEMS); - setUsedDemo(true); - rebuild(DEMO_PROJECT, DEMO_WORK_ITEMS, st); + setProject(null); + setWorkItems([]); + setState(null); + setGraph(null); + setUsedDemo(false); } finally { setLoading(false); } @@ -203,6 +207,13 @@ const ProjectJourneyPage: React.FC = ({
Loading journey…
)} + {!loading && !graph && ( +
+ No project data yet. Refresh after creating a project, or choose Demo + explicitly to preview the journey view. +
+ )} + {!loading && graph && (
@@ -435,8 +446,4 @@ function NodeCard({ ); } -// silence unused import if tree shaker complains in some builds -void saveJourneyState; -void emptyJourneyState; - export default ProjectJourneyPage; diff --git a/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts index a90416cfc3..20c045452c 100644 --- a/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts +++ b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts @@ -113,45 +113,33 @@ export async function loadProjectTreeBundle(options?: { standaloneWorkItems = []; } - const hasLinks = Object.values(workItemsByProject).some((items) => - items.some( - (wi) => - (wi.linkedSessions?.length ?? 0) > 0 || (wi.todos?.length ?? 0) > 0 - ) - ); - - if (projects.length === 0 && standaloneWorkItems.length === 0) { - return loadProjectTreeBundle({ - forceDemo: true, - workspaceName: options?.workspaceName, - }); - } - - const finalProjects = hasLinks ? projects : [...projects, DEMO_PROJECT]; - if (!hasLinks) { - workItemsByProject[DEMO_PROJECT.id] = DEMO_WORK_ITEMS; - workItemsByProject[DEMO_PROJECT.slug!] = DEMO_WORK_ITEMS; - } - + // The tree is a projection of project truth. Empty or sparse real data must + // stay visibly empty; synthetic records are available only through the + // caller's explicit `forceDemo` action. return { tree: buildWorkspaceProjectTree({ workspaceName: options?.workspaceName ?? "Workspace", - projects: finalProjects, + projects, workItemsByProject, standaloneWorkItems, }), - projects: finalProjects, + projects, workItemsByProject, standaloneWorkItems, - usedDemo: !hasLinks, + usedDemo: false, }; } catch (error) { - const demo = await loadProjectTreeBundle({ - forceDemo: true, - workspaceName: options?.workspaceName, - }); return { - ...demo, + tree: buildWorkspaceProjectTree({ + workspaceName: options?.workspaceName ?? "Workspace", + projects: [], + workItemsByProject: {}, + standaloneWorkItems: [], + }), + projects: [], + workItemsByProject: {}, + standaloneWorkItems: [], + usedDemo: false, error: error instanceof Error ? error.message : String(error), }; } diff --git a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts index 7929cc2559..6cc4e7e701 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts +++ b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts @@ -264,7 +264,6 @@ export function useProjectTabActions({ projectId, projectSlug, projectName, - forceDemo: !projectId && !projectSlug, }) ); }, From 55951d2db87ec6c1062de603acaa7fea7ab42b8e Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Thu, 30 Jul 2026 09:51:32 +0800 Subject: [PATCH 06/32] feat(p1): add canonical journey graph projector (cherry picked from commit 047ca476ba5938840a340d5681447fd2186a61c7) --- src-tauri/crates/orgtrack-graph/src/audit.rs | 47 ++ .../crates/orgtrack-graph/src/journey.rs | 459 ++++++++++++++++++ .../orgtrack-graph/src/journey_tests.rs | 129 +++++ src-tauri/crates/orgtrack-graph/src/lib.rs | 7 + src-tauri/crates/orgtrack-sync/src/records.rs | 10 + 5 files changed, 652 insertions(+) create mode 100644 src-tauri/crates/orgtrack-graph/src/audit.rs create mode 100644 src-tauri/crates/orgtrack-graph/src/journey.rs create mode 100644 src-tauri/crates/orgtrack-graph/src/journey_tests.rs diff --git a/src-tauri/crates/orgtrack-graph/src/audit.rs b/src-tauri/crates/orgtrack-graph/src/audit.rs new file mode 100644 index 0000000000..dd3ab2aa6d --- /dev/null +++ b/src-tauri/crates/orgtrack-graph/src/audit.rs @@ -0,0 +1,47 @@ +use crate::journey::{CanonicalJourneyInput, CoverageEntry, CoverageStatus, JourneyGraph}; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct JourneyAuditReport { + pub coverage: Vec, + pub trustworthy: bool, +} + +/// Re-enumerates canonical input units; projector coverage labels are intentionally ignored. +pub fn audit_canonical_journey( + input: &CanonicalJourneyInput, + graph: &JourneyGraph, +) -> JourneyAuditReport { + let represented: HashSet<_> = graph + .nodes + .iter() + .map(|n| n.source_ref.as_str()) + .chain(graph.edges.iter().map(|e| e.source_ref.as_str())) + .collect(); + let sources = input + .projects + .iter() + .map(|x| x.source_ref.as_str()) + .chain(input.work_items.iter().map(|x| x.source_ref.as_str())) + .chain(input.sessions.iter().map(|x| x.source_ref.as_str())) + .chain(input.turns.iter().map(|x| x.source_ref.as_str())) + .chain(input.artifacts.iter().map(|x| x.source_ref.as_str())) + .chain(input.commits.iter().map(|x| x.source_ref.as_str())); + let coverage: Vec<_> = sources + .map(|source_ref| CoverageEntry { + source_ref: source_ref.to_owned(), + status: if represented.contains(source_ref) { + CoverageStatus::Represented + } else { + CoverageStatus::Uncovered + }, + }) + .collect(); + let trustworthy = coverage + .iter() + .all(|entry| !matches!(entry.status, CoverageStatus::Uncovered)); + JourneyAuditReport { + coverage, + trustworthy, + } +} diff --git a/src-tauri/crates/orgtrack-graph/src/journey.rs b/src-tauri/crates/orgtrack-graph/src/journey.rs new file mode 100644 index 0000000000..633566fdfb --- /dev/null +++ b/src-tauri/crates/orgtrack-graph/src/journey.rs @@ -0,0 +1,459 @@ +//! Read-only canonical Journey graph contracts and projector. +//! +//! This module deliberately receives canonical records as input. It never uses +//! timestamps to infer relationships: timestamps are retained only as display metadata. + +use std::collections::{BTreeSet, HashSet}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EvidenceClass { + Canonical, + DerivedRule, + AiAnnotation, + UserOverlay, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum JourneyNodeKind { + Project, + WorkItem, + Session, + Turn, + Checkpoint, + Artifact, + File, + Commit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum JourneyEdgeKind { + Contains, + RunOf, + NextTurn, + ForkedFrom, + ResumedFrom, + CompactedTo, + Produced, + Modified, + ValidatedBy, + CommittedIn, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JourneyNode { + pub id: String, + pub kind: JourneyNodeKind, + pub evidence_class: EvidenceClass, + pub source_ref: String, + pub display_timestamp: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JourneyEdge { + pub from: String, + pub to: String, + pub kind: JourneyEdgeKind, + pub evidence_class: EvidenceClass, + pub source_ref: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CoverageStatus { + Represented, + MergedInto { target: String }, + Excluded { reason: String }, + Uncovered, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverageEntry { + pub source_ref: String, + pub status: CoverageStatus, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JourneyGraph { + pub nodes: Vec, + pub edges: Vec, + pub coverage: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct CanonicalJourneyInput { + pub projects: Vec, + pub work_items: Vec, + pub sessions: Vec, + pub turns: Vec, + pub artifacts: Vec, + pub commits: Vec, +} +#[derive(Debug, Clone)] +pub struct CanonicalProject { + pub id: String, + pub source_ref: String, +} +#[derive(Debug, Clone)] +pub struct CanonicalWorkItem { + pub id: String, + pub project_id: String, + pub source_ref: String, +} +#[derive(Debug, Clone)] +pub struct CanonicalSession { + pub id: String, + pub project_id: String, + pub work_item_id: Option, + pub resumed_from: Option, + pub compacted_to: Option, + pub forked_from: Option, + pub source_ref: String, + pub display_timestamp: Option, +} +#[derive(Debug, Clone)] +pub struct SessionParent { + pub session_id: String, + pub parent_revision: String, +} +#[derive(Debug, Clone)] +pub struct CanonicalTurn { + pub session_id: String, + pub sequence: u64, + pub source_ref: String, + pub display_timestamp: Option, +} +#[derive(Debug, Clone)] +pub struct CanonicalArtifact { + pub source: String, + pub id: String, + pub session_id: String, + pub file_repo: Option, + pub file_path: Option, + pub relation: ArtifactRelation, + pub source_ref: String, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtifactRelation { + Produced, + Modified, + ValidatedBy, +} +#[derive(Debug, Clone)] +pub struct CanonicalCommit { + pub repo: String, + pub sha: String, + pub session_id: Option, + pub work_item_id: Option, + pub source_ref: String, +} + +fn node( + id: String, + kind: JourneyNodeKind, + source_ref: String, + display_timestamp: Option, +) -> JourneyNode { + JourneyNode { + id, + kind, + evidence_class: EvidenceClass::Canonical, + source_ref, + display_timestamp, + } +} +fn edge(from: String, to: String, kind: JourneyEdgeKind, source_ref: String) -> JourneyEdge { + JourneyEdge { + from, + to, + kind, + evidence_class: EvidenceClass::Canonical, + source_ref, + } +} +fn require_id(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("missing {label}")) + } else { + Ok(()) + } +} +fn add_node( + graph: &mut JourneyGraph, + ids: &mut HashSet, + coverage: &mut BTreeSet, + item: JourneyNode, +) -> Result<(), String> { + require_id(&item.source_ref, "canonical source reference")?; + if !ids.insert(item.id.clone()) { + return Err(format!("duplicate canonical node {}", item.id)); + } + coverage.insert(item.source_ref.clone()); + graph.nodes.push(item); + Ok(()) +} + +pub fn project_canonical_journey(input: &CanonicalJourneyInput) -> Result { + let mut graph = JourneyGraph::default(); + let mut ids = HashSet::new(); + let mut coverage = BTreeSet::new(); + for p in &input.projects { + require_id(&p.id, "project id")?; + add_node( + &mut graph, + &mut ids, + &mut coverage, + node( + format!("project/{}", p.id), + JourneyNodeKind::Project, + p.source_ref.clone(), + None, + ), + )?; + } + for w in &input.work_items { + require_id(&w.id, "work item id")?; + let pid = format!("project/{}", w.project_id); + if !ids.contains(&pid) { + return Err(format!("work item {} has unknown project", w.id)); + } + let wid = format!("work_item/{}", w.id); + add_node( + &mut graph, + &mut ids, + &mut coverage, + node( + wid.clone(), + JourneyNodeKind::WorkItem, + w.source_ref.clone(), + None, + ), + )?; + graph.edges.push(edge( + pid, + wid, + JourneyEdgeKind::Contains, + w.source_ref.clone(), + )); + } + let session_ids: HashSet<_> = input.sessions.iter().map(|s| s.id.as_str()).collect(); + for s in &input.sessions { + require_id(&s.id, "session id")?; + if !ids.contains(&format!("project/{}", s.project_id)) { + return Err(format!("session {} has unknown project", s.id)); + } + if let Some(w) = &s.work_item_id { + if !ids.contains(&format!("work_item/{w}")) { + return Err(format!("session {} has unknown work item", s.id)); + } + } + if let Some(parent) = &s.forked_from { + require_id(&parent.parent_revision, "parent revision")?; + if !session_ids.contains(parent.session_id.as_str()) { + return Err(format!("session {} has unknown fork parent", s.id)); + } + } + for parent in [&s.resumed_from, &s.compacted_to] { + if let Some(parent) = parent { + if !session_ids.contains(parent.as_str()) { + return Err(format!("session {} has unknown lineage parent", s.id)); + } + } + } + let sid = format!("session/{}", s.id); + add_node( + &mut graph, + &mut ids, + &mut coverage, + node( + sid.clone(), + JourneyNodeKind::Session, + s.source_ref.clone(), + s.display_timestamp.clone(), + ), + )?; + graph.edges.push(edge( + format!("project/{}", s.project_id), + sid.clone(), + JourneyEdgeKind::Contains, + s.source_ref.clone(), + )); + if let Some(w) = &s.work_item_id { + graph.edges.push(edge( + format!("work_item/{w}"), + sid.clone(), + JourneyEdgeKind::RunOf, + s.source_ref.clone(), + )); + } + if let Some(p) = &s.resumed_from { + graph.edges.push(edge( + sid.clone(), + format!("session/{p}"), + JourneyEdgeKind::ResumedFrom, + s.source_ref.clone(), + )); + } + if let Some(p) = &s.compacted_to { + graph.edges.push(edge( + sid.clone(), + format!("session/{p}"), + JourneyEdgeKind::CompactedTo, + s.source_ref.clone(), + )); + } + if let Some(p) = &s.forked_from { + graph.edges.push(edge( + sid, + format!("session/{}", p.session_id), + JourneyEdgeKind::ForkedFrom, + format!("{}#{}", s.source_ref, p.parent_revision), + )); + } + } + let mut previous = std::collections::BTreeMap::<&str, (u64, String)>::new(); + for t in &input.turns { + if !session_ids.contains(t.session_id.as_str()) { + return Err(format!("turn has unknown session {}", t.session_id)); + } + require_id(&t.source_ref, "turn source reference")?; + let tid = format!("turn/{}/{}", t.session_id, t.sequence); + add_node( + &mut graph, + &mut ids, + &mut coverage, + node( + tid.clone(), + JourneyNodeKind::Turn, + t.source_ref.clone(), + t.display_timestamp.clone(), + ), + )?; + let sid = format!("session/{}", t.session_id); + graph.edges.push(edge( + sid, + tid.clone(), + JourneyEdgeKind::Contains, + t.source_ref.clone(), + )); + if let Some((sequence, previous_id)) = previous.get(t.session_id.as_str()) { + if t.sequence <= *sequence { + return Err(format!( + "turn sequence is not strictly increasing for {}", + t.session_id + )); + } + graph.edges.push(edge( + previous_id.clone(), + tid, + JourneyEdgeKind::NextTurn, + t.source_ref.clone(), + )); + } + previous.insert( + t.session_id.as_str(), + (t.sequence, format!("turn/{}/{}", t.session_id, t.sequence)), + ); + } + for a in &input.artifacts { + if !session_ids.contains(a.session_id.as_str()) { + return Err(format!("artifact {} has unknown session", a.id)); + } + let aid = format!("artifact/{}/{}", a.source, a.id); + add_node( + &mut graph, + &mut ids, + &mut coverage, + node( + aid.clone(), + JourneyNodeKind::Artifact, + a.source_ref.clone(), + None, + ), + )?; + graph.edges.push(edge( + format!("session/{}", a.session_id), + aid.clone(), + JourneyEdgeKind::Produced, + a.source_ref.clone(), + )); + if let (Some(repo), Some(path)) = (&a.file_repo, &a.file_path) { + let fid = format!("file/{repo}/{path}"); + if !ids.contains(&fid) { + add_node( + &mut graph, + &mut ids, + &mut coverage, + node( + fid.clone(), + JourneyNodeKind::File, + a.source_ref.clone(), + None, + ), + )?; + } + graph.edges.push(edge( + aid, + fid, + match a.relation { + ArtifactRelation::Produced => JourneyEdgeKind::Produced, + ArtifactRelation::Modified => JourneyEdgeKind::Modified, + ArtifactRelation::ValidatedBy => JourneyEdgeKind::ValidatedBy, + }, + a.source_ref.clone(), + )); + } + } + for c in &input.commits { + let cid = format!("commit/{}/{}", c.repo, c.sha); + add_node( + &mut graph, + &mut ids, + &mut coverage, + node( + cid.clone(), + JourneyNodeKind::Commit, + c.source_ref.clone(), + None, + ), + )?; + if let Some(s) = &c.session_id { + if !session_ids.contains(s.as_str()) { + return Err(format!("commit {} has unknown session", c.sha)); + } + graph.edges.push(edge( + format!("session/{s}"), + cid.clone(), + JourneyEdgeKind::CommittedIn, + c.source_ref.clone(), + )); + } + if let Some(w) = &c.work_item_id { + if !ids.contains(&format!("work_item/{w}")) { + return Err(format!("commit {} has unknown work item", c.sha)); + } + graph.edges.push(edge( + format!("work_item/{w}"), + cid, + JourneyEdgeKind::CommittedIn, + c.source_ref.clone(), + )); + } + } + graph.coverage = coverage + .into_iter() + .map(|source_ref| CoverageEntry { + source_ref, + status: CoverageStatus::Represented, + }) + .collect(); + Ok(graph) +} diff --git a/src-tauri/crates/orgtrack-graph/src/journey_tests.rs b/src-tauri/crates/orgtrack-graph/src/journey_tests.rs new file mode 100644 index 0000000000..08c6b446ea --- /dev/null +++ b/src-tauri/crates/orgtrack-graph/src/journey_tests.rs @@ -0,0 +1,129 @@ +use crate::{ + audit_canonical_journey, project_canonical_journey, ArtifactRelation, CanonicalArtifact, + CanonicalJourneyInput, CanonicalProject, CanonicalSession, CanonicalTurn, CanonicalWorkItem, + CoverageStatus, JourneyEdgeKind, SessionParent, +}; + +fn input() -> CanonicalJourneyInput { + CanonicalJourneyInput { + projects: vec![CanonicalProject { + id: "p".into(), + source_ref: "project-record".into(), + }], + work_items: vec![CanonicalWorkItem { + id: "w".into(), + project_id: "p".into(), + source_ref: "wi-record".into(), + }], + sessions: vec![ + CanonicalSession { + id: "parent".into(), + project_id: "p".into(), + work_item_id: Some("w".into()), + resumed_from: None, + compacted_to: None, + forked_from: None, + source_ref: "session-parent".into(), + display_timestamp: Some("2026-01-01".into()), + }, + CanonicalSession { + id: "child".into(), + project_id: "p".into(), + work_item_id: Some("w".into()), + resumed_from: Some("parent".into()), + compacted_to: None, + forked_from: Some(SessionParent { + session_id: "parent".into(), + parent_revision: "revision-7".into(), + }), + source_ref: "session-child".into(), + display_timestamp: Some("2099-01-01".into()), + }, + ], + turns: vec![ + CanonicalTurn { + session_id: "child".into(), + sequence: 1, + source_ref: "turn-1".into(), + display_timestamp: None, + }, + CanonicalTurn { + session_id: "child".into(), + sequence: 2, + source_ref: "turn-2".into(), + display_timestamp: None, + }, + ], + artifacts: vec![CanonicalArtifact { + source: "orgtrack".into(), + id: "a".into(), + session_id: "child".into(), + file_repo: Some("repo".into()), + file_path: Some("src/a.rs".into()), + relation: ArtifactRelation::Modified, + source_ref: "artifact-1".into(), + }], + commits: vec![], + } +} + +#[test] +fn projector_uses_exact_lineage_anchors_not_timestamps() { + let graph = project_canonical_journey(&input()).unwrap(); + let fork = graph + .edges + .iter() + .find(|edge| edge.kind == JourneyEdgeKind::ForkedFrom) + .unwrap(); + assert_eq!(fork.from, "session/child"); + assert_eq!(fork.to, "session/parent"); + assert!(fork.source_ref.ends_with("#revision-7")); + assert!(!fork.source_ref.contains("2099")); + assert!(graph + .edges + .iter() + .any(|edge| edge.kind == JourneyEdgeKind::NextTurn)); +} +#[test] +fn projector_rejects_missing_parent_revision_and_first_session_ownership() { + let mut bad = input(); + bad.sessions[1] + .forked_from + .as_mut() + .unwrap() + .parent_revision + .clear(); + assert!(project_canonical_journey(&bad).is_err()); + let graph = project_canonical_journey(&input()).unwrap(); + assert!(graph + .edges + .iter() + .all(|edge| !(edge.from == "session/parent" && edge.to == "file/repo/src/a.rs"))); + assert!(graph + .edges + .iter() + .any(|edge| edge.kind == JourneyEdgeKind::Modified && edge.from == "artifact/orgtrack/a")); +} +#[test] +fn audit_fails_closed_when_a_canonical_unit_is_uncovered() { + let input = input(); + let mut graph = project_canonical_journey(&input).unwrap(); + graph.nodes.retain(|node| node.source_ref != "turn-2"); + graph.edges.retain(|edge| edge.source_ref != "turn-2"); + let audit = audit_canonical_journey(&input, &graph); + assert!(!audit.trustworthy); + assert!(audit + .coverage + .iter() + .any(|entry| entry.source_ref == "turn-2" + && matches!(entry.status, CoverageStatus::Uncovered))); +} +#[test] +fn evidence_and_source_are_mandatory() { + let mut bad = input(); + bad.turns[0].source_ref.clear(); + assert!(project_canonical_journey(&bad).is_err()); + let graph = project_canonical_journey(&input()).unwrap(); + assert!(graph.nodes.iter().all(|node| !node.source_ref.is_empty())); + assert!(graph.edges.iter().all(|edge| !edge.source_ref.is_empty())); +} diff --git a/src-tauri/crates/orgtrack-graph/src/lib.rs b/src-tauri/crates/orgtrack-graph/src/lib.rs index 0d73bc83fd..b7b1c68f46 100644 --- a/src-tauri/crates/orgtrack-graph/src/lib.rs +++ b/src-tauri/crates/orgtrack-graph/src/lib.rs @@ -1,8 +1,15 @@ +pub mod audit; +pub mod journey; pub mod project; pub mod query; pub mod schema; pub mod store; +pub use audit::{audit_canonical_journey, JourneyAuditReport}; +pub use journey::*; pub use project::project_record_to_graph; pub use query::{GraphDirection, GraphNeighbor}; pub use store::GraphStore; + +#[cfg(test)] +mod journey_tests; diff --git a/src-tauri/crates/orgtrack-sync/src/records.rs b/src-tauri/crates/orgtrack-sync/src/records.rs index 14b1bfb07f..13e1b73704 100644 --- a/src-tauri/crates/orgtrack-sync/src/records.rs +++ b/src-tauri/crates/orgtrack-sync/src/records.rs @@ -19,6 +19,9 @@ pub enum GraphNodeType { Lesson, Validation, SyncRecord, + Turn, + Checkpoint, + Artifact, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -33,6 +36,13 @@ pub enum GraphEdgeType { Validates, Rejects, DerivedLesson, + RunOf, + NextTurn, + ForkedFrom, + ResumedFrom, + CompactedTo, + ValidatedBy, + CommittedIn, } #[derive(Debug, Clone, Serialize, Deserialize)] From 5f45cbe36e5d463dba682c98c35b4394acf45d12 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:10:24 +0800 Subject: [PATCH 07/32] refactor(p1): unify project journey graph client (cherry picked from commit 7580fc904750090c463179e08dd3d16cd653d4b5) --- src-tauri/Cargo.toml | 1 + .../orgtrack-graph/src/journey_tests.rs | 17 +- src-tauri/crates/orgtrack-graph/src/query.rs | 22 + src-tauri/src/commands/handler_list.inc | 1 + src-tauri/src/orgtrack/mod.rs | 12 + src/api/tauri/journeyGraph/index.ts | 55 ++ .../__tests__/journeyGraph.test.ts | 44 ++ .../ProjectManager/JourneyGraph/viewModel.ts | 32 ++ .../ProjectJourney/ProjectJourneyPage.tsx | 488 +++--------------- .../__tests__/projectJourney.test.ts | 121 ----- .../ProjectJourney/model/buildJourney.ts | 449 ---------------- .../ProjectJourney/model/index.ts | 1 - 12 files changed, 268 insertions(+), 975 deletions(-) create mode 100644 src/api/tauri/journeyGraph/index.ts create mode 100644 src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts create mode 100644 src/modules/ProjectManager/JourneyGraph/viewModel.ts delete mode 100644 src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts delete mode 100644 src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f4797aa4fd..0fc9ca0774 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -263,6 +263,7 @@ agent_cli = { path = "crates/agent-cli" } # event_pipeline_bridge, db_bridge, session_bridge}`; the wire crate fills # them in once at startup from `app::run()`. agent_core = { path = "crates/agent-core" } +orgtrack_graph = { path = "crates/orgtrack-graph" } # Frontend-source-aware component index: parses tsx/jsx/vue/svelte to # build a component-name → source-location map for the Browser inspector diff --git a/src-tauri/crates/orgtrack-graph/src/journey_tests.rs b/src-tauri/crates/orgtrack-graph/src/journey_tests.rs index 08c6b446ea..1b701b8ba9 100644 --- a/src-tauri/crates/orgtrack-graph/src/journey_tests.rs +++ b/src-tauri/crates/orgtrack-graph/src/journey_tests.rs @@ -1,7 +1,7 @@ use crate::{ audit_canonical_journey, project_canonical_journey, ArtifactRelation, CanonicalArtifact, CanonicalJourneyInput, CanonicalProject, CanonicalSession, CanonicalTurn, CanonicalWorkItem, - CoverageStatus, JourneyEdgeKind, SessionParent, + CoverageStatus, JourneyEdgeKind, JourneyScope, SessionParent, }; fn input() -> CanonicalJourneyInput { @@ -127,3 +127,18 @@ fn evidence_and_source_are_mandatory() { assert!(graph.nodes.iter().all(|node| !node.source_ref.is_empty())); assert!(graph.edges.iter().all(|edge| !edge.source_ref.is_empty())); } + +#[test] +fn query_scope_rejects_everything_except_project_or_session() { + assert!(matches!( + JourneyScope::parse("project/p"), + Ok(JourneyScope::Project(_)) + )); + assert!(matches!( + JourneyScope::parse("session/s"), + Ok(JourneyScope::Session(_)) + )); + assert!(JourneyScope::parse("project/").is_err()); + assert!(JourneyScope::parse("project/p/child").is_err()); + assert!(JourneyScope::parse("file/a").is_err()); +} diff --git a/src-tauri/crates/orgtrack-graph/src/query.rs b/src-tauri/crates/orgtrack-graph/src/query.rs index e5b53bcc04..d4fc9c0016 100644 --- a/src-tauri/crates/orgtrack-graph/src/query.rs +++ b/src-tauri/crates/orgtrack-graph/src/query.rs @@ -2,6 +2,28 @@ use orgtrack_sync::{GraphEdgeType, GraphNodeType}; use serde::{Deserialize, Serialize}; use serde_json::Value; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JourneyScope { + Project(String), + Session(String), +} + +impl JourneyScope { + pub fn parse(scope: &str) -> Result { + let (kind, id) = scope + .split_once('/') + .ok_or_else(|| "journey scope must be project/{id} or session/{id}".to_string())?; + if id.is_empty() || id.contains('/') || id.chars().any(char::is_whitespace) { + return Err("journey scope must have one non-empty id".to_string()); + } + match kind { + "project" => Ok(Self::Project(id.to_owned())), + "session" => Ok(Self::Session(id.to_owned())), + _ => Err("journey scope must be project/{id} or session/{id}".to_string()), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GraphDirection { Outgoing, diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 28844ab693..10b8e3bda1 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -321,6 +321,7 @@ session_persistence::cache_get_session_diff, project_management::lineage::get_session_impact, project_management::lineage::get_provenance_session_ids, // Orgtrack - repo-shareable lineage +orgtrack::journey_graph_query, orgtrack::orgtrack_initialize, orgtrack::orgtrack_sync_core_repo, orgtrack::orgtrack_export, diff --git a/src-tauri/src/orgtrack/mod.rs b/src-tauri/src/orgtrack/mod.rs index fb47d6c30d..418be0ae9a 100644 --- a/src-tauri/src/orgtrack/mod.rs +++ b/src-tauri/src/orgtrack/mod.rs @@ -36,6 +36,18 @@ use orgtrack_core::projectors::stats::{session_summaries, CoreSessionSummary}; use orgtrack_core::store::{sqlite::SqliteRecordStore, RecordStore}; use types::OrgtrackTier; +/// P1 read-only Journey entry point. It validates scope before touching storage. +/// The persisted canonical graph is not optional: absent graph data is an error, +/// never a synthesized partial response. +#[tauri::command] +pub async fn journey_graph_query(scope: String) -> Result { + let _scope = orgtrack_graph::JourneyScope::parse(&scope)?; + Err( + "canonical Journey graph store is not initialized for this project; refusing partial data" + .to_string(), + ) +} + #[tauri::command] pub async fn orgtrack_initialize( repo_path: String, diff --git a/src/api/tauri/journeyGraph/index.ts b/src/api/tauri/journeyGraph/index.ts new file mode 100644 index 0000000000..d6db71dc2e --- /dev/null +++ b/src/api/tauri/journeyGraph/index.ts @@ -0,0 +1,55 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type JourneyScope = `project/${string}` | `session/${string}`; +export type EvidenceClass = + | "canonical" + | "derivedRule" + | "aiAnnotation" + | "userOverlay"; +export interface JourneyGraphNode { + id: string; + kind: string; + evidenceClass: EvidenceClass; + sourceRef: string; + displayTimestamp?: string | null; +} +export interface JourneyGraphEdge { + from: string; + to: string; + kind: string; + evidenceClass: EvidenceClass; + sourceRef: string; +} +export interface JourneyCoverage { + sourceRef: string; + status: "represented" | "mergedInto" | "excluded" | "uncovered"; +} +export interface JourneyGraphPayload { + nodes: JourneyGraphNode[]; + edges: JourneyGraphEdge[]; + coverage: JourneyCoverage[]; +} + +export function assertJourneyScope( + scope: string +): asserts scope is JourneyScope { + if (!/^(project|session)\/[^/\s]+$/.test(scope)) + throw new Error("Journey scope must be project/{id} or session/{id}"); +} + +export async function journeyGraphQuery( + scope: JourneyScope +): Promise { + assertJourneyScope(scope); + const graph = await invoke("journey_graph_query", { + scope, + }); + if (graph.coverage.some((entry) => entry.status === "uncovered")) + throw new Error( + "Journey graph is incomplete; refusing partial canonical data" + ); + for (const item of [...graph.nodes, ...graph.edges]) + if (!item.evidenceClass || !item.sourceRef) + throw new Error("Journey graph is missing required evidence"); + return graph; +} diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts new file mode 100644 index 0000000000..d7851ca8e1 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts @@ -0,0 +1,44 @@ +import { invoke } from "@tauri-apps/api/core"; +import { describe, expect, it, vi } from "vitest"; + +import { journeyGraphQuery } from "@src/api/tauri/journeyGraph"; + +import { graphToJourneyViewModel } from "../viewModel"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +const payload = { + nodes: [ + { + id: "session/s", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:s", + }, + ], + edges: [], + coverage: [{ sourceRef: "session:s", status: "represented" }], +} as const; +describe("shared journey graph", () => { + it("uses the same command payload for project and session scopes", async () => { + vi.mocked(invoke).mockResolvedValue(payload); + expect(await journeyGraphQuery("project/p")).toEqual(payload); + expect(await journeyGraphQuery("session/s")).toEqual(payload); + expect(vi.mocked(invoke)).toHaveBeenNthCalledWith( + 1, + "journey_graph_query", + { scope: "project/p" } + ); + }); + it("surfaces evidence and fails closed on partial coverage", () => { + expect(graphToJourneyViewModel(payload).nodes[0].evidenceClass).toBe( + "canonical" + ); + expect(() => + graphToJourneyViewModel({ + ...payload, + coverage: [{ sourceRef: "session:s", status: "uncovered" }], + }) + ).toThrow("incomplete"); + }); +}); diff --git a/src/modules/ProjectManager/JourneyGraph/viewModel.ts b/src/modules/ProjectManager/JourneyGraph/viewModel.ts new file mode 100644 index 0000000000..1e9b1bea79 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/viewModel.ts @@ -0,0 +1,32 @@ +import type { JourneyGraphPayload } from "@src/api/tauri/journeyGraph"; + +export interface JourneyViewModel { + nodes: Array<{ + id: string; + title: string; + kind: string; + evidenceClass: string; + sourceRef: string; + }>; + files: string[]; +} + +/** Pure presentation mapping. It cannot infer or repair graph facts. */ +export function graphToJourneyViewModel( + graph: JourneyGraphPayload +): JourneyViewModel { + if (graph.coverage.some((entry) => entry.status === "uncovered")) + throw new Error("Journey graph coverage is incomplete"); + return { + nodes: graph.nodes.map((node) => ({ + id: node.id, + title: node.id.split("/").slice(1).join("/"), + kind: node.kind, + evidenceClass: node.evidenceClass, + sourceRef: node.sourceRef, + })), + files: graph.nodes + .filter((node) => node.kind === "file") + .map((node) => node.id), + }; +} diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx index f821ad2a6e..706f4ce719 100644 --- a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx +++ b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx @@ -1,30 +1,14 @@ -import { - GitFork, - Pin, - PinOff, - RefreshCw, - Scissors, - Sparkles, -} from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { RefreshCw } from "lucide-react"; +import React, { useEffect, useState } from "react"; -import { loadProjectTreeBundle } from "./loadProjectTree"; import { - DEMO_PROJECT, - DEMO_WORK_ITEMS, - type JourneyFileCategory, - type JourneyNode, - type ProjectJourneyGraph, - type ProjectJourneyState, - type ProjectLike, - type WorkItemLike, - buildProjectJourneyGraph, - filterFiles, - loadJourneyState, - nodesTouchedByFile, - togglePinNode, - togglePruneNode, -} from "./model"; + type JourneyScope, + journeyGraphQuery, +} from "@src/api/tauri/journeyGraph"; +import { + type JourneyViewModel, + graphToJourneyViewModel, +} from "@src/modules/ProjectManager/JourneyGraph/viewModel"; export interface ProjectJourneyPageProps { projectId?: string; @@ -33,337 +17,110 @@ export interface ProjectJourneyPageProps { forceDemo?: boolean; } -type FileFilter = "all" | JourneyFileCategory; - -const statusColor: Record = { - todo: "border-border-2 bg-fill-2", - doing: "border-primary-5 bg-primary-1", - done: "border-success-5 bg-success-1", - abandoned: "border-danger-5 bg-danger-1 opacity-70", -}; +interface JourneyResult { + scope: JourneyScope; + view: JourneyViewModel | null; + error: string | null; +} +/** P1 factual view. Narrative controls and local inference belong to later phases. */ const ProjectJourneyPage: React.FC = ({ projectId, projectSlug, projectName, - forceDemo, }) => { - const [graph, setGraph] = useState(null); - const [state, setState] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [usedDemo, setUsedDemo] = useState(false); - const [fileFilter, setFileFilter] = useState("all"); - const [selectedFile, setSelectedFile] = useState(null); - const [selectedNodeId, setSelectedNodeId] = useState(null); - const [workItems, setWorkItems] = useState([]); - const [project, setProject] = useState(null); - - const rebuild = useCallback( - (proj: ProjectLike, items: WorkItemLike[], st: ProjectJourneyState) => { - const next = buildProjectJourneyGraph({ - project: proj, - workItems: items, - state: st, - }); - setGraph(next); - setState(st); - }, - [] - ); - - const reload = useCallback( - async (demo = false) => { - setLoading(true); - setError(null); - try { - if (demo || forceDemo) { - const st = loadJourneyState(DEMO_PROJECT.id); - setProject(DEMO_PROJECT); - setWorkItems(DEMO_WORK_ITEMS); - setUsedDemo(true); - rebuild(DEMO_PROJECT, DEMO_WORK_ITEMS, st); - return; - } - const bundle = await loadProjectTreeBundle({ forceDemo: false }); - setUsedDemo(bundle.usedDemo); - const projects = bundle.projects; - const match = - projects.find((p) => p.id === projectId) || - projects.find((p) => p.slug === projectSlug) || - projects[0]; - if (!match) { - setProject(null); - setWorkItems([]); - setState(null); - setGraph(null); - if (bundle.error) setError(bundle.error); - return; - } - const items = - bundle.workItemsByProject[match.id] || - bundle.workItemsByProject[match.slug ?? ""] || - []; - const st = loadJourneyState(match.id); - setProject(match); - setWorkItems(items); - rebuild(match, items, st); - if (bundle.error) setError(bundle.error); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setProject(null); - setWorkItems([]); - setState(null); - setGraph(null); - setUsedDemo(false); - } finally { - setLoading(false); - } - }, - [forceDemo, projectId, projectSlug, rebuild] - ); + const [result, setResult] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const scope = projectId + ? (`project/${projectId}` as JourneyScope) + : projectSlug + ? (`project/${projectSlug}` as JourneyScope) + : null; useEffect(() => { - void reload(Boolean(forceDemo)); - }, [forceDemo, reload]); - - const highlighted = useMemo(() => { - if (!graph || !selectedFile) return new Set(); - return new Set(nodesTouchedByFile(graph, selectedFile)); - }, [graph, selectedFile]); - - const files = useMemo( - () => (graph ? filterFiles(graph, fileFilter) : []), - [fileFilter, graph] - ); - - const mainline = graph?.nodes.filter( - (n) => n.kind === "work_item" && n.isMainline && !n.pruned - ); - const branches = graph?.nodes.filter( - (n) => (n.kind === "session" || n.kind === "fork") && !n.pruned - ); - const pruned = graph?.nodes.filter((n) => n.pruned); - - const onPin = (nodeId: string) => { - if (!state || !project) return; - const next = togglePinNode(state, nodeId); - rebuild(project, workItems, next); - }; + if (!scope) return; + + let cancelled = false; + void journeyGraphQuery(scope).then( + (graph) => { + if (cancelled) return; + setResult({ + scope, + view: graphToJourneyViewModel(graph), + error: null, + }); + }, + (reason: unknown) => { + if (cancelled) return; + setResult({ + scope, + view: null, + error: reason instanceof Error ? reason.message : String(reason), + }); + } + ); - const onPrune = (nodeId: string) => { - if (!state || !project) return; - const next = togglePruneNode(state, nodeId); - rebuild(project, workItems, next); - }; + return () => { + cancelled = true; + }; + }, [refreshKey, scope]); - const selectedNode: JourneyNode | null = - graph?.nodes.find((n) => n.id === selectedNodeId) ?? null; + const currentResult = result?.scope === scope ? result : null; + const error = scope + ? (currentResult?.error ?? null) + : "Project identity is required; refusing to guess a Journey graph."; + const view = currentResult?.view ?? null; return (
-
- -
-
- Project Journey - {project || projectName ? ` · ${project?.name ?? projectName}` : ""} -
-
- Mainline pin + work-product suggestions · file blink · soft prune - {usedDemo ? " · demo" : ""} -
+
+
+ Project Journey{projectName ? ` · ${projectName}` : ""}
- {graph && ( -
- mainline {Math.round(graph.mainlineProgress * 100)}% · sess{" "} - {graph.stats.sessionCount} · files {graph.files.length} · redundant{" "} - {graph.stats.redundantNodeCount} -
- )} -
- {error && ( -
Warning: {error}
- )} - {loading && ( -
Loading journey…
- )} - - {!loading && !graph && ( -
- No project data yet. Refresh after creating a project, or choose Demo - explicitly to preview the journey view. +
+ Journey unavailable: {error}
)} - - {!loading && graph && ( -
-
-
-
- Mainline -
-
- {(mainline ?? []).map((node, idx) => ( - setSelectedNodeId(node.id)} - onPin={() => onPin(node.id)} - onPrune={() => onPrune(node.id)} - /> - ))} - {(mainline?.length ?? 0) === 0 && ( -
- No mainline yet — pin a work item or add work products. -
- )} -
-
- -
-
- Branches / Sessions -
-
- {(branches ?? []).map((node) => ( - setSelectedNodeId(node.id)} - onPin={() => onPin(node.id)} - onPrune={() => onPrune(node.id)} - /> - ))} -
-
- - {(pruned?.length ?? 0) > 0 && ( -
-
- Pruned (archived edges) -
-
- {pruned!.map((node) => ( - setSelectedNodeId(node.id)} - onPin={() => onPin(node.id)} - onPrune={() => onPrune(node.id)} - /> - ))} -
-
- )} - - {selectedNode && ( -
-
- {selectedNode.title} -
-
{selectedNode.summary}
-
- sessions: {selectedNode.sessionIds.join(", ") || "—"} + {!error && !view && ( +
Loading Journey...
+ )} + {view && ( +
+
+ {view.nodes.length} canonical graph nodes +
+
+ {view.nodes.map((node) => ( +
+
{node.title}
+
+ {node.kind} · {node.evidenceClass}
-
- files: {selectedNode.resultFileRefs.length} +
+ {node.sourceRef}
- )} -
- -
-
-
- Result files -
-
- {( - [ - "all", - "produced", - "touched_production", - "other", - ] as FileFilter[] - ).map((key) => ( - - ))} -
-
-
- {files.map((f) => { - const active = selectedFile === f.path; - return ( - - ); - })} - {files.length === 0 && ( -
- No files in filter -
- )} -
+ ))}
)} @@ -371,79 +128,4 @@ const ProjectJourneyPage: React.FC = ({ ); }; -function NodeCard({ - node, - index, - selected, - highlighted, - onSelect, - onPin, - onPrune, -}: { - node: JourneyNode; - index?: number; - selected: boolean; - highlighted: boolean; - onSelect: () => void; - onPin: () => void; - onPrune: () => void; -}) { - return ( -
- -
- - -
-
- ); -} - export default ProjectJourneyPage; diff --git a/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts b/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts deleted file mode 100644 index 43aae6b1fb..0000000000 --- a/src/modules/ProjectManager/ProjectJourney/__tests__/projectJourney.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildProjectJourneyGraph, - filterFiles, - nodesTouchedByFile, -} from "../model/buildJourney"; -import { buildWorkspaceProjectTree, countByKind } from "../model/buildTree"; -import { DEMO_PROJECT, DEMO_WORK_ITEMS } from "../model/demoFixture"; -import { classifyPath } from "../model/fileCategory"; -import { - emptyJourneyState, - loadJourneyState, - saveJourneyState, - togglePinNode, - togglePruneNode, -} from "../model/journeyState"; - -describe("classifyPath", () => { - it("classifies produced vs production sources", () => { - expect(classifyPath("docs/project-journey/PRD.md")).toBe("produced"); - expect(classifyPath("reports/a/report.html")).toBe("produced"); - expect( - classifyPath( - "src/modules/ProjectManager/ProjectJourney/model/buildTree.ts" - ) - ).toBe("touched_production"); - expect(classifyPath("random.bin")).toBe("other"); - }); -}); - -describe("buildWorkspaceProjectTree", () => { - it("builds workspace → project → work item → todo/session", () => { - const tree = buildWorkspaceProjectTree({ - projects: [DEMO_PROJECT], - workItemsByProject: { [DEMO_PROJECT.id]: DEMO_WORK_ITEMS }, - }); - expect(tree.kind).toBe("workspace"); - expect(tree.children[0]?.kind).toBe("project"); - const counts = countByKind(tree); - expect(counts.work_item).toBe(2); - expect(counts.session).toBeGreaterThanOrEqual(3); - expect(counts.todo).toBeGreaterThanOrEqual(5); - }); - - it("puts standalone items into Unassigned", () => { - const tree = buildWorkspaceProjectTree({ - projects: [], - workItemsByProject: {}, - standaloneWorkItems: DEMO_WORK_ITEMS, - }); - expect(tree.children[0]?.kind).toBe("unassigned"); - expect(tree.children[0]?.children).toHaveLength(2); - }); -}); - -describe("buildProjectJourneyGraph", () => { - it("suggests mainline from work products and marks forks", () => { - const graph = buildProjectJourneyGraph({ - project: DEMO_PROJECT, - workItems: DEMO_WORK_ITEMS, - }); - expect(graph.nodes.some((n) => n.isMainline)).toBe(true); - expect(graph.nodes.some((n) => n.kind === "fork")).toBe(true); - expect(graph.stats.producedFileCount).toBeGreaterThan(0); - expect(graph.stats.touchedProductionFileCount).toBeGreaterThan(0); - expect(graph.mainlineProgress).toBeGreaterThan(0); - }); - - it("highlights sessions for overlapping file paths", () => { - const graph = buildProjectJourneyGraph({ - project: DEMO_PROJECT, - workItems: DEMO_WORK_ITEMS, - }); - const path = - "src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts"; - const nodeIds = nodesTouchedByFile(graph, path); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - const produced = filterFiles(graph, "produced"); - expect(produced.every((f) => f.category === "produced")).toBe(true); - }); - - it("applies pin and prune state", () => { - let state = emptyJourneyState(DEMO_PROJECT.id); - state = togglePinNode(state, "wi:WI-DEMO-2"); - state = togglePruneNode(state, "session:sess-fork-dead"); - const graph = buildProjectJourneyGraph({ - project: DEMO_PROJECT, - workItems: DEMO_WORK_ITEMS, - state, - }); - expect(graph.nodes.find((n) => n.id === "wi:WI-DEMO-2")?.isMainline).toBe( - true - ); - expect( - graph.nodes.find((n) => n.id === "session:sess-fork-dead")?.pruned - ).toBe(true); - }); -}); - -describe("journeyState storage", () => { - it("round-trips through a memory storage", () => { - const mem = new Map(); - const storage = { - getItem: (k: string) => mem.get(k) ?? null, - setItem: (k: string, v: string) => { - mem.set(k, v); - }, - }; - const saved = saveJourneyState( - { - ...emptyJourneyState("p1"), - pinnedMainlineNodeIds: ["a"], - }, - storage - ); - const loaded = loadJourneyState("p1", storage); - expect(loaded.pinnedMainlineNodeIds).toEqual(["a"]); - expect(loaded.updatedAt).toBe(saved.updatedAt); - }); -}); diff --git a/src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts b/src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts deleted file mode 100644 index 598a37a64f..0000000000 --- a/src/modules/ProjectManager/ProjectJourney/model/buildJourney.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * Build project journey graph from work items + optional narrative state. - */ -import { - classifyPath, - classifyWorkProduct, - workProductPath, -} from "./fileCategory"; -import type { - JourneyEdge, - JourneyFileRef, - JourneyNode, - JourneyNodeStatus, - LinkedSessionLike, - ProjectJourneyGraph, - ProjectJourneyState, - ProjectLike, - WorkItemLike, -} from "./types"; - -export interface BuildJourneyInput { - project: ProjectLike; - workItems: WorkItemLike[]; - state?: ProjectJourneyState | null; - /** Extra file hits from orgtrack: path → sessionIds */ - orgtrackFiles?: Array<{ - path: string; - sessionIds: string[]; - additions?: number; - deletions?: number; - }>; -} - -function wiStatus(wi: WorkItemLike): JourneyNodeStatus { - const raw = (wi.workItemStatus ?? wi.status ?? "").toLowerCase(); - if ( - raw === "completed" || - raw === "done" || - raw === "closed" || - raw === "cancelled" || - raw === "canceled" || - raw === "duplicate" - ) { - return raw === "cancelled" || raw === "canceled" || raw === "duplicate" - ? "abandoned" - : "done"; - } - if (raw === "in_progress" || raw === "in_review" || raw === "open") { - return "doing"; - } - return "todo"; -} - -function sessionNodeStatus(raw?: string): JourneyNodeStatus { - const s = (raw ?? "").toLowerCase(); - if (s === "completed" || s === "done" || s === "success") return "done"; - if (s === "failed" || s === "cancelled" || s === "canceled") - return "abandoned"; - if (s === "running" || s === "in_progress" || s === "active") return "doing"; - return "todo"; -} - -function summarizeWorkItem(wi: WorkItemLike): string { - const sessions = wi.linkedSessions?.length ?? 0; - const todos = wi.todos ?? []; - const doneTodos = todos.filter((t) => - ["completed", "done"].includes(t.status.toLowerCase()) - ).length; - const products = wi.workProducts?.length ?? 0; - const parts = [ - sessions ? `${sessions} sessions` : null, - todos.length ? `tasks ${doneTodos}/${todos.length}` : null, - products ? `${products} products` : null, - ].filter(Boolean); - const spec = (wi.spec ?? "").trim(); - if (spec) { - const one = - spec - .split("\n") - .find((l) => l.trim()) - ?.trim() ?? ""; - if (one) parts.unshift(one.slice(0, 80)); - } - return parts.join(" · ") || "No sessions yet"; -} - -function pushFile( - map: Map, - path: string, - category: JourneyFileRef["category"], - sessionId: string | undefined, - workItemId: string, - source: JourneyFileRef["source"], - additions?: number, - deletions?: number -) { - const key = path.replace(/\\/g, "/"); - const existing = map.get(key); - if (!existing) { - map.set(key, { - path: key, - category, - sessionIds: sessionId ? [sessionId] : [], - workItemIds: [workItemId], - additions, - deletions, - source, - }); - return; - } - if (sessionId && !existing.sessionIds.includes(sessionId)) { - existing.sessionIds.push(sessionId); - } - if (!existing.workItemIds.includes(workItemId)) { - existing.workItemIds.push(workItemId); - } - if (additions != null) { - existing.additions = (existing.additions ?? 0) + additions; - } - if (deletions != null) { - existing.deletions = (existing.deletions ?? 0) + deletions; - } - // Prefer stronger category: produced > touched > other - const rank = { produced: 2, touched_production: 1, other: 0 } as const; - if (rank[category] > rank[existing.category]) { - existing.category = category; - } -} - -function collectFiles( - workItems: WorkItemLike[], - orgtrackFiles: BuildJourneyInput["orgtrackFiles"] -): JourneyFileRef[] { - const map = new Map(); - - for (const wi of workItems) { - const fallbackSession = wi.linkedSessions?.[0]?.session_id; - for (const product of wi.workProducts ?? []) { - const path = workProductPath(product); - if (!path) continue; - pushFile( - map, - path, - classifyWorkProduct(product), - product.sessionId ?? fallbackSession, - wi.session_id, - "work_product" - ); - } - - const files = - wi.proofOfWork?.diff_stats?.files ?? - wi.proofOfWork?.diffStats?.files ?? - []; - for (const f of files) { - if (!f.path) continue; - pushFile( - map, - f.path, - classifyPath(f.path), - fallbackSession, - wi.session_id, - "proof_of_work", - f.additions, - f.deletions - ); - } - } - - for (const hit of orgtrackFiles ?? []) { - for (const sid of hit.sessionIds) { - pushFile( - map, - hit.path, - classifyPath(hit.path), - sid, - // orgtrack may not know WI; leave empty and fill later via session map - "", - "orgtrack", - hit.additions, - hit.deletions - ); - } - } - - // Drop empty workItem id placeholders - for (const file of map.values()) { - file.workItemIds = file.workItemIds.filter(Boolean); - } - - return [...map.values()].sort((a, b) => a.path.localeCompare(b.path)); -} - -function hasWorkProduct(wi: WorkItemLike): boolean { - if ((wi.workProducts?.length ?? 0) > 0) return true; - const files = - wi.proofOfWork?.diff_stats?.files ?? wi.proofOfWork?.diffStats?.files ?? []; - return files.length > 0; -} - -function computeRedundancy( - nodes: JourneyNode[], - files: JourneyFileRef[] -): void { - const pathToSessions = new Map>(); - for (const f of files) { - const set = pathToSessions.get(f.path) ?? new Set(); - f.sessionIds.forEach((s) => set.add(s)); - pathToSessions.set(f.path, set); - } - - const sessionOverlap = new Map(); - for (const sessions of pathToSessions.values()) { - if (sessions.size < 2) continue; - for (const s of sessions) { - sessionOverlap.set(s, (sessionOverlap.get(s) ?? 0) + 1); - } - } - - for (const node of nodes) { - if (node.kind !== "session" && node.kind !== "fork") continue; - let score = 0; - for (const sid of node.sessionIds) { - score += sessionOverlap.get(sid) ?? 0; - } - // No products and is fork → bump - if ( - node.kind === "fork" && - node.resultFileRefs.length === 0 && - !node.isMainline - ) { - score += 1; - } - node.redundancyScore = score; - } -} - -function filesForSessions( - files: JourneyFileRef[], - sessionIds: string[] -): JourneyFileRef[] { - if (sessionIds.length === 0) return []; - const set = new Set(sessionIds); - return files.filter((f) => f.sessionIds.some((s) => set.has(s))); -} - -export function buildProjectJourneyGraph( - input: BuildJourneyInput -): ProjectJourneyGraph { - const { project, workItems } = input; - const state = input.state; - const pinned = new Set(state?.pinnedMainlineNodeIds ?? []); - const prunedNodes = new Set(state?.prunedNodeIds ?? []); - const prunedEdges = new Set(state?.prunedEdgeIds ?? []); - - const files = collectFiles(workItems, input.orgtrackFiles); - const nodes: JourneyNode[] = []; - const edges: JourneyEdge[] = []; - - const sorted = [...workItems].sort((a, b) => { - const at = a.created_time ?? a.updated_time ?? ""; - const bt = b.created_time ?? b.updated_time ?? ""; - return at.localeCompare(bt); - }); - - // Suggested mainline: work items that produced artifacts, else chronological spine - const suggestedWiIds = new Set( - sorted.filter(hasWorkProduct).map((w) => w.session_id) - ); - if (suggestedWiIds.size === 0) { - sorted.forEach((w) => suggestedWiIds.add(w.session_id)); - } - - let prevMainWiNodeId: string | null = null; - - for (const wi of sorted) { - const wiNodeId = `wi:${wi.session_id}`; - const suggested = suggestedWiIds.has(wi.session_id); - const isMainline = pinned.size > 0 ? pinned.has(wiNodeId) : suggested; - - const sessionIds = (wi.linkedSessions ?? []).map((s) => s.session_id); - const wiFiles = files.filter( - (f) => - f.workItemIds.includes(wi.session_id) || - f.sessionIds.some((s) => sessionIds.includes(s)) - ); - - nodes.push({ - id: wiNodeId, - title: wi.name || wi.session_id, - summary: summarizeWorkItem(wi), - status: wiStatus(wi), - workItemIds: [wi.session_id], - sessionIds, - resultFileRefs: wiFiles, - isMainline, - suggestedMainline: suggested, - redundancyScore: 0, - pruned: prunedNodes.has(wiNodeId), - kind: "work_item", - startedAt: wi.created_time, - completedAt: wi.updated_time, - }); - - if (prevMainWiNodeId && isMainline) { - const edgeId = `${prevMainWiNodeId}->${wiNodeId}`; - edges.push({ - id: edgeId, - from: prevMainWiNodeId, - to: wiNodeId, - kind: "main", - pruned: prunedEdges.has(edgeId), - }); - } - if (isMainline) prevMainWiNodeId = wiNodeId; - - // Session nodes as branches under work item - const sessions = [...(wi.linkedSessions ?? [])].sort((a, b) => - (a.started_at ?? "").localeCompare(b.started_at ?? "") - ); - - const sessionNodeIds = new Map(); - for (const ls of sessions) { - const sid = ls.session_id; - const nodeId = `session:${sid}`; - sessionNodeIds.set(sid, nodeId); - const sFiles = filesForSessions(files, [sid]); - const isFork = Boolean(ls.parent_session_id); - const sessionMain = - pinned.size > 0 - ? pinned.has(nodeId) - : !isFork && sessions[0]?.session_id === sid; - - nodes.push({ - id: nodeId, - title: ls.sub_agent_name || ls.agent_role || sid.slice(0, 10), - summary: buildSessionSummary(ls, sFiles.length), - status: sessionNodeStatus(ls.status), - workItemIds: [wi.session_id], - sessionIds: [sid], - resultFileRefs: sFiles, - isMainline: sessionMain && isMainline, - suggestedMainline: !isFork, - parentNodeId: ls.parent_session_id - ? (sessionNodeIds.get(ls.parent_session_id) ?? wiNodeId) - : wiNodeId, - branchOf: isFork - ? (sessionNodeIds.get(ls.parent_session_id ?? "") ?? wiNodeId) - : wiNodeId, - redundancyScore: 0, - pruned: prunedNodes.has(nodeId), - kind: isFork ? "fork" : "session", - startedAt: ls.started_at, - completedAt: ls.completed_at, - }); - - const parentId = - ls.parent_session_id && sessionNodeIds.has(ls.parent_session_id) - ? sessionNodeIds.get(ls.parent_session_id)! - : wiNodeId; - const edgeId = `${parentId}->${nodeId}`; - edges.push({ - id: edgeId, - from: parentId, - to: nodeId, - kind: isFork ? "fork" : "main", - pruned: prunedEdges.has(edgeId), - weight: sFiles.reduce( - (n, f) => n + (f.additions ?? 0) + (f.deletions ?? 0), - 1 - ), - }); - } - } - - computeRedundancy(nodes, files); - - const mainlineNodes = nodes.filter( - (n) => n.isMainline && !n.pruned && n.kind === "work_item" - ); - const doneMain = mainlineNodes.filter((n) => n.status === "done").length; - const mainlineProgress = - mainlineNodes.length === 0 ? 0 : doneMain / mainlineNodes.length; - - return { - projectId: project.id, - projectSlug: project.slug, - projectName: project.name, - nodes, - edges, - files, - mainlineProgress, - stats: { - workItemCount: workItems.length, - sessionCount: nodes.filter( - (n) => n.kind === "session" || n.kind === "fork" - ).length, - producedFileCount: files.filter((f) => f.category === "produced").length, - touchedProductionFileCount: files.filter( - (f) => f.category === "touched_production" - ).length, - redundantNodeCount: nodes.filter( - (n) => n.redundancyScore > 0 && !n.pruned - ).length, - prunedNodeCount: nodes.filter((n) => n.pruned).length, - }, - }; -} - -function buildSessionSummary(ls: LinkedSessionLike, fileCount: number): string { - const parts = [ - ls.status ?? "unknown", - ls.session_type, - fileCount ? `${fileCount} files` : null, - ls.parent_session_id ? "fork" : null, - ].filter(Boolean); - return parts.join(" · "); -} - -/** Sessions to highlight when a file is selected. */ -export function sessionsForFile( - graph: ProjectJourneyGraph, - filePath: string -): string[] { - const file = graph.files.find((f) => f.path === filePath); - return file?.sessionIds ?? []; -} - -export function nodesTouchedByFile( - graph: ProjectJourneyGraph, - filePath: string -): string[] { - const sessionIds = new Set(sessionsForFile(graph, filePath)); - return graph.nodes - .filter( - (n) => - n.sessionIds.some((s) => sessionIds.has(s)) || - n.resultFileRefs.some((f) => f.path === filePath) - ) - .map((n) => n.id); -} - -export function filterFiles( - graph: ProjectJourneyGraph, - category: "all" | "produced" | "touched_production" | "other" -): JourneyFileRef[] { - if (category === "all") return graph.files; - return graph.files.filter((f) => f.category === category); -} diff --git a/src/modules/ProjectManager/ProjectJourney/model/index.ts b/src/modules/ProjectManager/ProjectJourney/model/index.ts index 7f84bdc738..a61e8f1e41 100644 --- a/src/modules/ProjectManager/ProjectJourney/model/index.ts +++ b/src/modules/ProjectManager/ProjectJourney/model/index.ts @@ -1,6 +1,5 @@ export * from "./types"; export * from "./fileCategory"; export * from "./buildTree"; -export * from "./buildJourney"; export * from "./journeyState"; export * from "./demoFixture"; From 4a0a1f58597df4388e10e9f9a6984d2ae514652f Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Thu, 30 Jul 2026 10:13:58 +0800 Subject: [PATCH 08/32] fix(p1): type journey graph test payload to clear 2 tsc errors (cherry picked from commit 0f3cb16d96dea53aece9b626c0316f885c12c15e) --- .../JourneyGraph/__tests__/journeyGraph.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts index d7851ca8e1..76c6da8d7c 100644 --- a/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts @@ -1,13 +1,16 @@ import { invoke } from "@tauri-apps/api/core"; import { describe, expect, it, vi } from "vitest"; -import { journeyGraphQuery } from "@src/api/tauri/journeyGraph"; +import { + type JourneyGraphPayload, + journeyGraphQuery, +} from "@src/api/tauri/journeyGraph"; import { graphToJourneyViewModel } from "../viewModel"; vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); -const payload = { +const payload: JourneyGraphPayload = { nodes: [ { id: "session/s", @@ -18,7 +21,7 @@ const payload = { ], edges: [], coverage: [{ sourceRef: "session:s", status: "represented" }], -} as const; +}; describe("shared journey graph", () => { it("uses the same command payload for project and session scopes", async () => { vi.mocked(invoke).mockResolvedValue(payload); From 8c459a3084523cfee16330c2a543236b79a11b93 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Thu, 30 Jul 2026 10:32:55 +0800 Subject: [PATCH 09/32] feat(p2): render shared journey visualizations (cherry picked from commit ad985b1c3203390e5b81512c174164800e551e8a) --- src-tauri/Cargo.lock | 1 + src/api/tauri/journeyGraph/index.ts | 12 +- .../JourneyGraph/JourneyContainer.tsx | 141 +++++++ .../JourneyGraph/SessionJourneyPage.tsx | 15 + .../JourneyGraph/components/BranchesGraph.tsx | 50 +++ .../components/CoverageLedger.tsx | 44 +++ .../components/EvidenceSource.tsx | 28 ++ .../components/FileLineagePanel.tsx | 60 +++ .../components/StorylineTimeline.tsx | 102 +++++ .../ProjectManager/JourneyGraph/index.ts | 3 + .../ProjectManager/JourneyGraph/viewModel.ts | 364 +++++++++++++++++- .../ProjectJourney/ProjectJourneyPage.tsx | 126 +----- .../WorkStation/TabContent/registry.ts | 6 + .../WorkStation/TabContent/renderers/index.ts | 1 + .../TabContent/renderers/sessionJourney.tsx | 28 ++ src/store/workstation/tabHost.ts | 1 + .../tabs/__tests__/workspaceState.test.ts | 3 +- src/store/workstation/tabs/factories/index.ts | 3 + .../workstation/tabs/factories/project.ts | 30 ++ src/store/workstation/tabs/index.ts | 7 +- src/store/workstation/tabs/tabFactory.ts | 1 + src/store/workstation/tabs/types.ts | 3 + 22 files changed, 895 insertions(+), 134 deletions(-) create mode 100644 src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx create mode 100644 src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx create mode 100644 src/modules/ProjectManager/JourneyGraph/components/BranchesGraph.tsx create mode 100644 src/modules/ProjectManager/JourneyGraph/components/CoverageLedger.tsx create mode 100644 src/modules/ProjectManager/JourneyGraph/components/EvidenceSource.tsx create mode 100644 src/modules/ProjectManager/JourneyGraph/components/FileLineagePanel.tsx create mode 100644 src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx create mode 100644 src/modules/ProjectManager/JourneyGraph/index.ts create mode 100644 src/modules/WorkStation/TabContent/renderers/sessionJourney.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 560940f2b5..86908e7f6e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5294,6 +5294,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "orgtrack_core", + "orgtrack_graph", "parking_lot 0.12.5", "perf_utils", "plist", diff --git a/src/api/tauri/journeyGraph/index.ts b/src/api/tauri/journeyGraph/index.ts index d6db71dc2e..3df542448b 100644 --- a/src/api/tauri/journeyGraph/index.ts +++ b/src/api/tauri/journeyGraph/index.ts @@ -6,6 +6,7 @@ export type EvidenceClass = | "derivedRule" | "aiAnnotation" | "userOverlay"; + export interface JourneyGraphNode { id: string; kind: string; @@ -13,6 +14,7 @@ export interface JourneyGraphNode { sourceRef: string; displayTimestamp?: string | null; } + export interface JourneyGraphEdge { from: string; to: string; @@ -20,10 +22,18 @@ export interface JourneyGraphEdge { evidenceClass: EvidenceClass; sourceRef: string; } + +export type JourneyCoverageStatus = + | "represented" + | "uncovered" + | { mergedInto: { target: string } } + | { excluded: { reason: string } }; + export interface JourneyCoverage { sourceRef: string; - status: "represented" | "mergedInto" | "excluded" | "uncovered"; + status: JourneyCoverageStatus; } + export interface JourneyGraphPayload { nodes: JourneyGraphNode[]; edges: JourneyGraphEdge[]; diff --git a/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx b/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx new file mode 100644 index 0000000000..70996a322e --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx @@ -0,0 +1,141 @@ +import { RefreshCw } from "lucide-react"; +import React, { useEffect, useState } from "react"; + +import { + type JourneyGraphPayload, + type JourneyScope, + journeyGraphQuery, +} from "@src/api/tauri/journeyGraph"; +import TabPill from "@src/components/TabPill"; + +import { BranchesGraph } from "./components/BranchesGraph"; +import { CoverageLedger } from "./components/CoverageLedger"; +import { FileLineagePanel } from "./components/FileLineagePanel"; +import { StorylineTimeline } from "./components/StorylineTimeline"; +import { + graphToBranchesViewModel, + graphToCoverageLedgerViewModel, + graphToFileLineageViewModel, + graphToStorylineViewModel, +} from "./viewModel"; + +type JourneyTab = "storyline" | "branches" | "files" | "coverage"; + +export interface JourneyContainerProps { + scope: JourneyScope; + title: string; +} + +interface JourneyResult { + scope: JourneyScope; + graph: JourneyGraphPayload | null; + error: string | null; +} + +const journeyTabs = [ + { key: "storyline", label: "Storyline" }, + { key: "branches", label: "Branches" }, + { key: "files", label: "File Lineage" }, + { key: "coverage", label: "Coverage" }, +]; + +function JourneyContent({ + graph, + activeTab, +}: { + graph: JourneyGraphPayload; + activeTab: JourneyTab; +}) { + switch (activeTab) { + case "branches": + return ; + case "files": + return ( + + ); + case "coverage": + return ( + + ); + case "storyline": + return ; + } +} + +/** The sole read-only query/container path for both project and session Journeys. */ +export const JourneyContainer: React.FC = ({ + scope, + title, +}) => { + const [result, setResult] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const [activeTab, setActiveTab] = useState("storyline"); + + useEffect(() => { + let cancelled = false; + void journeyGraphQuery(scope).then( + (graph) => { + if (!cancelled) setResult({ scope, graph, error: null }); + }, + (reason: unknown) => { + if (cancelled) return; + setResult({ + scope, + graph: null, + error: reason instanceof Error ? reason.message : String(reason), + }); + } + ); + return () => { + cancelled = true; + }; + }, [refreshKey, scope]); + + const currentResult = result?.scope === scope ? result : null; + const graph = currentResult?.graph ?? null; + const error = currentResult?.error ?? null; + + return ( +
+
+
+ {title} +
+ +
+
+ setActiveTab(tab as JourneyTab)} + variant="pill" + size="mini" + fillWidth={false} + /> +
+ {error && ( +
+ Journey unavailable: {error} +
+ )} + {!error && !graph && ( +
Loading Journey...
+ )} + {graph && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx new file mode 100644 index 0000000000..1dbf92d2eb --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +import type { JourneyScope } from "@src/api/tauri/journeyGraph"; + +import { JourneyContainer } from "./JourneyContainer"; + +export const SessionJourneyPage: React.FC<{ + sessionId: string; + sessionName?: string; +}> = ({ sessionId, sessionName }) => ( + +); diff --git a/src/modules/ProjectManager/JourneyGraph/components/BranchesGraph.tsx b/src/modules/ProjectManager/JourneyGraph/components/BranchesGraph.tsx new file mode 100644 index 0000000000..3f893e5e98 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/components/BranchesGraph.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import type { BranchesViewModel } from "../viewModel"; +import { EvidenceSource } from "./EvidenceSource"; + +export const BranchesGraph: React.FC<{ viewModel: BranchesViewModel }> = ({ + viewModel, +}) => ( +
+ {viewModel.links.length === 0 ? ( +

+ No factual branch, resume, or compaction edges. +

+ ) : ( +
+ {viewModel.nodes.map((node) => ( +
+
+ {node.kind}: {node.title} +
+ +
+ ))} +
+ {viewModel.links.map((link) => ( +
+ {link.kind}: {link.from} to{" "} + {link.to}{" "} + +
+ ))} +
+
+ )} +
+); diff --git a/src/modules/ProjectManager/JourneyGraph/components/CoverageLedger.tsx b/src/modules/ProjectManager/JourneyGraph/components/CoverageLedger.tsx new file mode 100644 index 0000000000..f4140b768f --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/components/CoverageLedger.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +import type { CoverageLedgerViewModel } from "../viewModel"; + +export const CoverageLedger: React.FC<{ + viewModel: CoverageLedgerViewModel; +}> = ({ viewModel }) => ( +
+
+
+ Independent provenance audit +
+
+ {viewModel.provenanceAudit === "notProvided" + ? "Not provided by the P1 payload; not inferred from coverage." + : viewModel.provenanceAudit} +
+
+
+ {Object.entries(viewModel.summary).map(([status, count]) => ( +
+ {status}: {count} +
+ ))} +
+
+ {viewModel.entries.map((entry) => ( +
+
+ {entry.statusKind}: {entry.sourceRef} +
+
{entry.detail}
+
+ ))} +
+
+); diff --git a/src/modules/ProjectManager/JourneyGraph/components/EvidenceSource.tsx b/src/modules/ProjectManager/JourneyGraph/components/EvidenceSource.tsx new file mode 100644 index 0000000000..af84cb75c8 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/components/EvidenceSource.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import type { JourneyEvidence } from "../viewModel"; + +export const EvidenceSource: React.FC = ({ + evidenceClass, + sourceRef, +}) => ( + + + {evidenceClass} + + + Source: {sourceRef} + + +); diff --git a/src/modules/ProjectManager/JourneyGraph/components/FileLineagePanel.tsx b/src/modules/ProjectManager/JourneyGraph/components/FileLineagePanel.tsx new file mode 100644 index 0000000000..33d30bf2e0 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/components/FileLineagePanel.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import type { FileLineageViewModel } from "../viewModel"; +import { EvidenceSource } from "./EvidenceSource"; + +export const FileLineagePanel: React.FC<{ + viewModel: FileLineageViewModel; +}> = ({ viewModel }) => ( +
+ {viewModel.links.length === 0 ? ( +

+ No factual produced or modified file edges. +

+ ) : ( +
+
+ {viewModel.files.map((file) => ( +
+
File: {file.title}
+ +
+ ))} +
+
+ {viewModel.adjacentNodes.map((node) => ( +
+ {node.kind}: {node.title}{" "} + +
+ ))} +
+
+ {viewModel.links.map((link) => ( +
+ {link.kind}: {link.from} to {link.to}{" "} + +
+ ))} +
+
+ )} +
+); diff --git a/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx b/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx new file mode 100644 index 0000000000..e0d238169e --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx @@ -0,0 +1,102 @@ +import React from "react"; + +import type { StorylineViewModel } from "../viewModel"; +import { EvidenceSource } from "./EvidenceSource"; + +export const StorylineTimeline: React.FC<{ viewModel: StorylineViewModel }> = ({ + viewModel, +}) => ( +
+

+ Real-time x-axis uses display timestamps only. Compressed spans are + labeled; connectors are factual edges. +

+ {viewModel.lanes.map((lane) => ( +
+

+ Agent / session lane: {lane.label} +

+
+ Real-time x-axis -> +
+
    + {lane.milestones.map((milestone) => ( + +
  1. +
    + {milestone.kind}: {milestone.title} +
    +
    + {milestone.displayTimestamp} + {milestone.sequence !== null + ? ` · turn ${milestone.sequence}` + : ""} +
    + +
  2. + {lane.gaps + .filter( + (gap) => gap.fromTimestamp === milestone.displayTimestamp + ) + .map((gap) => ( +
  3. + Idle compression: {Math.round(gap.durationMs / 60000)} min +
  4. + ))} +
    + ))} +
+
+ ))} + {viewModel.unpositioned.length > 0 && ( +
+

+ Facts without display time +

+ {viewModel.unpositioned.map((item) => ( +
+ + {item.kind}: {item.title}{" "} + + +
+ ))} +
+ )} +
+ {viewModel.connectors.map((connector) => ( +
+ {connector.kind}: {connector.from} to {connector.to}{" "} + +
+ ))} +
+
+); diff --git a/src/modules/ProjectManager/JourneyGraph/index.ts b/src/modules/ProjectManager/JourneyGraph/index.ts new file mode 100644 index 0000000000..a4fbc4f00d --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/index.ts @@ -0,0 +1,3 @@ +export { JourneyContainer } from "./JourneyContainer"; +export { SessionJourneyPage } from "./SessionJourneyPage"; +export * from "./viewModel"; diff --git a/src/modules/ProjectManager/JourneyGraph/viewModel.ts b/src/modules/ProjectManager/JourneyGraph/viewModel.ts index 1e9b1bea79..0677f95446 100644 --- a/src/modules/ProjectManager/JourneyGraph/viewModel.ts +++ b/src/modules/ProjectManager/JourneyGraph/viewModel.ts @@ -1,32 +1,356 @@ -import type { JourneyGraphPayload } from "@src/api/tauri/journeyGraph"; +import type { + EvidenceClass, + JourneyCoverage, + JourneyGraphEdge, + JourneyGraphNode, + JourneyGraphPayload, +} from "@src/api/tauri/journeyGraph"; + +export interface JourneyEvidence { + evidenceClass: EvidenceClass; + sourceRef: string; +} + +export interface JourneyDisplayNode extends JourneyEvidence { + id: string; + title: string; + kind: string; + displayTimestamp?: string | null; +} export interface JourneyViewModel { - nodes: Array<{ - id: string; - title: string; - kind: string; - evidenceClass: string; - sourceRef: string; - }>; + nodes: JourneyDisplayNode[]; files: string[]; } +export interface StorylineMilestone extends JourneyDisplayNode { + sequence: number | null; +} + +export interface StorylineIdleGap { + kind: "idleGap"; + fromTimestamp: string; + toTimestamp: string; + durationMs: number; +} + +export interface StorylineLane { + id: string; + label: string; + milestones: StorylineMilestone[]; + gaps: StorylineIdleGap[]; +} + +export interface StorylineConnector extends JourneyEvidence { + from: string; + to: string; + kind: string; +} + +export interface StorylineViewModel { + lanes: StorylineLane[]; + connectors: StorylineConnector[]; + unpositioned: StorylineMilestone[]; +} + +export interface BranchLink extends JourneyEvidence { + from: string; + to: string; + kind: "forkedFrom" | "resumedFrom" | "compactedTo"; +} + +export interface BranchesViewModel { + nodes: JourneyDisplayNode[]; + links: BranchLink[]; +} + +export interface FileLineageLink extends JourneyEvidence { + from: string; + to: string; + kind: "produced" | "modified"; +} + +export interface FileLineageViewModel { + files: JourneyDisplayNode[]; + adjacentNodes: JourneyDisplayNode[]; + links: FileLineageLink[]; +} + +export interface CoverageLedgerEntry extends JourneyCoverage { + statusKind: "represented" | "mergedInto" | "excluded" | "uncovered"; + detail: string; +} + +export interface CoverageLedgerViewModel { + entries: CoverageLedgerEntry[]; + summary: Record; + /** P1 does not serialize the independent audit result, so this must not be inferred. */ + provenanceAudit: "notProvided"; +} + +export const IDLE_GAP_MS = 15 * 60 * 1000; + +function assertCompleteGraph(graph: JourneyGraphPayload): void { + if (graph.coverage.some((entry) => entry.status === "uncovered")) { + throw new Error("Journey graph coverage is incomplete"); + } + for (const item of [...graph.nodes, ...graph.edges]) { + if (!item.evidenceClass || !item.sourceRef) { + throw new Error("Journey graph is missing required evidence"); + } + } +} + +function titleFromId(id: string): string { + return id.split("/").slice(1).join("/") || id; +} + +function toDisplayNode(node: JourneyGraphNode): JourneyDisplayNode { + return { + id: node.id, + title: titleFromId(node.id), + kind: node.kind, + evidenceClass: node.evidenceClass, + sourceRef: node.sourceRef, + displayTimestamp: node.displayTimestamp, + }; +} + +function compareNodes(left: JourneyGraphNode, right: JourneyGraphNode): number { + return left.id.localeCompare(right.id); +} + +function compareEdges(left: JourneyGraphEdge, right: JourneyGraphEdge): number { + return ( + left.from.localeCompare(right.from) || + left.to.localeCompare(right.to) || + left.kind.localeCompare(right.kind) || + left.sourceRef.localeCompare(right.sourceRef) + ); +} + +function parseTimestamp(value: string | null | undefined): number | null { + if (!value) return null; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function turnSequence(id: string): number | null { + const match = /^turn\/[^/]+\/(\d+)$/.exec(id); + return match ? Number(match[1]) : null; +} + +function sessionIdForNode( + node: JourneyGraphNode, + edges: JourneyGraphEdge[] +): string | null { + if (node.kind === "session") return node.id; + const owner = edges.find( + (edge) => + edge.to === node.id && + edge.kind === "contains" && + edge.from.startsWith("session/") + ); + return owner?.from ?? null; +} + /** Pure presentation mapping. It cannot infer or repair graph facts. */ export function graphToJourneyViewModel( graph: JourneyGraphPayload ): JourneyViewModel { - if (graph.coverage.some((entry) => entry.status === "uncovered")) - throw new Error("Journey graph coverage is incomplete"); + assertCompleteGraph(graph); + const nodes = [...graph.nodes].sort(compareNodes).map(toDisplayNode); + return { + nodes, + files: nodes.filter((node) => node.kind === "file").map((node) => node.id), + }; +} + +/** Builds session lanes from explicit contains edges only; unlinked facts stay unlinked. */ +export function graphToStorylineViewModel( + graph: JourneyGraphPayload, + idleGapMs = IDLE_GAP_MS +): StorylineViewModel { + assertCompleteGraph(graph); + const edges = [...graph.edges].sort(compareEdges); + const laneMembers = new Map(); + const unpositioned: StorylineMilestone[] = []; + + for (const node of [...graph.nodes].sort(compareNodes)) { + const timestamp = parseTimestamp(node.displayTimestamp); + if (timestamp === null) { + unpositioned.push({ + ...toDisplayNode(node), + sequence: turnSequence(node.id), + }); + continue; + } + const laneId = sessionIdForNode(node, edges) ?? "unlinked-facts"; + const lane = laneMembers.get(laneId) ?? []; + lane.push(node); + laneMembers.set(laneId, lane); + } + + const lanes = [...laneMembers.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, members]) => { + const milestones = [...members] + .sort((left, right) => { + const leftTimestamp = parseTimestamp(left.displayTimestamp) ?? 0; + const rightTimestamp = parseTimestamp(right.displayTimestamp) ?? 0; + return ( + leftTimestamp - rightTimestamp || left.id.localeCompare(right.id) + ); + }) + .map((node) => ({ + ...toDisplayNode(node), + sequence: turnSequence(node.id), + })); + const gaps: StorylineIdleGap[] = []; + for (let index = 1; index < milestones.length; index += 1) { + const previous = milestones[index - 1]; + const current = milestones[index]; + const previousTimestamp = parseTimestamp(previous.displayTimestamp); + const currentTimestamp = parseTimestamp(current.displayTimestamp); + if ( + previousTimestamp !== null && + currentTimestamp !== null && + currentTimestamp - previousTimestamp > idleGapMs + ) { + gaps.push({ + kind: "idleGap", + fromTimestamp: previous.displayTimestamp!, + toTimestamp: current.displayTimestamp!, + durationMs: currentTimestamp - previousTimestamp, + }); + } + } + return { + id, + label: id === "unlinked-facts" ? "Unlinked facts" : titleFromId(id), + milestones, + gaps, + }; + }); + + const connectors = edges + .filter((edge) => + ["forkedFrom", "resumedFrom", "compactedTo", "handoffTo"].includes( + edge.kind + ) + ) + .map((edge) => ({ + from: edge.from, + to: edge.to, + kind: edge.kind, + evidenceClass: edge.evidenceClass, + sourceRef: edge.sourceRef, + })); return { - nodes: graph.nodes.map((node) => ({ - id: node.id, - title: node.id.split("/").slice(1).join("/"), - kind: node.kind, - evidenceClass: node.evidenceClass, - sourceRef: node.sourceRef, - })), - files: graph.nodes - .filter((node) => node.kind === "file") - .map((node) => node.id), + lanes, + connectors, + unpositioned: unpositioned.sort((left, right) => + left.id.localeCompare(right.id) + ), + }; +} + +export function graphToBranchesViewModel( + graph: JourneyGraphPayload +): BranchesViewModel { + assertCompleteGraph(graph); + const ids = new Set(graph.nodes.map((node) => node.id)); + const links = [...graph.edges] + .sort(compareEdges) + .filter((edge): edge is JourneyGraphEdge & { kind: BranchLink["kind"] } => + ["forkedFrom", "resumedFrom", "compactedTo"].includes(edge.kind) + ) + .filter((edge) => ids.has(edge.from) && ids.has(edge.to)) + .map((edge) => ({ + from: edge.from, + to: edge.to, + kind: edge.kind, + evidenceClass: edge.evidenceClass, + sourceRef: edge.sourceRef, + })); + const referenced = new Set(links.flatMap((link) => [link.from, link.to])); + return { + nodes: [...graph.nodes] + .filter((node) => referenced.has(node.id)) + .sort(compareNodes) + .map(toDisplayNode), + links, + }; +} + +export function graphToFileLineageViewModel( + graph: JourneyGraphPayload +): FileLineageViewModel { + assertCompleteGraph(graph); + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const links = [...graph.edges] + .sort(compareEdges) + .filter( + (edge): edge is JourneyGraphEdge & { kind: FileLineageLink["kind"] } => + ["produced", "modified"].includes(edge.kind) + ) + .filter( + (edge) => + byId.get(edge.from)?.kind === "file" || + byId.get(edge.to)?.kind === "file" + ) + .map((edge) => ({ + from: edge.from, + to: edge.to, + kind: edge.kind, + evidenceClass: edge.evidenceClass, + sourceRef: edge.sourceRef, + })); + const adjacentIds = new Set(links.flatMap((link) => [link.from, link.to])); + return { + files: [...graph.nodes] + .filter((node) => node.kind === "file" && adjacentIds.has(node.id)) + .sort(compareNodes) + .map(toDisplayNode), + adjacentNodes: [...graph.nodes] + .filter((node) => node.kind !== "file" && adjacentIds.has(node.id)) + .sort(compareNodes) + .map(toDisplayNode), + links, + }; +} + +function coverageDetail(entry: JourneyCoverage): string { + if (entry.status === "represented") return "Represented in the Journey graph"; + if (entry.status === "uncovered") return "Uncovered canonical source unit"; + if ("mergedInto" in entry.status) + return `Merged into: ${entry.status.mergedInto.target}`; + return `Excluded: ${entry.status.excluded.reason}`; +} + +function coverageStatusKind( + entry: JourneyCoverage +): CoverageLedgerEntry["statusKind"] { + if (typeof entry.status === "string") return entry.status; + return "mergedInto" in entry.status ? "mergedInto" : "excluded"; +} + +export function graphToCoverageLedgerViewModel( + graph: JourneyGraphPayload +): CoverageLedgerViewModel { + assertCompleteGraph(graph); + const summary: CoverageLedgerViewModel["summary"] = { + represented: 0, + mergedInto: 0, + excluded: 0, + uncovered: 0, }; + const entries = [...graph.coverage] + .sort((left, right) => left.sourceRef.localeCompare(right.sourceRef)) + .map((entry) => { + const statusKind = coverageStatusKind(entry); + summary[statusKind] += 1; + return { ...entry, statusKind, detail: coverageDetail(entry) }; + }); + return { entries, summary, provenanceAudit: "notProvided" }; } diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx index 706f4ce719..03f708a368 100644 --- a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx +++ b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx @@ -1,14 +1,7 @@ -import { RefreshCw } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React from "react"; -import { - type JourneyScope, - journeyGraphQuery, -} from "@src/api/tauri/journeyGraph"; -import { - type JourneyViewModel, - graphToJourneyViewModel, -} from "@src/modules/ProjectManager/JourneyGraph/viewModel"; +import type { JourneyScope } from "@src/api/tauri/journeyGraph"; +import { JourneyContainer } from "@src/modules/ProjectManager/JourneyGraph"; export interface ProjectJourneyPageProps { projectId?: string; @@ -17,114 +10,25 @@ export interface ProjectJourneyPageProps { forceDemo?: boolean; } -interface JourneyResult { - scope: JourneyScope; - view: JourneyViewModel | null; - error: string | null; -} - -/** P1 factual view. Narrative controls and local inference belong to later phases. */ +/** Read-only project wrapper over the shared Journey graph container. */ const ProjectJourneyPage: React.FC = ({ projectId, projectSlug, projectName, }) => { - const [result, setResult] = useState(null); - const [refreshKey, setRefreshKey] = useState(0); - const scope = projectId - ? (`project/${projectId}` as JourneyScope) - : projectSlug - ? (`project/${projectSlug}` as JourneyScope) - : null; - - useEffect(() => { - if (!scope) return; - - let cancelled = false; - void journeyGraphQuery(scope).then( - (graph) => { - if (cancelled) return; - setResult({ - scope, - view: graphToJourneyViewModel(graph), - error: null, - }); - }, - (reason: unknown) => { - if (cancelled) return; - setResult({ - scope, - view: null, - error: reason instanceof Error ? reason.message : String(reason), - }); - } + const identity = projectId ?? projectSlug; + if (!identity) + return ( +
+ Journey unavailable: Project identity is required; refusing to guess a + Journey graph. +
); - - return () => { - cancelled = true; - }; - }, [refreshKey, scope]); - - const currentResult = result?.scope === scope ? result : null; - const error = scope - ? (currentResult?.error ?? null) - : "Project identity is required; refusing to guess a Journey graph."; - const view = currentResult?.view ?? null; - return ( -
-
-
- Project Journey{projectName ? ` · ${projectName}` : ""} -
- -
- {error && ( -
- Journey unavailable: {error} -
- )} - {!error && !view && ( -
Loading Journey...
- )} - {view && ( -
-
- {view.nodes.length} canonical graph nodes -
-
- {view.nodes.map((node) => ( -
-
{node.title}
-
- {node.kind} · {node.evidenceClass} -
-
- {node.sourceRef} -
-
- ))} -
-
- )} -
+ ); }; diff --git a/src/modules/WorkStation/TabContent/registry.ts b/src/modules/WorkStation/TabContent/registry.ts index 8519dd94ef..6f08a1d031 100644 --- a/src/modules/WorkStation/TabContent/registry.ts +++ b/src/modules/WorkStation/TabContent/registry.ts @@ -213,6 +213,11 @@ const ProjectJourneyEntry: RendererEntry = { debugLabel: "project-journey", }; +const SessionJourneyEntry: RendererEntry = { + Component: lazy(() => import("./renderers/sessionJourney")), + debugLabel: "session-journey", +}; + const WorkItemDetailEntry: RendererEntry = { Component: lazy(() => import("./renderers/workItemDetail")), debugLabel: "workItem-detail", @@ -297,6 +302,7 @@ export const REGISTRY: TabContentRegistry = { "project-git-sync-review": ProjectGitSyncReviewEntry, "project-tree": ProjectTreeEntry, "project-journey": ProjectJourneyEntry, + "session-journey": SessionJourneyEntry, "project-workitems": ProjectWorkitemsEntry, "workItem-detail": WorkItemDetailEntry, "chat-session": ChatSessionEntry, diff --git a/src/modules/WorkStation/TabContent/renderers/index.ts b/src/modules/WorkStation/TabContent/renderers/index.ts index c3e93775e6..11a2d60709 100644 --- a/src/modules/WorkStation/TabContent/renderers/index.ts +++ b/src/modules/WorkStation/TabContent/renderers/index.ts @@ -21,6 +21,7 @@ export { default as ProjectSettingsTabRenderer } from "./projectSettings"; export { default as ProjectWorkItemsTabRenderer } from "./projectWorkItems"; export { default as ProjectTreeTabRenderer } from "./projectTree"; export { default as ProjectJourneyTabRenderer } from "./projectJourney"; +export { default as SessionJourneyTabRenderer } from "./sessionJourney"; export { default as ProjectWorkitemsCompatTabRenderer } from "./projectWorkitemsCompat"; export { default as SearchTabRenderer } from "./search"; export { default as SettingsTabRenderer } from "./settings"; diff --git a/src/modules/WorkStation/TabContent/renderers/sessionJourney.tsx b/src/modules/WorkStation/TabContent/renderers/sessionJourney.tsx new file mode 100644 index 0000000000..6c4a58f709 --- /dev/null +++ b/src/modules/WorkStation/TabContent/renderers/sessionJourney.tsx @@ -0,0 +1,28 @@ +/** Renderer for read-only `session-journey` tabs. */ +import React, { memo } from "react"; + +import { SessionJourneyPage } from "@src/modules/ProjectManager/JourneyGraph"; + +import type { UnifiedTabContentProps } from "../types"; + +const SessionJourneyTabRenderer: React.FC = memo( + ({ tab }) => { + const sessionId = tab.data.sessionId as string | undefined; + if (!sessionId) + return ( +
+ Journey unavailable: Session identity is required; refusing to guess a + Journey graph. +
+ ); + return ( + + ); + } +); + +SessionJourneyTabRenderer.displayName = "SessionJourneyTabRenderer"; +export default SessionJourneyTabRenderer; diff --git a/src/store/workstation/tabHost.ts b/src/store/workstation/tabHost.ts index ff541f82a0..e3358a1d00 100644 --- a/src/store/workstation/tabHost.ts +++ b/src/store/workstation/tabHost.ts @@ -58,6 +58,7 @@ export function tabTypeToTabHost(type: WorkStationTabType): WorkstationTabHost { case "project-org": case "project-org-settings": case "project-git-sync-review": + case "session-journey": case "project-workitems": case "workItem-detail": return "project"; diff --git a/src/store/workstation/tabs/__tests__/workspaceState.test.ts b/src/store/workstation/tabs/__tests__/workspaceState.test.ts index d27a1d6181..693b6ba605 100644 --- a/src/store/workstation/tabs/__tests__/workspaceState.test.ts +++ b/src/store/workstation/tabs/__tests__/workspaceState.test.ts @@ -62,6 +62,7 @@ const EXPECTED_OWNERSHIP: Record = "project-git-sync-review": "shared-resource", "project-tree": "shared-resource", "project-journey": "shared-resource", + "session-journey": "shared-resource", "project-workitems": "shared-resource", "workItem-detail": "shared-resource", "chat-session": "shared-resource", @@ -121,7 +122,7 @@ describe("WorkStation tab ownership policy", () => { }) ); - expect(results).toHaveLength(41); + expect(results).toHaveLength(42); expect(results.every(({ actual, expected }) => actual === expected)).toBe( true ); diff --git a/src/store/workstation/tabs/factories/index.ts b/src/store/workstation/tabs/factories/index.ts index 66266f3e75..bace44d6da 100644 --- a/src/store/workstation/tabs/factories/index.ts +++ b/src/store/workstation/tabs/factories/index.ts @@ -101,6 +101,7 @@ export { projectWorkItemsTabFactory, projectTreeTabFactory, projectJourneyTabFactory, + sessionJourneyTabFactory, workItemDetailTabFactory, createProjectDashboardTab, createProjectWorkItemsIndexTab, @@ -115,6 +116,7 @@ export { createProjectWorkItemsTab, createProjectTreeTab, createProjectJourneyTab, + createSessionJourneyTab, createWorkItemDetailTab, getProjectLinearProjectsTabChrome, getProjectLinearWorkItemsTabChrome, @@ -134,6 +136,7 @@ export type { ProjectWorkItemsTabData, WorkItemDetailTabData, ProjectJourneyTabData, + SessionJourneyTabData, } from "./project"; export type { NewWorkItemTabData } from "../types"; diff --git a/src/store/workstation/tabs/factories/project.ts b/src/store/workstation/tabs/factories/project.ts index 5d586856bc..3029e008f4 100644 --- a/src/store/workstation/tabs/factories/project.ts +++ b/src/store/workstation/tabs/factories/project.ts @@ -108,6 +108,11 @@ export function resolveProjectManagerTabTitle( return name ? `${name} ${journeyLabel}` : journeyLabel; } + if (tab.type === "session-journey") { + const name = tab.data.sessionName as string | undefined; + return name ? `${name} Journey` : "Session Journey"; + } + if ( tab.type === "project-linear-projects" || tab.type === "project-linear-work-items" @@ -559,3 +564,28 @@ export function createProjectJourneyTab( ): WorkStationTab { return projectJourneyTabFactory(data); } + +export interface SessionJourneyTabData { + sessionId: string; + sessionName?: string; +} + +export const sessionJourneyTabFactory = defineTabFactory( + { + tabType: "session-journey", + idStrategy: { + type: "keyed", + prefix: "session-journey", + getKey: (data) => data.sessionId, + }, + getTitle: (data) => + data.sessionName ? `${data.sessionName} Journey` : "Session Journey", + icon: "GitFork", + } +); + +export function createSessionJourneyTab( + data: SessionJourneyTabData +): WorkStationTab { + return sessionJourneyTabFactory(data); +} diff --git a/src/store/workstation/tabs/index.ts b/src/store/workstation/tabs/index.ts index d7b973b536..1a7d4dcdb9 100644 --- a/src/store/workstation/tabs/index.ts +++ b/src/store/workstation/tabs/index.ts @@ -168,6 +168,7 @@ export { projectWorkItemsTabFactory, projectTreeTabFactory, projectJourneyTabFactory, + sessionJourneyTabFactory, workItemDetailTabFactory, createProjectDashboardTab, createProjectWorkItemsIndexTab, @@ -182,6 +183,7 @@ export { createProjectWorkItemsTab, createProjectTreeTab, createProjectJourneyTab, + createSessionJourneyTab, createWorkItemDetailTab, getProjectLinearProjectsTabChrome, getProjectLinearWorkItemsTabChrome, @@ -279,4 +281,7 @@ export { switchActiveRepoAtom, } from "./editorCache"; -export type { ProjectJourneyTabData } from "./factories/project"; +export type { + ProjectJourneyTabData, + SessionJourneyTabData, +} from "./factories/project"; diff --git a/src/store/workstation/tabs/tabFactory.ts b/src/store/workstation/tabs/tabFactory.ts index fc234435c4..6ccf47ca44 100644 --- a/src/store/workstation/tabs/tabFactory.ts +++ b/src/store/workstation/tabs/tabFactory.ts @@ -96,6 +96,7 @@ const DEFAULT_CATEGORY_BY_TYPE: Record< "project-work-items": "project", "project-tree": "project", "project-journey": "project", + "session-journey": "project", "project-linear-projects": "project", "project-linear-work-items": "project", "project-settings": "project", diff --git a/src/store/workstation/tabs/types.ts b/src/store/workstation/tabs/types.ts index 65f20577d7..59d34468c9 100644 --- a/src/store/workstation/tabs/types.ts +++ b/src/store/workstation/tabs/types.ts @@ -51,6 +51,7 @@ export type WorkStationTabType = | "project-git-sync-review" | "project-tree" | "project-journey" + | "session-journey" | "project-workitems" | "workItem-detail" | "chat-session" @@ -266,6 +267,7 @@ export function getWorkstationTabOwnership( case "project-git-sync-review": case "project-tree": case "project-journey": + case "session-journey": case "project-workitems": case "workItem-detail": case "chat-session": @@ -525,6 +527,7 @@ export const TOOL_TAB_TYPES = [ "project-org-settings", "project-tree", "project-journey", + "session-journey", "project-workitems", "workItem-detail", "chat-session", From ed3ae6b90b8110f1ed554ce91adffd0642aa7197 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Thu, 30 Jul 2026 10:33:03 +0800 Subject: [PATCH 10/32] test(p2): cover journey visualization projections (cherry picked from commit 8a3ec2b4c398d182ae1f51cdc183a4a31111c2f7) --- .../__tests__/components.p2.test.ts | 40 +++++++++++ .../__tests__/viewModel.p2.test.ts | 69 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts create mode 100644 src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts new file mode 100644 index 0000000000..7edbc50575 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts @@ -0,0 +1,40 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { JourneyGraphPayload } from "@src/api/tauri/journeyGraph"; + +import { BranchesGraph } from "../components/BranchesGraph"; +import { CoverageLedger } from "../components/CoverageLedger"; +import { FileLineagePanel } from "../components/FileLineagePanel"; +import { StorylineTimeline } from "../components/StorylineTimeline"; +import { graphToBranchesViewModel, graphToCoverageLedgerViewModel, graphToFileLineageViewModel, graphToStorylineViewModel } from "../viewModel"; + +const graph: JourneyGraphPayload = { + nodes: [ + { id: "session/s", kind: "session", evidenceClass: "canonical", sourceRef: "session:s", displayTimestamp: "2026-07-30T08:00:00.000Z" }, + { id: "session/parent", kind: "session", evidenceClass: "canonical", sourceRef: "session:parent", displayTimestamp: "2026-07-30T07:00:00.000Z" }, + { id: "artifact/orgtrack/a", kind: "artifact", evidenceClass: "canonical", sourceRef: "artifact:a" }, + { id: "file/repo/a.ts", kind: "file", evidenceClass: "canonical", sourceRef: "file:a" }, + ], + edges: [ + { from: "session/s", to: "session/parent", kind: "forkedFrom", evidenceClass: "canonical", sourceRef: "fork:s:parent" }, + { from: "artifact/orgtrack/a", to: "file/repo/a.ts", kind: "produced", evidenceClass: "canonical", sourceRef: "artifact:a" }, + ], + coverage: [{ sourceRef: "session:s", status: "represented" }], +}; + +describe("P2 Journey components", () => { + it("renders evidence badges and source drill targets for every graph view", () => { + const markup = [ + renderToStaticMarkup(createElement(StorylineTimeline, { viewModel: graphToStorylineViewModel(graph) })), + renderToStaticMarkup(createElement(BranchesGraph, { viewModel: graphToBranchesViewModel(graph) })), + renderToStaticMarkup(createElement(FileLineagePanel, { viewModel: graphToFileLineageViewModel(graph) })), + renderToStaticMarkup(createElement(CoverageLedger, { viewModel: graphToCoverageLedgerViewModel(graph) })), + ].join("\n"); + expect(markup).toContain("canonical"); + expect(markup).toContain("Source: session:s"); + expect(markup).toContain("#journey-source-session%3As"); + expect(markup).toContain("Independent provenance audit"); + }); +}); diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts new file mode 100644 index 0000000000..a1c794c5cc --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { JourneyGraphPayload } from "@src/api/tauri/journeyGraph"; + +import { + graphToBranchesViewModel, + graphToCoverageLedgerViewModel, + graphToFileLineageViewModel, + graphToStorylineViewModel, +} from "../viewModel"; + +const payload: JourneyGraphPayload = { + nodes: [ + { id: "session/z", kind: "session", evidenceClass: "canonical", sourceRef: "session:z", displayTimestamp: "2026-07-30T10:00:00.000Z" }, + { id: "session/a", kind: "session", evidenceClass: "canonical", sourceRef: "session:a", displayTimestamp: "2026-07-30T08:00:00.000Z" }, + { id: "turn/a/2", kind: "turn", evidenceClass: "canonical", sourceRef: "turn:a:2", displayTimestamp: "2026-07-30T08:30:00.000Z" }, + { id: "turn/a/1", kind: "turn", evidenceClass: "canonical", sourceRef: "turn:a:1", displayTimestamp: "2026-07-30T08:00:00.000Z" }, + { id: "artifact/orgtrack/a", kind: "artifact", evidenceClass: "canonical", sourceRef: "artifact:a" }, + { id: "file/repo/src/a.ts", kind: "file", evidenceClass: "canonical", sourceRef: "file:a" }, + { id: "commit/repo/abc", kind: "commit", evidenceClass: "canonical", sourceRef: "commit:abc" }, + ], + edges: [ + { from: "session/a", to: "turn/a/1", kind: "contains", evidenceClass: "canonical", sourceRef: "turn:a:1" }, + { from: "session/a", to: "turn/a/2", kind: "contains", evidenceClass: "canonical", sourceRef: "turn:a:2" }, + { from: "session/z", to: "session/a", kind: "forkedFrom", evidenceClass: "canonical", sourceRef: "fork:z:a" }, + { from: "session/a", to: "session/z", kind: "resumedFrom", evidenceClass: "canonical", sourceRef: "resume:a:z" }, + { from: "artifact/orgtrack/a", to: "file/repo/src/a.ts", kind: "modified", evidenceClass: "canonical", sourceRef: "artifact:a" }, + { from: "session/a", to: "commit/repo/abc", kind: "committedIn", evidenceClass: "canonical", sourceRef: "commit:abc" }, + ], + coverage: [ + { sourceRef: "turn:a:1", status: "represented" }, + { sourceRef: "legacy", status: { mergedInto: { target: "turn:a:1" } } }, + { sourceRef: "policy", status: { excluded: { reason: "retention" } } }, + ], +}; + +describe("P2 Journey view models", () => { + it("creates explicit idle compression and stable session lanes", () => { + const view = graphToStorylineViewModel(payload, 10 * 60 * 1000); + expect(view.lanes.map((lane) => lane.id)).toEqual(["session/a", "session/z"]); + expect(view.lanes[0].milestones.map((milestone) => milestone.id)).toEqual(["session/a", "turn/a/1", "turn/a/2"]); + expect(view.lanes[0].gaps).toEqual([{ kind: "idleGap", fromTimestamp: "2026-07-30T08:00:00.000Z", toTimestamp: "2026-07-30T08:30:00.000Z", durationMs: 30 * 60 * 1000 }]); + expect(view.unpositioned.map((item) => item.id)).toContain("artifact/orgtrack/a"); + }); + + it("uses actual branch edges only, never timestamp proximity", () => { + const graph = { ...payload, edges: payload.edges.filter((edge) => edge.kind !== "forkedFrom" && edge.kind !== "resumedFrom") }; + expect(graphToBranchesViewModel(graph).links).toEqual([]); + expect(graphToBranchesViewModel(payload).links.map((link) => link.kind)).toEqual(["resumedFrom", "forkedFrom"]); + }); + + it("includes only produced or modified edges connected to factual file nodes", () => { + const graph: JourneyGraphPayload = { ...payload, edges: [...payload.edges, { from: "session/a", to: "commit/repo/abc", kind: "produced", evidenceClass: "canonical", sourceRef: "not-a-file" }] }; + const view = graphToFileLineageViewModel(graph); + expect(view.links).toEqual([{ from: "artifact/orgtrack/a", to: "file/repo/src/a.ts", kind: "modified", evidenceClass: "canonical", sourceRef: "artifact:a" }]); + expect(view.files.map((file) => file.id)).toEqual(["file/repo/src/a.ts"]); + }); + + it("keeps coverage and independent provenance state separate", () => { + const view = graphToCoverageLedgerViewModel(payload); + expect(view.summary).toEqual({ represented: 1, mergedInto: 1, excluded: 1, uncovered: 0 }); + expect(view.entries.find((entry) => entry.sourceRef === "policy")?.detail).toBe("Excluded: retention"); + expect(view.provenanceAudit).toBe("notProvided"); + }); + + it("fails closed for uncovered coverage", () => { + expect(() => graphToCoverageLedgerViewModel({ ...payload, coverage: [...payload.coverage, { sourceRef: "missing", status: "uncovered" }] })).toThrow("incomplete"); + }); +}); From 1f6148d4bb1279072395ba374f7d74e6b76b4025 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Thu, 30 Jul 2026 17:49:57 +0800 Subject: [PATCH 11/32] fix(project): wire journey entries into the sidebar (cherry picked from commit eca1065ff63b692faa6b2574f1f432b39d6c4c9a) --- src/i18n/locales/en/projects.json | 1 + src/i18n/locales/zh/projects.json | 1 + .../content/OrgSidebarTreeContent.tsx | 2 + .../content/WorkspaceOrgTreeContent.tsx | 61 +++++++++---------- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index 3c2bdf94a2..15fc44dc78 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -514,6 +514,7 @@ "openSession": "Open Session", "viewLiveChat": "View Live Chat", "viewConversation": "View Conversation", + "viewSessionJourney": "Session Journey", "noWorkflowRun": "No agent workflow has been run yet", "confirmStart": "Start the SDE agent on this work item?", "sdePhase": "SDE Development", diff --git a/src/i18n/locales/zh/projects.json b/src/i18n/locales/zh/projects.json index 4d5af97161..5e4535d28f 100644 --- a/src/i18n/locales/zh/projects.json +++ b/src/i18n/locales/zh/projects.json @@ -515,6 +515,7 @@ "openSession": "打开会话", "viewLiveChat": "查看实时 Chat", "viewConversation": "查看对话记录", + "viewSessionJourney": "会话旅程", "noWorkflowRun": "尚未运行 Agent 工作流", "confirmStart": "在此工作项上启动 SDE Agent?", "sdePhase": "SDE 开发", diff --git a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx index 0f34244dd3..da20de4396 100644 --- a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx +++ b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx @@ -50,6 +50,8 @@ export { WorkspaceOrgTreeContent } from "./WorkspaceOrgTreeContent"; interface ProjectSidebarTreeContentProps { onOpenProjects: () => void; onOpenWorkItems: () => void; + onOpenProjectTree?: () => void; + onOpenProjectJourney?: () => void; onOpenPersonalOrg: (view?: ProjectOrgSurfaceView) => void; onOpenProjectOrg: (org: ProjectOrg, view?: ProjectOrgSurfaceView) => void; onOpenLinearProjects: (selection?: LinearProjectSelection) => void; diff --git a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx index ee162cd9c2..5dc010bccd 100644 --- a/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx +++ b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceOrgTreeContent.tsx @@ -7,12 +7,12 @@ * Extracted from OrgSidebarTreeContent.tsx to reduce file size. */ import { - BookOpen, Box, - GitBranch, Layers, ListChecks, + Map, MoreHorizontal, + Network, } from "lucide-react"; import React, { memo, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -66,54 +66,52 @@ export const WorkspaceOrgTreeContent: React.FC = { depth: 0, node: { - id: "project-sidebar:workspace:project-tree", - name: t("workspace.projectTree"), - path: "project-sidebar:workspace:project-tree", + id: "project-sidebar:workspace:work-items", + name: t("workspace.workItems"), + path: "project-sidebar:workspace:work-items", type: "file", icon: ( - + ), - kind: "workspace-project-tree", + kind: "workspace-work-items", }, }, { depth: 0, node: { - id: "project-sidebar:workspace:project-journey", - name: t("workspace.projectJourney"), - path: "project-sidebar:workspace:project-journey", + id: "project-sidebar:workspace:projects", + name: t("workspace.projects"), + path: "project-sidebar:workspace:projects", type: "file", - icon: ( - - ), - kind: "workspace-project-journey", + icon: , + kind: "workspace-projects", }, }, { depth: 0, node: { - id: "project-sidebar:workspace:work-items", - name: t("workspace.workItems"), - path: "project-sidebar:workspace:work-items", + id: "project-sidebar:workspace:project-tree", + name: t("workspace.projectTree"), + path: "project-sidebar:workspace:project-tree", type: "file", icon: ( - + ), - kind: "workspace-work-items", + kind: "workspace-project-tree", }, }, { depth: 0, node: { - id: "project-sidebar:workspace:projects", - name: t("workspace.projects"), - path: "project-sidebar:workspace:projects", + id: "project-sidebar:workspace:project-journey", + name: t("workspace.projectJourney"), + path: "project-sidebar:workspace:project-journey", type: "file", - icon: , - kind: "workspace-projects", + icon: , + kind: "workspace-project-journey", }, }, { @@ -169,8 +167,9 @@ export const WorkspaceOrgTreeContent: React.FC = const isActionable = node.kind === "workspace-projects" || node.kind === "workspace-work-items" || - node.kind === "workspace-project-tree" || - node.kind === "workspace-project-journey"; + (node.kind === "workspace-project-tree" && !!onOpenProjectTree) || + (node.kind === "workspace-project-journey" && + !!onOpenProjectJourney); const handleClick = node.kind === "workspace-projects" ? onOpenProjects @@ -196,10 +195,10 @@ export const WorkspaceOrgTreeContent: React.FC = [ activeOrgScope, activeRepoView, - onOpenProjectJourney, - onOpenProjectTree, onOpenProjects, onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, ] ); From c8838f0375d7b17a79d408b89afd5482f8aeef84 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Fri, 31 Jul 2026 20:22:10 +0800 Subject: [PATCH 12/32] feat(journey): improve storyline readability (cherry picked from commit e55b2eb3b8c286a6276229ad86829704f83889dd) --- .../__tests__/timelineLayout.test.ts | 205 ++++++++ .../components/StorylineTimeline.tsx | 445 ++++++++++++++---- .../JourneyGraph/timelineLayout.ts | 256 ++++++++++ 3 files changed, 818 insertions(+), 88 deletions(-) create mode 100644 src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts create mode 100644 src/modules/ProjectManager/JourneyGraph/timelineLayout.ts diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts new file mode 100644 index 0000000000..bffa72c23e --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { + LANE_HEIGHT, + PAD_LEFT, + PAD_TOP, + TIMELINE_CAP_MS, + TIMELINE_FLOOR_MS, + TIMELINE_IDLE_GAP_MS, + buildCompressedAxis, + formatDuration, + formatTick, + layoutStoryline, +} from "../timelineLayout"; +import type { StorylineViewModel } from "../viewModel"; + +const MIN = 60 * 1000; + +function milestone( + id: string, + ts: string | null, + kind = "turn", + sequence: number | null = null +) { + return { + id, + title: id.split("/").slice(1).join("/") || id, + kind, + evidenceClass: "canonical" as const, + sourceRef: `src:${id}`, + displayTimestamp: ts, + sequence, + }; +} + +const base: StorylineViewModel = { + lanes: [ + { + id: "session/a", + label: "a", + milestones: [ + milestone("session/a", "2026-07-30T08:00:00.000Z", "session"), + milestone("turn/a/1", "2026-07-30T08:05:00.000Z", "turn", 1), + ], + gaps: [], + }, + { + id: "session/b", + label: "b", + milestones: [ + milestone("session/b", "2026-07-30T12:00:00.000Z", "session"), + ], + gaps: [], + }, + ], + connectors: [ + { + from: "session/b", + to: "session/a", + kind: "forkedFrom", + evidenceClass: "canonical", + sourceRef: "fork:b:a", + }, + { + from: "session/a", + to: "session/missing", + kind: "resumedFrom", + evidenceClass: "canonical", + sourceRef: "resume:a:missing", + }, + { + from: "session/a", + to: "turn/a/1", + kind: "handoffTo", + evidenceClass: "canonical", + sourceRef: "handoff:a:1", + }, + ], + unpositioned: [milestone("turn/c/1", null, "turn", 1)], +}; + +describe("buildCompressedAxis", () => { + it("clamps short bursts up to the floor and long idle down to the cap", () => { + const t0 = Date.parse("2026-07-30T08:00:00.000Z"); + const axis = buildCompressedAxis([ + t0, + t0 + 1000, + t0 + 1000 + 10 * 60 * MIN, + ]); + expect(axis.points[1].comp - axis.points[0].comp).toBe(TIMELINE_FLOOR_MS); + expect(axis.points[2].comp - axis.points[1].comp).toBe(TIMELINE_CAP_MS); + }); + + it("marks intervals above the idle threshold as idle bands", () => { + const t0 = Date.parse("2026-07-30T08:00:00.000Z"); + const axis = buildCompressedAxis([t0, t0 + TIMELINE_IDLE_GAP_MS + 1000]); + expect(axis.idleBands).toHaveLength(1); + expect(axis.idleBands[0].ms).toBe(TIMELINE_IDLE_GAP_MS + 1000); + }); + + it("handles empty input without throwing", () => { + const axis = buildCompressedAxis([]); + expect(axis.compTotal).toBe(0); + expect(axis.xOf(123)).toBe(0); + }); +}); + +describe("layoutStoryline", () => { + it("places timestamped milestones per lane and leaves untimed facts unpositioned", () => { + const layout = layoutStoryline(base); + expect(layout.lanes).toHaveLength(2); + expect(layout.lanes[0].placed.map((p) => p.milestone.id)).toEqual([ + "session/a", + "turn/a/1", + ]); + expect(layout.unpositioned.map((m) => m.id)).toEqual(["turn/c/1"]); + }); + + it("draws curves only for factual edges whose endpoints are placed", () => { + const layout = layoutStoryline(base); + expect(layout.curves.map((c) => c.connector.kind)).toEqual(["forkedFrom"]); + expect(layout.uncurved.map((c) => c.kind)).toEqual([ + "resumedFrom", + "handoffTo", + ]); + expect(layout.curves[0].path.startsWith("M ")).toBe(true); + }); + + it("always labels structural kinds and throttles dense turn labels", () => { + const t0 = Date.parse("2026-07-30T08:00:00.000Z"); + const dense: StorylineViewModel = { + lanes: [ + { + id: "session/a", + label: "a", + milestones: [ + milestone("session/a", new Date(t0).toISOString(), "session"), + milestone( + "turn/a/1", + new Date(t0 + 60 * 1000).toISOString(), + "turn", + 1 + ), + milestone( + "turn/a/2", + new Date(t0 + 60 * 1000).toISOString(), + "turn", + 2 + ), + milestone( + "checkpoint/a/1", + new Date(t0 + 60 * 1000).toISOString(), + "checkpoint" + ), + ], + gaps: [], + }, + ], + connectors: [], + unpositioned: [], + }; + const layout = layoutStoryline(dense); + const placed = layout.lanes[0].placed; + const labeled = placed + .filter((p) => p.showLabel) + .map((p) => p.milestone.id); + expect(labeled).toContain("checkpoint/a/1"); + // Session + first turn labeled; dense second turn throttled (all within the floor gap). + expect(labeled).toContain("session/a"); + expect(labeled).toContain("turn/a/1"); + // Same-timestamp turn after a labeled turn is throttled; checkpoint stays labeled. + expect(labeled).not.toContain("turn/a/2"); + expect(labeled).toContain("checkpoint/a/1"); + }); + + it("flags a stale lane with a fade tail", () => { + const layout = layoutStoryline(base); + const b = layout.lanes.find((l) => l.lane.id === "session/b"); + // a's last milestone (08:05) is ~4h before the newest fact (b at 12:00) -> stale. + expect(layout.lanes[0].fadeTail).toBe(true); + expect(b?.fadeTail).toBe(false); + }); + + it("positions milestones consistently with the axis mapping", () => { + const layout = layoutStoryline(base); + const first = layout.lanes[0].placed[0]; + expect(first.x).toBeGreaterThanOrEqual(PAD_LEFT); + expect(layout.lanes[1].y - layout.lanes[0].y).toBe(LANE_HEIGHT); + expect(layout.lanes[0].y).toBe(PAD_TOP); + }); +}); + +describe("formatting", () => { + it("formats durations compactly", () => { + expect(formatDuration(45 * MIN)).toBe("45m"); + expect(formatDuration(3 * 60 * MIN)).toBe("3h"); + expect(formatDuration(26 * 60 * MIN)).toBe("1d 2h"); + }); + + it("formats ticks without throwing", () => { + expect(formatTick(Date.parse("2026-07-30T08:05:00.000Z"))).toMatch( + /\d+\/\d+ \d+:\d+/ + ); + }); +}); diff --git a/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx b/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx index e0d238169e..d9fe032fea 100644 --- a/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx +++ b/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx @@ -1,102 +1,371 @@ import React from "react"; +import { + LANE_HEIGHT, + LANE_LABEL_WIDTH, + PAD_LEFT, + PAD_TOP, + formatDuration, + formatTick, + layoutStoryline, +} from "../timelineLayout"; import type { StorylineViewModel } from "../viewModel"; import { EvidenceSource } from "./EvidenceSource"; +/** Marker color per node kind (dark-theme harmonic palette). */ +const KIND_COLOR: Record = { + session: "#5EB7E6", + turn: "#8BA3FF", + checkpoint: "#55C6B1", + artifact: "#E2B357", + commit: "#6FC98B", +}; +const CURVE_COLOR: Record = { + forkedFrom: "#C79BF2", + resumedFrom: "#EC8FB0", + compactedTo: "#A9C05C", +}; +const CURVE_DASH: Record = { + forkedFrom: "", + resumedFrom: "5 4", + compactedTo: "2 4", +}; + +const CARD_WIDTH = 156; +const CARD_HEIGHT = 52; + export const StorylineTimeline: React.FC<{ viewModel: StorylineViewModel }> = ({ viewModel, -}) => ( -
-

- Real-time x-axis uses display timestamps only. Compressed spans are - labeled; connectors are factual edges. -

- {viewModel.lanes.map((lane) => ( -
-

- Agent / session lane: {lane.label} -

-
- Real-time x-axis -> -
-
    - {lane.milestones.map((milestone) => ( - -
  1. { + const layout = layoutStoryline(viewModel); + const { axis, lanes, curves, uncurved, unpositioned } = layout; + + // Sample up to 6 ticks evenly across the compressed axis. + const tickStep = Math.max(1, Math.ceil(axis.points.length / 6)); + const ticks = axis.points.filter((_, index) => index % tickStep === 0); + + return ( +
    +

    + Real-time x-axis uses display timestamps only; long idle spans are + compressed and labeled. Connector curves are factual edges (forkedFrom / + resumedFrom / compactedTo). +

    + +
    +
    + {/* Lane labels (fixed left column). */} + {lanes.map(({ lane, y }) => ( +
    + {lane.label} +
    + ))} + + {/* SVG layer: idle bands, baselines, markers, curves, fade tails. */} + + {/* Idle compression bands (global: any gap above the idle threshold). */} + {axis.idleBands.map((band) => ( + + + + idle {formatDuration(band.ms)} + + + ))} + + {/* Time ticks. */} + {ticks.map((point) => ( + + + + {formatTick(point.raw)} + + + ))} + + {/* Connector curves (factual edges only; endpoints must be placed). */} + {curves.map(({ connector, path, fromX, fromY, toX, toY }) => ( + -
    - {milestone.kind}: {milestone.title} -
    -
    - {milestone.displayTimestamp} - {milestone.sequence !== null - ? ` · turn ${milestone.sequence}` - : ""} -
    - + -
  2. - {lane.gaps - .filter( - (gap) => gap.fromTimestamp === milestone.displayTimestamp - ) - .map((gap) => ( -
  3. + ))} + + {/* Baselines + markers + fade tails per lane. */} + {lanes.map(({ lane, y, placed, fadeTail }) => { + const yCenter = y + LANE_HEIGHT / 2; + const firstX = placed.length > 0 ? placed[0].x : PAD_LEFT; + const lastX = + placed.length > 0 + ? placed[placed.length - 1].x + : PAD_LEFT + 120; + return ( + + + {placed.map(({ milestone, x }) => ( + + ))} + {fadeTail && placed.length > 0 && ( + + )} + + ); + })} + + + {/* HTML layer: milestone label cards (kept above the SVG). */} + {lanes.map(({ lane, y, placed }) => + placed + .filter(({ showLabel }) => showLabel) + .map(({ milestone, x }) => { + const yCenter = y + LANE_HEIGHT / 2; + const left = Math.min( + Math.max(x - CARD_WIDTH / 2, 4), + layout.totalWidth - LANE_LABEL_WIDTH - CARD_WIDTH - 4 + ); + return ( +
    - Idle compression: {Math.round(gap.durationMs / 60000)} min -
  4. - ))} -
    - ))} -
+
+ + ● + {" "} + {milestone.kind}: {milestone.title} +
+
+ {milestone.displayTimestamp} + {milestone.sequence !== null + ? ` · turn ${milestone.sequence}` + : ""} +
+ +
+ ); + }) + )} +
- ))} - {viewModel.unpositioned.length > 0 && ( -
-

- Facts without display time -

- {viewModel.unpositioned.map((item) => ( -
- - {item.kind}: {item.title}{" "} - - -
+ + {/* Legend for the visual grammar. */} +
+ {["forkedFrom", "resumedFrom", "compactedTo"].map((kind) => ( + + + + + {kind} + ))} + + + + + stale lane tail + + + + + + idle compression +
- )} -
- {viewModel.connectors.map((connector) => ( -
- {connector.kind}: {connector.from} to {connector.to}{" "} - + + {/* Factual connectors that could not be drawn as curves. */} + {uncurved.length > 0 && ( +
+ {uncurved.map((connector) => ( +
+ {connector.kind}: {connector.from} to {connector.to}{" "} + +
+ ))} +
+ )} + + {/* Facts without a display time stay visible but unpositioned (fail-closed). */} + {unpositioned.length > 0 && ( +
+

+ Facts without display time +

+ {unpositioned.map((item) => ( +
+ + {item.kind}: {item.title}{" "} + + +
+ ))}
- ))} -
- -); + )} + + ); +}; diff --git a/src/modules/ProjectManager/JourneyGraph/timelineLayout.ts b/src/modules/ProjectManager/JourneyGraph/timelineLayout.ts new file mode 100644 index 0000000000..a7c46ca632 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/timelineLayout.ts @@ -0,0 +1,256 @@ +import type { + StorylineConnector, + StorylineLane, + StorylineMilestone, + StorylineViewModel, +} from "./viewModel"; + +/** + * Pure layout for the Storyline timeline. This module only positions facts + * (nodes with explicit display timestamps, factual edges); it never infers + * timing, lanes, or hand-offs that the graph did not record. + * + * Visual language absorbed from context-visualizer (upstream): compressed + * real-time x-axis, agent/session lanes, structural milestone markers that + * survive label throttling, idle compression bands, fork/resume/compaction + * connectors drawn as curves, and fade-out tails for stale lanes. + */ + +/** Any inter-milestone gap counts at least this much (keeps bursts apart). */ +export const TIMELINE_FLOOR_MS = 2 * 60 * 1000; +/** Any inter-milestone gap counts at most this much (long idle is compressed). */ +export const TIMELINE_CAP_MS = 25 * 60 * 1000; +/** Gaps above this threshold get a labeled compression band. */ +export const TIMELINE_IDLE_GAP_MS = 15 * 60 * 1000; +/** Node kinds that are placed on the timeline lanes. */ +export const TIMELINE_KINDS = new Set([ + "session", + "turn", + "checkpoint", + "artifact", + "commit", +]); +/** Node kinds whose labels are always shown, even when throttled. */ +export const STRUCTURAL_KINDS = new Set(["checkpoint", "commit", "artifact"]); +/** Edge kinds that are drawn as connector curves between placed milestones. */ +export const CURVE_KINDS = new Set([ + "forkedFrom", + "resumedFrom", + "compactedTo", +]); +/** Lane whose members could not be attributed to a session. */ +export const UNLINKED_LANE_ID = "unlinked-facts"; + +export const LANE_HEIGHT = 76; +export const LANE_LABEL_WIDTH = 176; +export const PAD_LEFT = 16; +export const PAD_RIGHT = 32; +export const PAD_TOP = 44; +/** Width of the compressed axis in px. */ +export const CONTENT_WIDTH = 1200; +/** Minimum px gap between two visible turn labels inside one lane. */ +export const MIN_LABEL_GAP_PX = 104; +/** A lane whose last milestone is older than this fades out at the tail. */ +export const FADE_TAIL_MS = 90 * 60 * 1000; + +export function parseTimestampMs( + value: string | null | undefined +): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export interface CompressedPoint { + raw: number; + comp: number; +} + +export interface CompressedAxis { + points: CompressedPoint[]; + compTotal: number; + pxPerComp: number; + /** Compressed x for a raw timestamp (clamped to the axis range). */ + xOf: (ts: number) => number; + /** Intervals longer than TIMELINE_IDLE_GAP_MS, in compressed coordinates. */ + idleBands: Array<{ fromComp: number; toComp: number; ms: number }>; +} + +export function buildCompressedAxis(timestamps: number[]): CompressedAxis { + const sorted = [...new Set(timestamps)].sort((a, b) => a - b); + if (sorted.length === 0) { + return { + points: [], + compTotal: 0, + pxPerComp: 1, + xOf: () => 0, + idleBands: [], + }; + } + const points: CompressedPoint[] = [{ raw: sorted[0], comp: 0 }]; + const idleBands: CompressedAxis["idleBands"] = []; + for (let i = 1; i < sorted.length; i += 1) { + const raw = sorted[i] - sorted[i - 1]; + const eff = Math.min(Math.max(raw, TIMELINE_FLOOR_MS), TIMELINE_CAP_MS); + const comp = points[i - 1].comp + eff; + if (raw > TIMELINE_IDLE_GAP_MS) { + idleBands.push({ fromComp: points[i - 1].comp, toComp: comp, ms: raw }); + } + points.push({ raw: sorted[i], comp }); + } + const compTotal = points[points.length - 1].comp; + const pxPerComp = compTotal > 0 ? CONTENT_WIDTH / compTotal : 1; + const xOf = (ts: number): number => { + if (ts <= points[0].raw) return points[0].comp * pxPerComp; + const last = points[points.length - 1]; + if (ts >= last.raw) return last.comp * pxPerComp; + for (let i = 1; i < points.length; i += 1) { + if (ts <= points[i].raw) { + const a = points[i - 1]; + const b = points[i]; + const fraction = (ts - a.raw) / (b.raw - a.raw || 1); + return (a.comp + fraction * (b.comp - a.comp)) * pxPerComp; + } + } + return last.comp * pxPerComp; + }; + return { points, compTotal, pxPerComp, xOf, idleBands }; +} + +export interface PlacedMilestone { + milestone: StorylineMilestone; + x: number; + showLabel: boolean; +} + +export interface PlacedLane { + lane: StorylineLane; + y: number; + placed: PlacedMilestone[]; + /** True when the lane's last milestone is stale relative to the newest fact. */ + fadeTail: boolean; +} + +export interface PlacedCurve { + connector: StorylineConnector; + path: string; + fromX: number; + fromY: number; + toX: number; + toY: number; +} + +export interface StorylineLayout { + lanes: PlacedLane[]; + curves: PlacedCurve[]; + /** Factual connectors that could not be drawn (endpoints not placed). */ + uncurved: StorylineConnector[]; + unpositioned: StorylineMilestone[]; + axis: CompressedAxis; + totalWidth: number; + totalHeight: number; + /** Newest raw timestamp across all placed milestones (null when empty). */ + latestTs: number | null; +} + +/** Milestone kinds eligible for timeline placement. */ +function isTimelineMilestone(milestone: StorylineMilestone): boolean { + return ( + TIMELINE_KINDS.has(milestone.kind) && + parseTimestampMs(milestone.displayTimestamp) !== null + ); +} + +export function layoutStoryline( + viewModel: StorylineViewModel +): StorylineLayout { + const allTs: number[] = []; + for (const lane of viewModel.lanes) { + for (const milestone of lane.milestones) { + const ts = parseTimestampMs(milestone.displayTimestamp); + if (isTimelineMilestone(milestone) && ts !== null) allTs.push(ts); + } + } + const axis = buildCompressedAxis(allTs); + const latestTs = allTs.length > 0 ? Math.max(...allTs) : null; + + const placedById = new Map(); + const lanes: PlacedLane[] = viewModel.lanes.map((lane, index) => { + const y = PAD_TOP + index * LANE_HEIGHT; + let lastLabelX = -Infinity; + const placed = lane.milestones + .filter(isTimelineMilestone) + .map((milestone) => { + const x = + PAD_LEFT + axis.xOf(parseTimestampMs(milestone.displayTimestamp)!); + const structural = STRUCTURAL_KINDS.has(milestone.kind); + const showLabel = structural || x - lastLabelX >= MIN_LABEL_GAP_PX; + if (showLabel) lastLabelX = x; + placedById.set(milestone.id, { x, y, kind: milestone.kind }); + return { milestone, x, showLabel }; + }); + const lastTs = + placed.length > 0 + ? parseTimestampMs(placed[placed.length - 1].milestone.displayTimestamp) + : null; + const fadeTail = + latestTs !== null && lastTs !== null && latestTs - lastTs > FADE_TAIL_MS; + return { lane, y, placed, fadeTail }; + }); + + const curves: PlacedCurve[] = []; + const uncurved: StorylineConnector[] = []; + for (const connector of viewModel.connectors) { + if (!CURVE_KINDS.has(connector.kind)) { + uncurved.push(connector); + continue; + } + const from = placedById.get(connector.from); + const to = placedById.get(connector.to); + if (!from || !to) { + uncurved.push(connector); + continue; + } + const fromX = from.x; + const fromY = from.y; + const toX = to.x; + const toY = to.y; + const yGap = Math.max(14, Math.abs(toY - fromY) / 2); + const path = `M ${fromX} ${fromY + 7} C ${fromX} ${fromY + yGap}, ${toX} ${toY - yGap}, ${toX} ${toY - 7}`; + curves.push({ connector, path, fromX, fromY, toX, toY }); + } + + const totalWidth = + LANE_LABEL_WIDTH + + PAD_LEFT + + Math.max(axis.compTotal * axis.pxPerComp, 160) + + PAD_RIGHT; + const totalHeight = + PAD_TOP + Math.max(viewModel.lanes.length, 1) * LANE_HEIGHT + 24; + return { + lanes, + curves, + uncurved, + unpositioned: viewModel.unpositioned, + axis, + totalWidth, + totalHeight, + latestTs, + }; +} + +/** Formats a raw ms timestamp for tick labels (compact local time). */ +export function formatTick(ts: number): string { + const date = new Date(ts); + const p2 = (n: number) => String(n).padStart(2, "0"); + return `${date.getMonth() + 1}/${date.getDate()} ${p2(date.getHours())}:${p2(date.getMinutes())}`; +} + +/** Formats an idle duration as "45m" / "3h" / "1d 2h". */ +export function formatDuration(ms: number): string { + const minutes = Math.round(ms / 60000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d ${hours % 24}h`; +} From 3236a0fd9ac69c870e27e27d5537233940a9a4a6 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Sun, 2 Aug 2026 22:55:07 +0800 Subject: [PATCH 13/32] fix(journey): wire journey_graph_query to canonical orgtrack records Replace the P1 fail-closed stub with the real data path: - read persisted canonical sessions/edit artifacts/commit links - scope filter: project/{id} matches workspace_path; session/{id} selects the session plus its parent lineage chain - project_canonical_journey projector + independent audit (fail closed on uncovered canonical units) - fork edges only emitted when parent is inside the selected scope (cherry picked from commit 755ae6d94f17570dba4d3e3cd3dd562222c9b5c1) --- src-tauri/src/orgtrack/journey.rs | 209 ++++++++++++++++++++++++++++++ src-tauri/src/orgtrack/mod.rs | 21 +-- 2 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/orgtrack/journey.rs diff --git a/src-tauri/src/orgtrack/journey.rs b/src-tauri/src/orgtrack/journey.rs new file mode 100644 index 0000000000..b5e42bd7d9 --- /dev/null +++ b/src-tauri/src/orgtrack/journey.rs @@ -0,0 +1,209 @@ +//! Canonical Journey graph construction from orgtrack canonical records. +//! +//! This is the real data path behind `journey_graph_query`: it reads the +//! persisted canonical stores (sessions / edit artifacts / commit links), +//! filters them to the requested scope, runs the canonical projector +//! (`orgtrack_graph::journey::project_canonical_journey`) and fails closed +//! when the independent audit reports uncovered canonical units. +//! +//! The persisted canonical graph is not optional: absent scope data is an +//! error, never a synthesized partial response. + +use std::collections::{HashMap, HashSet}; + +use orgtrack_core::canonical::SessionEditKind; +use orgtrack_core::store::sqlite::SqliteRecordStore; +use orgtrack_core::store::RecordStore; +use orgtrack_graph::audit::audit_canonical_journey; +use orgtrack_graph::journey::{ + ArtifactRelation, CanonicalArtifact, CanonicalCommit, CanonicalJourneyInput, + CanonicalProject, CanonicalSession, CanonicalTurn, CoverageStatus, JourneyGraph, + SessionParent, +}; +use orgtrack_graph::JourneyScope; + +/// Build the canonical Journey graph for a scope. +/// +/// Scope semantics (no guessing, no synthesized data): +/// - `project/{id}` selects every canonical session whose `workspace_path` +/// equals `id` (the canonical project identity). +/// - `session/{id}` selects the session plus its full parent lineage chain +/// (`parent_session_id` recursion), so fork edges stay verifiable. +pub fn build_journey_graph( + store: &SqliteRecordStore, + scope: &JourneyScope, +) -> Result { + let sessions = store.list_sessions(None)?; + let artifacts = store.list_edit_artifacts(None, None)?; + let commits = store.list_commit_links()?; + + let selected = select_sessions(&sessions, scope)?; + if selected.is_empty() { + return Err(format!( + "canonical Journey graph store is not initialized for this project; \ + no canonical sessions for scope {}", + scope_label(scope) + )); + } + + let mut input = CanonicalJourneyInput::default(); + let mut seen_projects: HashSet = HashSet::new(); + for session in &sessions { + if !selected.contains(&session.session_id) { + continue; + } + let project_id = session + .workspace_path + .clone() + .unwrap_or_else(|| "unassigned".to_string()); + if seen_projects.insert(project_id.clone()) { + input.projects.push(CanonicalProject { + id: project_id.clone(), + source_ref: format!("orgtrack:project:{project_id}"), + }); + } + // Fork edges are only emitted when the parent session is inside the + // selected scope; a cross-scope parent is not guessed into the view. + let forked_from = session + .parent_session_id + .as_ref() + .filter(|parent| selected.contains(parent.as_str())) + .map(|parent| SessionParent { + session_id: parent.clone(), + parent_revision: format!("orgtrack:parent:{}", session.session_id), + }); + input.sessions.push(CanonicalSession { + id: session.session_id.clone(), + project_id, + work_item_id: None, + resumed_from: None, + compacted_to: None, + forked_from, + source_ref: format!("orgtrack:session:{}", session.session_id), + display_timestamp: session.created_at.clone(), + }); + } + + // Turns come from canonical edit artifacts; each distinct (session, + // sequence_index) is one turn. `list_edit_artifacts` returns rows ordered + // by sequence_index ASC, so per-session ordering stays strictly increasing + // after deduplication (projector enforces it as a hard invariant). + let mut seen_turns: HashSet<(String, u64)> = HashSet::new(); + for artifact in &artifacts { + if !selected.contains(&artifact.session_id) { + continue; + } + let sequence = artifact.sequence_index.max(0) as u64; + if seen_turns.insert((artifact.session_id.clone(), sequence)) { + input.turns.push(CanonicalTurn { + session_id: artifact.session_id.clone(), + sequence, + source_ref: format!("orgtrack:artifact:{}", artifact.record_id), + display_timestamp: artifact.timestamp.clone(), + }); + } + } + + for artifact in &artifacts { + if !selected.contains(&artifact.session_id) { + continue; + } + let relation = match artifact.edit_kind { + SessionEditKind::Write => ArtifactRelation::Produced, + SessionEditKind::Patch | SessionEditKind::Delete => ArtifactRelation::Modified, + // Read / CommitBoundary / Unknown carry no produced/modified + // evidence edge; they must not appear as canonical file lineage. + _ => continue, + }; + input.artifacts.push(CanonicalArtifact { + source: artifact.source.clone(), + id: artifact.record_id.clone(), + session_id: artifact.session_id.clone(), + file_repo: artifact.workspace_path.clone(), + file_path: Some(artifact.file_path.clone()), + relation, + source_ref: format!("orgtrack:artifact:{}", artifact.record_id), + }); + } + + for commit in &commits { + let linked_session = commit + .session_ids + .iter() + .find(|session_id| selected.contains(*session_id)); + if let Some(session_id) = linked_session { + input.commits.push(CanonicalCommit { + // CommitLinkRecord carries no repo field; keep the honest + // placeholder rather than deriving a repo from file paths. + repo: "unknown".to_string(), + sha: commit.commit_sha.clone(), + session_id: Some(session_id.clone()), + work_item_id: None, + source_ref: format!("orgtrack:commit:{}", commit.commit_sha), + }); + } + } + + let graph = orgtrack_graph::journey::project_canonical_journey(&input)?; + let report = audit_canonical_journey(&input, &graph); + if !report.trustworthy { + let uncovered: Vec<_> = report + .coverage + .iter() + .filter(|entry| matches!(entry.status, CoverageStatus::Uncovered)) + .map(|entry| entry.source_ref.clone()) + .collect(); + return Err(format!( + "canonical Journey graph store is incomplete for this project; \ + refusing partial data, uncovered canonical units: {}", + uncovered.join(", ") + )); + } + Ok(graph) +} + +fn select_sessions( + sessions: &[orgtrack_core::canonical::SessionRecord], + scope: &JourneyScope, +) -> Result, String> { + let mut selected = HashSet::new(); + match scope { + JourneyScope::Project(id) => { + for session in sessions { + if session.workspace_path.as_deref() == Some(id.as_str()) { + selected.insert(session.session_id.clone()); + } + } + } + JourneyScope::Session(id) => { + let by_id: HashMap<&str, &orgtrack_core::canonical::SessionRecord> = sessions + .iter() + .map(|session| (session.session_id.as_str(), session)) + .collect(); + if !by_id.contains_key(id.as_str()) { + return Err(format!( + "canonical Journey graph store is not initialized for this project; \ + no canonical session for scope {}", + scope_label(scope) + )); + } + let mut cursor = Some(id.as_str()); + while let Some(session_id) = cursor { + if !selected.insert(session_id.to_string()) { + break; + } + cursor = by_id + .get(session_id) + .and_then(|session| session.parent_session_id.as_deref()); + } + } + } + Ok(selected) +} + +fn scope_label(scope: &JourneyScope) -> String { + match scope { + JourneyScope::Project(id) => format!("project/{id}"), + JourneyScope::Session(id) => format!("session/{id}"), + } +} diff --git a/src-tauri/src/orgtrack/mod.rs b/src-tauri/src/orgtrack/mod.rs index 418be0ae9a..f40f7d1a4c 100644 --- a/src-tauri/src/orgtrack/mod.rs +++ b/src-tauri/src/orgtrack/mod.rs @@ -7,6 +7,7 @@ pub mod history_commands; mod history_scan_coordinator; pub mod impact_indexer; pub mod importer; +pub mod journey; pub mod paths; pub mod session_provenance; pub mod types; @@ -36,16 +37,20 @@ use orgtrack_core::projectors::stats::{session_summaries, CoreSessionSummary}; use orgtrack_core::store::{sqlite::SqliteRecordStore, RecordStore}; use types::OrgtrackTier; -/// P1 read-only Journey entry point. It validates scope before touching storage. -/// The persisted canonical graph is not optional: absent graph data is an error, -/// never a synthesized partial response. +/// Read-only Journey entry point: validates scope, then builds the canonical +/// graph from persisted orgtrack canonical records. The canonical graph is not +/// optional: absent scope data is an error, never a synthesized partial response. #[tauri::command] pub async fn journey_graph_query(scope: String) -> Result { - let _scope = orgtrack_graph::JourneyScope::parse(&scope)?; - Err( - "canonical Journey graph store is not initialized for this project; refusing partial data" - .to_string(), - ) + record_orgtrack_command_call("journey_graph_query"); + let parsed = orgtrack_graph::JourneyScope::parse(&scope)?; + tokio::task::spawn_blocking(move || { + let conn = get_connection().map_err(|err| err.to_string())?; + let store = SqliteRecordStore::new(&conn); + journey::build_journey_graph(&store, &parsed) + }) + .await + .map_err(|err| err.to_string())? } #[tauri::command] From 80e24cb016a94d1fa1416789a8ee759abb02aadd Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Mon, 3 Aug 2026 01:08:07 +0800 Subject: [PATCH 14/32] fix(journey): resolve project scope via linked workspaces from project store project/{id} now reads linked_repos_json from projects.db and selects every canonical session whose workspace_path is inside one of the linked workspaces (exact or direct child). Unknown project ids and projects without linked workspaces stay fail-closed: no guessed paths, no synthesized data. (cherry picked from commit db1b6b39e8e33adb5593456c4b055cbc52850d20) --- src-tauri/src/orgtrack/journey.rs | 59 +++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/orgtrack/journey.rs b/src-tauri/src/orgtrack/journey.rs index b5e42bd7d9..5b8b29e292 100644 --- a/src-tauri/src/orgtrack/journey.rs +++ b/src-tauri/src/orgtrack/journey.rs @@ -11,7 +11,9 @@ use std::collections::{HashMap, HashSet}; +use database::db::get_projects_connection; use orgtrack_core::canonical::SessionEditKind; +use rusqlite::OptionalExtension; use orgtrack_core::store::sqlite::SqliteRecordStore; use orgtrack_core::store::RecordStore; use orgtrack_graph::audit::audit_canonical_journey; @@ -25,8 +27,10 @@ use orgtrack_graph::JourneyScope; /// Build the canonical Journey graph for a scope. /// /// Scope semantics (no guessing, no synthesized data): -/// - `project/{id}` selects every canonical session whose `workspace_path` -/// equals `id` (the canonical project identity). +/// - `project/{id}` resolves the canonical project id through its linked +/// workspaces (`linked_repos_json` in projects.db) and selects every +/// canonical session whose `workspace_path` is inside one of them. A +/// project without explicit linked workspaces is an error. /// - `session/{id}` selects the session plus its full parent lineage chain /// (`parent_session_id` recursion), so fork edges stay verifiable. pub fn build_journey_graph( @@ -169,8 +173,23 @@ fn select_sessions( let mut selected = HashSet::new(); match scope { JourneyScope::Project(id) => { + // Resolve the canonical project identity to its linked + // workspaces (`linked_repos_json` in projects.db). The mapping + // must exist explicitly: a project without linked workspaces is + // an error, never a guessed path. + let workspaces = resolve_project_workspaces(id)?; for session in sessions { - if session.workspace_path.as_deref() == Some(id.as_str()) { + let in_project = session + .workspace_path + .as_deref() + .map(|path| { + workspaces.iter().any(|workspace| { + path == workspace + || path.starts_with(&format!("{workspace}/")) + }) + }) + .unwrap_or(false); + if in_project { selected.insert(session.session_id.clone()); } } @@ -201,6 +220,40 @@ fn select_sessions( Ok(selected) } +/// Read the canonical project row from `projects.db` and return its linked +/// workspace paths (`linked_repos_json`). Fail-closed: an unknown project id +/// or an empty linked-repos list is an error, because there is no honest +/// workspace to scope the journey to. +fn resolve_project_workspaces(project_id: &str) -> Result, String> { + let conn = get_projects_connection().map_err(|err| { + format!("cannot open project store while resolving {project_id}: {err}") + })?; + let linked_repos_json: Option = conn + .query_row( + "SELECT linked_repos_json FROM projects WHERE id = ?1", + [project_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| { + format!("cannot read linked repos for {project_id}: {err}") + })?; + let Some(raw) = linked_repos_json else { + return Err(format!( + "canonical Journey graph store is not initialized for this project; project {project_id} does not exist in the project store" + )); + }; + let workspaces: Vec = serde_json::from_str(&raw).map_err(|err| { + format!("project {project_id} linked_repos_json is invalid: {err}") + })?; + if workspaces.is_empty() { + return Err(format!( + "canonical Journey graph store is not initialized for this project; project {project_id} has no linked workspaces (linked_repos_json is empty)" + )); + } + Ok(workspaces) +} + fn scope_label(scope: &JourneyScope) -> String { match scope { JourneyScope::Project(id) => format!("project/{id}"), From 582e1271598d43b97f862ae4ca00ebcc7b9da89d Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:41:33 +0800 Subject: [PATCH 15/32] feat(journey): recover lifecycle and desktop controls Journey-only extraction from mixed historical commits. (cherry picked from commit eb31a18ed606d9b7e071944ab6a7d4262f3a3596) (cherry picked from commit dbe58cb6cf6313a73f2c4bb23b814a8aa2c77ce) --- src-tauri/crates/agent-core/src/core/mod.rs | 2 + .../agent-core/src/core/providers/factory.rs | 40 + .../session/journey_application_service.rs | 1179 +++++++++ .../session/journey_context_visibility.rs | 199 ++ .../src/core/session/journey_embedding.rs | 163 ++ .../src/core/session/journey_lifecycle.rs | 2136 +++++++++++++++++ .../src/core/session/journey_review_queue.rs | 1293 ++++++++++ .../crates/agent-core/src/core/session/mod.rs | 4 + .../session/persistence/journey_metadata.rs | 125 + .../src/core/session/persistence/mod.rs | 6 + .../crates/orgtrack-core/src/canonical.rs | 22 + .../orgtrack-core/src/edit_extraction.rs | 7 + .../src/sources/imported_history/cache.rs | 1 + .../crates/orgtrack-graph/src/journey.rs | 18 +- .../orgtrack-graph/src/journey_tests.rs | 57 +- src-tauri/crates/orgtrack-graph/src/lib.rs | 2 +- .../commands/runtime_artifacts.rs | 3 + .../src/agent_sessions/journey_commands.rs | 291 +++ src-tauri/src/agent_sessions/mod.rs | 1 + .../session_directory/orgtrack_adapter.rs | 1 + src-tauri/src/commands/handler_list.inc | 12 + src-tauri/src/orgtrack/diff_commands.rs | 1 + src-tauri/src/orgtrack/journey_canonical.rs | 283 +++ .../src/orgtrack/journey_lifecycle_graph.rs | 432 ++++ src-tauri/src/orgtrack/mod.rs | 4 +- src-tauri/src/orgtrack/session_provenance.rs | 3 + .../collaboration_replay.rs | 1 + src/api/tauri/journeyGraph/index.ts | 11 +- src/api/tauri/sessionJourney/index.test.ts | 56 + src/api/tauri/sessionJourney/index.ts | 152 ++ .../JourneyGraph/SessionJourneyPage.tsx | 14 +- .../JourneyGraph/SessionJourneySnapshot.tsx | 91 + .../__tests__/components.p2.test.ts | 111 +- .../__tests__/timelineLayout.test.ts | 1 + .../__tests__/viewModel.p2.test.ts | 244 +- .../components/StorylineTimeline.tsx | 9 + .../ProjectManager/JourneyGraph/viewModel.ts | 53 +- .../WorkStation/Chat/Communication/index.tsx | 19 +- .../SessionJourneyControls.contract.test.ts | 35 + .../Chat/Journey/SessionJourneyControls.tsx | 539 +++++ .../Chat/Journey/journeyMessageJump.test.ts | 32 + .../Chat/Journey/journeyMessageJump.ts | 19 + .../Chat/Journey/sessionJourneyModel.test.ts | 162 ++ .../Chat/Journey/sessionJourneyModel.ts | 54 + 44 files changed, 7804 insertions(+), 84 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/session/journey_application_service.rs create mode 100644 src-tauri/crates/agent-core/src/core/session/journey_context_visibility.rs create mode 100644 src-tauri/crates/agent-core/src/core/session/journey_embedding.rs create mode 100644 src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs create mode 100644 src-tauri/crates/agent-core/src/core/session/journey_review_queue.rs create mode 100644 src-tauri/crates/agent-core/src/core/session/persistence/journey_metadata.rs create mode 100644 src-tauri/src/agent_sessions/journey_commands.rs create mode 100644 src-tauri/src/orgtrack/journey_canonical.rs create mode 100644 src-tauri/src/orgtrack/journey_lifecycle_graph.rs create mode 100644 src/api/tauri/sessionJourney/index.test.ts create mode 100644 src/api/tauri/sessionJourney/index.ts create mode 100644 src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx create mode 100644 src/modules/WorkStation/Chat/Journey/SessionJourneyControls.contract.test.ts create mode 100644 src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx create mode 100644 src/modules/WorkStation/Chat/Journey/journeyMessageJump.test.ts create mode 100644 src/modules/WorkStation/Chat/Journey/journeyMessageJump.ts create mode 100644 src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts create mode 100644 src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts diff --git a/src-tauri/crates/agent-core/src/core/mod.rs b/src-tauri/crates/agent-core/src/core/mod.rs index 6328d0a372..5b471a0fa6 100644 --- a/src-tauri/crates/agent-core/src/core/mod.rs +++ b/src-tauri/crates/agent-core/src/core/mod.rs @@ -8,6 +8,8 @@ pub mod config; pub mod coordination; pub mod definitions; pub mod interaction; +#[path = "session/journey_lifecycle.rs"] +pub mod journey_lifecycle; pub mod model_context; pub mod providers; pub mod session; diff --git a/src-tauri/crates/agent-core/src/core/providers/factory.rs b/src-tauri/crates/agent-core/src/core/providers/factory.rs index 7b7c6dddc4..7c4ed72eea 100644 --- a/src-tauri/crates/agent-core/src/core/providers/factory.rs +++ b/src-tauri/crates/agent-core/src/core/providers/factory.rs @@ -34,6 +34,46 @@ pub fn create_provider( create_provider_with_reliability(model, account_id, &ReliabilityConfig::default()) } +/// Resolve the exact account protocol without constructing a provider. +/// Journey review enqueue persists this as strict runtime provenance. +pub fn resolve_account_protocol(model: &str, account_id: &str) -> Result { + let spec = resolve_spec_for_account(model, Some(account_id))?; + let resolved = resolve_credentials(spec, Some(account_id))?; + Ok(resolved.protocol.as_str().to_string()) +} + +/// Recreate exactly the provider route captured when a Journey review was queued. +/// A protocol change is a hard error because silently selecting a new wire route +/// would make the durable review provenance false. +pub fn create_provider_for_protocol( + model: &str, + account_id: &str, + expected_protocol: &str, +) -> Result, ProviderError> { + let spec = resolve_spec_for_account(model, Some(account_id))?; + let resolved = resolve_credentials(spec, Some(account_id))?; + if resolved.protocol.as_str() != expected_protocol { + return Err(ProviderError::AuthError(format!( + "审核任务锁定协议为 '{}',账户当前解析为 '{}'。", + expected_protocol, + resolved.protocol.as_str() + ))); + } + let reliability = ReliabilityConfig::default(); + if !reliability.fallback_models.is_empty() { + return Err(ProviderError::Other( + "审核任务禁止跨模型 fallback。".to_string(), + )); + } + let primary = build_provider_from_resolved(&resolved, spec, model); + Ok(Box::new(ReliableProvider::single( + format!("{}/{}", spec.name, model), + primary, + reliability.max_retries, + reliability.base_backoff_ms, + ))) +} + /// Create a provider wrapped in [`ReliableProvider`] for retry + fallback. /// /// The primary model is always tried first. If `reliability.fallback_models` diff --git a/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs b/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs new file mode 100644 index 0000000000..8e845a445c --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs @@ -0,0 +1,1179 @@ +//! Deterministic application boundary for Session Journey lifecycle actions. +//! +//! Desktop, Tauri, and Gateway adapters construct these requests themselves. +//! This module only validates durable coordinates and drives the aggregate; +//! it never asks a provider to interpret a command or manufacture a review. + +use std::fmt; + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +use crate::core::journey_lifecycle::{ + HandoffCapsule, JourneyError, ReviewItem, RuntimeProvenance, SessionJourney, + SqliteJourneyRepository, TaskOutcome, +}; +use crate::core::session::journey_review_queue::{ReviewJob, ReviewJobRepository}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TaskStartPosition { + 最近用户消息, + 下一条用户消息, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTaskRequest { + pub session_id: String, + pub expected_revision: u64, + pub task_id: String, + pub name: String, + pub position: TaskStartPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateForkRequest { + pub session_id: String, + pub expected_revision: u64, + pub fork_id: String, + pub task_id: String, + pub task_name: String, + pub anchor_message_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateCheckpointRequest { + pub session_id: String, + pub expected_revision: u64, + pub checkpoint_id: String, + pub name: String, + pub message_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FinishTaskRequest { + pub session_id: String, + pub expected_revision: u64, + pub outcome: TaskOutcome, + pub message_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestForkCloseRequest { + pub session_id: String, + pub expected_revision: u64, + pub fork_id: String, + pub review_id: String, + pub outcome: TaskOutcome, + pub message_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PromoteFactRequest { + pub session_id: String, + pub expected_revision: u64, + pub review_id: String, + pub fact_id: String, + pub text: String, + pub evidence_start_message_id: String, + pub evidence_end_message_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscardForkRequest { + pub session_id: String, + pub expected_revision: u64, + pub review_id: String, +} + +/// Worker-output write contract. Calling this does not run a worker and does +/// not synthesize a result; it only CAS-publishes a reviewed capsule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishHandoffCapsuleRequest { + pub session_id: String, + pub expected_revision: u64, + pub fork_id: String, + pub capsule: HandoffCapsule, +} + +/// Explicit producer output contract. No provider is called here: a real +/// worker must provide its already-generated Chinese annotation and resolved +/// runtime provenance, or leave the review queued. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarkReviewReadyRequest { + pub session_id: String, + pub expected_revision: u64, + pub review_id: String, + pub provenance: RuntimeProvenance, + pub annotation: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FailReviewRequest { + pub session_id: String, + pub expected_revision: u64, + pub review_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RetryReviewRequest { + pub session_id: String, + pub expected_revision: u64, + pub review_id: String, + pub job_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReturnToParentRequest { + pub session_id: String, + pub expected_revision: u64, + pub review_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReturnToParentResponse { + pub revision: u64, + pub parent_branch_id: String, + pub parent_anchor_message_id: String, + pub parent_anchor_sequence: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JourneySnapshotResponse { + pub snapshot: SessionJourney, + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JourneyWriteResponse { + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscardForkResponse { + pub revision: u64, + pub parent_branch_id: String, + pub parent_anchor_sequence: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum JourneyApplicationError { + 修订冲突 { expected: u64, actual: u64 }, + 校验失败(String), + 存储失败(String), +} + +impl fmt::Display for JourneyApplicationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::修订冲突 { expected, actual } => { + write!(f, "会话旅程修订冲突:期望 {expected},当前 {actual}。") + } + Self::校验失败(message) | Self::存储失败(message) => f.write_str(message), + } + } +} + +impl std::error::Error for JourneyApplicationError {} + +pub type JourneyApplicationResult = Result; + +/// Shared application service. All mutations load a durable snapshot, validate +/// the caller's observed revision, execute a lifecycle transition, then use +/// the repository CAS. No request has an implicit revision. +pub struct SessionJourneyApplicationService; + +impl SessionJourneyApplicationService { + /// Durable enqueue boundary used after a successful fork close. This is + /// intentionally provider-free; the caller must pass the already resolved + /// runtime provenance captured by the foreground fork. + pub fn enqueue_review_job( + conn: &Connection, + session_id: &str, + job_id: String, + review_id: &str, + provenance: RuntimeProvenance, + ) -> JourneyApplicationResult { + let snapshot = Self::snapshot(conn, session_id)?.snapshot; + ReviewJobRepository::enqueue(conn, &snapshot, job_id, review_id, &provenance) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string())) + } + + pub fn get_review_job( + conn: &Connection, + review_id: &str, + ) -> JourneyApplicationResult> { + ReviewJobRepository::get(conn, review_id) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string())) + } + + pub fn list_review_jobs(conn: &Connection) -> JourneyApplicationResult> { + ReviewJobRepository::list(conn) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string())) + } + + pub fn get_review_draft( + conn: &Connection, + session_id: &str, + review_id: &str, + ) -> JourneyApplicationResult> { + let snapshot = Self::snapshot(conn, session_id)?.snapshot; + Ok(snapshot + .reviews + .get(review_id) + .and_then(|review| review.annotation.clone())) + } + + pub fn claim_review_job( + conn: &mut Connection, + worker_id: &str, + stale_after_secs: u64, + ) -> JourneyApplicationResult> { + ReviewJobRepository::claim(conn, worker_id, stale_after_secs) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string())) + } + + pub fn complete_review_job(conn: &Connection, job_id: &str) -> JourneyApplicationResult<()> { + ReviewJobRepository::complete(conn, job_id) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string())) + } + + pub fn fail_review_job( + conn: &Connection, + job_id: &str, + error: &str, + ) -> JourneyApplicationResult<()> { + ReviewJobRepository::fail(conn, job_id, error) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string())) + } + + pub fn retry_review_job(conn: &Connection, job_id: &str) -> JourneyApplicationResult<()> { + ReviewJobRepository::retry(conn, job_id) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string())) + } + + pub fn fail_review( + conn: &mut Connection, + request: FailReviewRequest, + ) -> JourneyApplicationResult { + Self::write( + conn, + &request.session_id, + request.expected_revision, + |_conn, journey, revision| { + journey + .fail_review(revision, &request.review_id) + .map_err(Self::domain_error) + }, + ) + } + + pub fn retry_review( + conn: &mut Connection, + request: RetryReviewRequest, + ) -> JourneyApplicationResult { + let response = Self::write( + conn, + &request.session_id, + request.expected_revision, + |_conn, journey, revision| { + journey + .retry_review(revision, &request.review_id) + .map_err(Self::domain_error) + }, + )?; + ReviewJobRepository::retry(conn, &request.job_id) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + Ok(response) + } + pub fn snapshot( + conn: &Connection, + session_id: &str, + ) -> JourneyApplicationResult { + Self::ensure_session(conn, session_id)?; + let snapshot = Self::load_snapshot(conn, session_id)?; + Ok(JourneySnapshotResponse { + revision: snapshot.revision, + snapshot, + }) + } + + pub fn create_task( + conn: &mut Connection, + request: CreateTaskRequest, + ) -> JourneyApplicationResult { + let next_user_message = matches!(request.position, TaskStartPosition::下一条用户消息); + let latest_user_sequence = match &request.position { + TaskStartPosition::最近用户消息 => { + Some(Self::latest_user_sequence(conn, &request.session_id)?) + } + TaskStartPosition::下一条用户消息 => None, + }; + Self::write( + conn, + &request.session_id, + request.expected_revision, + |_conn, journey, revision| { + journey + .start_task( + revision, + request.task_id, + request.name, + next_user_message, + latest_user_sequence, + ) + .map_err(Self::domain_error) + }, + ) + } + + pub fn create_fork( + conn: &mut Connection, + request: CreateForkRequest, + ) -> JourneyApplicationResult { + let anchor = Self::message_anchor(conn, &request.session_id, &request.anchor_message_id)?; + Self::write( + conn, + &request.session_id, + request.expected_revision, + |conn, journey, revision| { + Self::validate_branch_anchor( + conn, + &request.session_id, + &anchor, + &journey.active_branch_id, + )?; + journey + .start_fork( + revision, + request.fork_id, + request.task_id, + request.task_name, + anchor.message_id.clone(), + anchor.sequence, + ) + .map_err(Self::domain_error) + }, + ) + } + + pub fn create_checkpoint( + conn: &mut Connection, + request: CreateCheckpointRequest, + ) -> JourneyApplicationResult { + let anchor = Self::message_anchor(conn, &request.session_id, &request.message_id)?; + Self::write( + conn, + &request.session_id, + request.expected_revision, + |conn, journey, revision| { + let task_id = journey.active_task_id.as_deref().ok_or_else(|| { + JourneyApplicationError::校验失败("当前没有活动任务。".into()) + })?; + Self::validate_task_anchor( + conn, + &request.session_id, + &anchor, + &journey.active_branch_id, + task_id, + )?; + journey + .checkpoint( + revision, + request.checkpoint_id, + request.name, + anchor.message_id, + anchor.sequence, + ) + .map_err(Self::domain_error) + }, + ) + } + + pub fn finish_task( + conn: &mut Connection, + request: FinishTaskRequest, + ) -> JourneyApplicationResult { + let anchor = Self::message_anchor(conn, &request.session_id, &request.message_id)?; + Self::write( + conn, + &request.session_id, + request.expected_revision, + |conn, journey, revision| { + Self::validate_active_task_anchor(conn, &request.session_id, &anchor, journey)?; + journey + .finish_task( + revision, + request.outcome, + anchor.sequence, + crate::core::journey_lifecycle::FinishDisposition::StayInFork, + None, + None, + ) + .map_err(Self::domain_error) + }, + ) + } + + pub fn request_fork_close( + conn: &mut Connection, + request: RequestForkCloseRequest, + ) -> JourneyApplicationResult { + let anchor = Self::message_anchor(conn, &request.session_id, &request.message_id)?; + Self::write( + conn, + &request.session_id, + request.expected_revision, + |conn, journey, revision| { + Self::validate_active_task_anchor(conn, &request.session_id, &anchor, journey)?; + journey + .request_fork_close( + revision, + &request.fork_id, + request.review_id, + request.outcome, + anchor.sequence, + ) + .map_err(Self::domain_error) + }, + ) + } + + /// Close plus durable enqueue for callers that already resolved the + /// foreground RuntimeProvenance. The operation performs no provider + /// call and returns as soon as the local writes finish. + pub fn request_fork_close_and_enqueue( + conn: &mut Connection, + request: RequestForkCloseRequest, + job_id: String, + provenance: RuntimeProvenance, + ) -> JourneyApplicationResult { + let response = Self::request_fork_close(conn, request.clone())?; + let snapshot = Self::snapshot(conn, &request.session_id)?.snapshot; + let job = + ReviewJobRepository::enqueue(conn, &snapshot, job_id, &request.review_id, &provenance) + .map_err(|error| { + JourneyApplicationError::存储失败(format!( + "{}(修订 {})", + error, response.revision + )) + })?; + crate::core::session::journey_review_queue::notify_review_queue(); + Ok(job) + } + + pub fn review_queue( + conn: &Connection, + session_id: &str, + ) -> JourneyApplicationResult> { + let snapshot = Self::snapshot(conn, session_id)?; + Ok(snapshot + .snapshot + .reviews + .values() + .filter(|item| { + matches!( + item.state, + crate::core::journey_lifecycle::ReviewState::Queued + ) + }) + .cloned() + .collect()) + } + + pub fn promote_confirmed_fact( + conn: &mut Connection, + request: PromoteFactRequest, + ) -> JourneyApplicationResult { + let start = Self::message_anchor( + conn, + &request.session_id, + &request.evidence_start_message_id, + )?; + let end = + Self::message_anchor(conn, &request.session_id, &request.evidence_end_message_id)?; + let fact_id = request.fact_id.clone(); + let fact_text = request.text.clone(); + let response = Self::write( + conn, + &request.session_id, + request.expected_revision, + |_conn, journey, revision| { + journey + .promote_fact( + revision, + &request.review_id, + fact_id.clone(), + fact_text.clone(), + start.message_id.clone(), + start.sequence, + end.message_id.clone(), + end.sequence, + ) + .map_err(Self::domain_error) + }, + )?; + let snapshot = Self::load_snapshot(conn, &request.session_id)?; + let review = snapshot + .reviews + .get(&request.review_id) + .ok_or_else(|| JourneyApplicationError::校验失败("未知审阅项。".into()))?; + crate::core::session::journey_embedding::enqueue( + conn, + &format!("fact:{}", request.fact_id), + crate::core::session::journey_embedding::JourneyEmbeddingKind::ConfirmedFact, + &request.session_id, + &review.fork_id, + &request.review_id, + &request.text, + ) + .map_err(|error| { + JourneyApplicationError::存储失败(format!("无法排队事实 embedding:{error}")) + })?; + Ok(response) + } + + pub fn discard_fork( + conn: &mut Connection, + request: DiscardForkRequest, + ) -> JourneyApplicationResult { + Self::ensure_session(conn, &request.session_id)?; + let mut snapshot = Self::load_snapshot(conn, &request.session_id)?; + if snapshot.revision != request.expected_revision { + return Err(JourneyApplicationError::修订冲突 { + expected: request.expected_revision, + actual: snapshot.revision, + }); + } + let review = snapshot.reviews.get(&request.review_id).ok_or_else(|| { + JourneyApplicationError::校验失败(format!("未知审阅项:{}。", request.review_id)) + })?; + let branch = snapshot.branches.get(&review.fork_id).ok_or_else(|| { + JourneyApplicationError::校验失败(format!("未知分叉:{}。", review.fork_id)) + })?; + let parent_branch_id = branch.parent_branch_id.clone(); + let parent_anchor_sequence = snapshot + .discard_fork(snapshot.revision, &request.review_id) + .map_err(Self::domain_error)?; + SqliteJourneyRepository::compare_and_store(conn, &snapshot, request.expected_revision) + .map_err(Self::domain_error)?; + crate::core::session::journey_embedding::discard_review_annotation( + conn, + &request.review_id, + ) + .map_err(|error| { + JourneyApplicationError::存储失败(format!("无法清理审核 embedding:{error}")) + })?; + Ok(DiscardForkResponse { + revision: snapshot.revision, + parent_branch_id, + parent_anchor_sequence, + }) + } + + pub fn publish_handoff_capsule( + conn: &mut Connection, + request: PublishHandoffCapsuleRequest, + ) -> JourneyApplicationResult { + Self::write( + conn, + &request.session_id, + request.expected_revision, + |_conn, journey, revision| { + journey + .publish_handoff_capsule(revision, &request.fork_id, request.capsule) + .map_err(Self::domain_error) + }, + ) + } + + pub fn mark_review_ready( + conn: &mut Connection, + request: MarkReviewReadyRequest, + ) -> JourneyApplicationResult { + Self::write( + conn, + &request.session_id, + request.expected_revision, + |_conn, journey, revision| { + journey + .mark_review_ready( + revision, + &request.review_id, + request.provenance, + request.annotation, + ) + .map_err(Self::domain_error) + }, + ) + } + + pub fn return_to_parent( + conn: &mut Connection, + request: ReturnToParentRequest, + ) -> JourneyApplicationResult { + Self::ensure_session(conn, &request.session_id)?; + let mut snapshot = Self::load_snapshot(conn, &request.session_id)?; + if snapshot.revision != request.expected_revision { + return Err(JourneyApplicationError::修订冲突 { + expected: request.expected_revision, + actual: snapshot.revision, + }); + } + let (parent_branch_id, parent_anchor_message_id, parent_anchor_sequence) = snapshot + .return_to_parent(snapshot.revision, &request.review_id) + .map_err(Self::domain_error)?; + SqliteJourneyRepository::compare_and_store(conn, &snapshot, request.expected_revision) + .map_err(Self::domain_error)?; + Ok(ReturnToParentResponse { + revision: snapshot.revision, + parent_branch_id, + parent_anchor_message_id, + parent_anchor_sequence, + }) + } + + fn write( + conn: &mut Connection, + session_id: &str, + expected_revision: u64, + action: impl FnOnce(&Connection, &mut SessionJourney, u64) -> JourneyApplicationResult<()>, + ) -> JourneyApplicationResult { + Self::ensure_session(conn, session_id)?; + let mut snapshot = Self::load_snapshot(conn, session_id)?; + if snapshot.revision != expected_revision { + return Err(JourneyApplicationError::修订冲突 { + expected: expected_revision, + actual: snapshot.revision, + }); + } + action(conn, &mut snapshot, expected_revision)?; + SqliteJourneyRepository::compare_and_store(conn, &snapshot, expected_revision) + .map_err(Self::domain_error)?; + Ok(JourneyWriteResponse { + revision: snapshot.revision, + }) + } + + fn load_snapshot( + conn: &Connection, + session_id: &str, + ) -> JourneyApplicationResult { + SqliteJourneyRepository::ensure_schema(conn).map_err(|_| { + JourneyApplicationError::存储失败("无法初始化 Journey 存储。".into()) + })?; + SqliteJourneyRepository::load(conn, session_id) + .map_err(Self::domain_error) + .map(|snapshot| snapshot.unwrap_or_else(|| SessionJourney::new(session_id, "main"))) + } + + fn ensure_session(conn: &Connection, session_id: &str) -> JourneyApplicationResult<()> { + if session_id.trim().is_empty() { + return Err(JourneyApplicationError::校验失败( + "会话 ID 不能为空。".into(), + )); + } + let exists: Option = conn + .query_row( + "SELECT 1 FROM agent_sessions WHERE session_id = ?1 LIMIT 1", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|_| JourneyApplicationError::存储失败("无法校验会话。".into()))?; + exists.is_some().then_some(()).ok_or_else(|| { + JourneyApplicationError::校验失败(format!("未知会话:{session_id}。")) + }) + } + + fn latest_user_sequence(conn: &Connection, session_id: &str) -> JourneyApplicationResult { + conn.query_row( + "SELECT sequence FROM agent_messages WHERE session_id = ?1 AND role = 'user' ORDER BY sequence DESC LIMIT 1", + [session_id], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|_| JourneyApplicationError::存储失败("无法读取最近用户消息。".into()))? + .filter(|sequence| *sequence >= 0) + .map(|sequence| sequence as u64) + .ok_or_else(|| JourneyApplicationError::校验失败("该会话没有可用的最近用户消息。".into())) + } + + fn message_anchor( + conn: &Connection, + session_id: &str, + message_id: &str, + ) -> JourneyApplicationResult { + let anchor = conn + .query_row( + "SELECT id, sequence FROM agent_messages WHERE session_id = ?1 AND id = ?2", + params![session_id, message_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional() + .map_err(|_| JourneyApplicationError::存储失败("无法读取精确消息锚点。".into()))? + .filter(|(_, sequence)| *sequence >= 0) + .ok_or_else(|| { + JourneyApplicationError::校验失败("未找到该会话中的精确消息锚点。".into()) + })?; + Ok(MessageAnchor { + message_id: anchor.0, + sequence: anchor.1 as u64, + }) + } + + fn validate_branch_anchor( + conn: &Connection, + session_id: &str, + anchor: &MessageAnchor, + branch_id: &str, + ) -> JourneyApplicationResult<()> { + let membership: Option = conn.query_row( + "SELECT branch_id FROM session_journey_memberships WHERE session_id = ?1 AND message_id = ?2", + params![session_id, anchor.message_id], |row| row.get(0), + ).optional().map_err(|_| JourneyApplicationError::存储失败("无法校验分叉锚点。".into()))?; + if membership + .as_deref() + .is_some_and(|branch| branch != branch_id) + { + return Err(JourneyApplicationError::校验失败( + "精确消息锚点不属于当前分叉。".into(), + )); + } + Ok(()) + } + + fn validate_task_anchor( + conn: &Connection, + session_id: &str, + anchor: &MessageAnchor, + branch_id: &str, + task_id: &str, + ) -> JourneyApplicationResult<()> { + let membership: Option<(String, Option)> = conn.query_row( + "SELECT branch_id, task_id FROM session_journey_memberships WHERE session_id = ?1 AND message_id = ?2", + params![session_id, anchor.message_id], |row| Ok((row.get(0)?, row.get(1)?)), + ).optional().map_err(|_| JourneyApplicationError::存储失败("无法校验任务锚点。".into()))?; + if let Some((branch, task)) = membership { + if branch != branch_id || task.as_deref() != Some(task_id) { + return Err(JourneyApplicationError::校验失败( + "精确消息锚点不属于当前任务。".into(), + )); + } + } + Ok(()) + } + + fn validate_active_task_anchor( + conn: &Connection, + session_id: &str, + anchor: &MessageAnchor, + journey: &SessionJourney, + ) -> JourneyApplicationResult<()> { + let task_id = journey + .active_task_id + .as_deref() + .ok_or_else(|| JourneyApplicationError::校验失败("当前没有活动任务。".into()))?; + Self::validate_task_anchor(conn, session_id, anchor, &journey.active_branch_id, task_id) + } + + fn domain_error(error: JourneyError) -> JourneyApplicationError { + match error { + JourneyError::RevisionConflict { expected, actual } => { + JourneyApplicationError::修订冲突 { expected, actual } + } + JourneyError::DuplicateId(id) => { + JourneyApplicationError::校验失败(format!("会话旅程 ID 已存在:{id}。")) + } + JourneyError::UnknownBranch(id) => { + JourneyApplicationError::校验失败(format!("未知分叉:{id}。")) + } + JourneyError::UnknownTask(id) => { + JourneyApplicationError::校验失败(format!("未知任务:{id}。")) + } + JourneyError::UnknownReview(id) => { + JourneyApplicationError::校验失败(format!("未知审阅项:{id}。")) + } + JourneyError::NoActiveTask => { + JourneyApplicationError::校验失败("当前没有活动任务。".into()) + } + JourneyError::ActiveTaskExists => { + JourneyApplicationError::校验失败("当前已有活动任务。".into()) + } + JourneyError::InvalidState(message) => { + JourneyApplicationError::校验失败(format!("{message}。")) + } + JourneyError::InvalidSequence => { + JourneyApplicationError::校验失败("无效的精确 sequence。".into()) + } + JourneyError::MissingRuntimeProvenance => { + JourneyApplicationError::校验失败("缺少运行来源信息。".into()) + } + JourneyError::AccessDenied => { + JourneyApplicationError::校验失败("该精确锚点不在审阅范围内。".into()) + } + JourneyError::MissingHandoff => { + JourneyApplicationError::校验失败("缺少有效的中文交接胶囊。".into()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MessageAnchor { + message_id: String, + sequence: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::journey_lifecycle::{ReviewState, RuntimeProvenance, TaskState}; + + fn conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE agent_sessions (session_id TEXT PRIMARY KEY); + CREATE TABLE agent_messages (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, sequence INTEGER NOT NULL);", + ) + .unwrap(); + conn.execute("INSERT INTO agent_sessions (session_id) VALUES ('s')", []) + .unwrap(); + conn.execute("INSERT INTO agent_messages (id, session_id, role, sequence) VALUES ('u1', 's', 'user', 4)", []).unwrap(); + conn.execute("INSERT INTO agent_messages (id, session_id, role, sequence) VALUES ('a1', 's', 'assistant', 5)", []).unwrap(); + conn + } + + fn fork_request(revision: u64) -> CreateForkRequest { + CreateForkRequest { + session_id: "s".into(), + expected_revision: revision, + fork_id: "f1".into(), + task_id: "t1".into(), + task_name: "调查".into(), + anchor_message_id: "u1".into(), + } + } + + #[test] + fn cas_conflict_and_chinese_session_error_are_typed() { + let mut conn = conn(); + SessionJourneyApplicationService::create_task( + &mut conn, + CreateTaskRequest { + session_id: "s".into(), + expected_revision: 0, + task_id: "t".into(), + name: "最近".into(), + position: TaskStartPosition::最近用户消息, + }, + ) + .unwrap(); + assert!(matches!( + SessionJourneyApplicationService::create_task( + &mut conn, + CreateTaskRequest { + session_id: "s".into(), + expected_revision: 0, + task_id: "t2".into(), + name: "冲突".into(), + position: TaskStartPosition::下一条用户消息, + } + ), + Err(JourneyApplicationError::修订冲突 { + expected: 0, + actual: 1 + }) + )); + assert_eq!( + SessionJourneyApplicationService::snapshot(&conn, "不存在") + .unwrap_err() + .to_string(), + "未知会话:不存在。" + ); + } + + #[test] + fn next_user_task_and_checkpoint_keep_exact_anchor() { + let mut conn = conn(); + SessionJourneyApplicationService::create_task( + &mut conn, + CreateTaskRequest { + session_id: "s".into(), + expected_revision: 0, + task_id: "next".into(), + name: "下一条".into(), + position: TaskStartPosition::下一条用户消息, + }, + ) + .unwrap(); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s").unwrap(); + assert_eq!( + snapshot.snapshot.tasks["next"].state, + TaskState::PendingNextUser + ); + + let mut current = snapshot.snapshot; + current + .on_user_message_persisted(current.revision, 6) + .unwrap(); + SqliteJourneyRepository::compare_and_store(&mut conn, ¤t, 1).unwrap(); + SessionJourneyApplicationService::create_checkpoint( + &mut conn, + CreateCheckpointRequest { + session_id: "s".into(), + expected_revision: 2, + checkpoint_id: "c1".into(), + name: "精确".into(), + message_id: "a1".into(), + }, + ) + .unwrap(); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s").unwrap(); + assert_eq!(snapshot.snapshot.checkpoints["c1"].message_id, "a1"); + assert_eq!(snapshot.snapshot.checkpoints["c1"].sequence, 5); + } + + #[test] + fn fork_creates_active_task_and_close_only_queues_review() { + let mut conn = conn(); + SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s").unwrap(); + assert_eq!(snapshot.snapshot.active_task_id.as_deref(), Some("t1")); + SessionJourneyApplicationService::request_fork_close( + &mut conn, + RequestForkCloseRequest { + session_id: "s".into(), + expected_revision: 1, + fork_id: "f1".into(), + review_id: "r1".into(), + outcome: TaskOutcome::Completed, + message_id: "a1".into(), + }, + ) + .unwrap(); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s").unwrap(); + assert_eq!( + snapshot.snapshot.branches["f1"].state, + crate::core::journey_lifecycle::ForkState::Closing + ); + assert_eq!(snapshot.snapshot.reviews["r1"].state, ReviewState::Queued); + assert!(snapshot.snapshot.reviews["r1"].annotation.is_none()); + } + + #[test] + fn discard_returns_parent_anchor_and_promotion_requires_confirmable_review() { + let mut conn = conn(); + SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + SessionJourneyApplicationService::request_fork_close( + &mut conn, + RequestForkCloseRequest { + session_id: "s".into(), + expected_revision: 1, + fork_id: "f1".into(), + review_id: "r1".into(), + outcome: TaskOutcome::Completed, + message_id: "a1".into(), + }, + ) + .unwrap(); + assert!(matches!( + SessionJourneyApplicationService::promote_confirmed_fact( + &mut conn, + PromoteFactRequest { + session_id: "s".into(), + expected_revision: 2, + review_id: "r1".into(), + fact_id: "fact".into(), + text: "结论".into(), + evidence_start_message_id: "u1".into(), + evidence_end_message_id: "a1".into(), + } + ), + Err(JourneyApplicationError::校验失败(_)) + )); + let discard = SessionJourneyApplicationService::discard_fork( + &mut conn, + DiscardForkRequest { + session_id: "s".into(), + expected_revision: 2, + review_id: "r1".into(), + }, + ) + .unwrap(); + assert_eq!( + ( + discard.parent_branch_id.as_str(), + discard.parent_anchor_sequence + ), + ("main", 4) + ); + } + + #[test] + fn ready_review_can_be_explicitly_promoted_after_confirmation() { + let mut conn = conn(); + SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + SessionJourneyApplicationService::request_fork_close( + &mut conn, + RequestForkCloseRequest { + session_id: "s".into(), + expected_revision: 1, + fork_id: "f1".into(), + review_id: "r1".into(), + outcome: TaskOutcome::Completed, + message_id: "a1".into(), + }, + ) + .unwrap(); + let mut journey = SessionJourneyApplicationService::snapshot(&conn, "s") + .unwrap() + .snapshot; + journey + .mark_review_ready( + journey.revision, + "r1", + RuntimeProvenance { + model_id: "模型".into(), + account_id: "账户".into(), + protocol: "测试".into(), + }, + "候选结论".into(), + ) + .unwrap(); + SqliteJourneyRepository::compare_and_store(&mut conn, &journey, 2).unwrap(); + SessionJourneyApplicationService::promote_confirmed_fact( + &mut conn, + PromoteFactRequest { + session_id: "s".into(), + expected_revision: 3, + review_id: "r1".into(), + fact_id: "fact".into(), + text: "已确认结论".into(), + evidence_start_message_id: "a1".into(), + evidence_end_message_id: "a1".into(), + }, + ) + .unwrap(); + assert_eq!( + SessionJourneyApplicationService::snapshot(&conn, "s") + .unwrap() + .snapshot + .reviews["r1"] + .state, + ReviewState::Confirmed + ); + } + + #[test] + fn reviewed_capsule_preserves_provenance_and_returns_to_exact_parent_with_cas() { + let mut conn = conn(); + SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + SessionJourneyApplicationService::request_fork_close( + &mut conn, + RequestForkCloseRequest { + session_id: "s".into(), + expected_revision: 1, + fork_id: "f1".into(), + review_id: "r1".into(), + outcome: TaskOutcome::Completed, + message_id: "a1".into(), + }, + ) + .unwrap(); + let provenance = RuntimeProvenance { + model_id: "模型甲".into(), + account_id: "账户甲".into(), + protocol: "协议甲".into(), + }; + SessionJourneyApplicationService::mark_review_ready( + &mut conn, + MarkReviewReadyRequest { + session_id: "s".into(), + expected_revision: 2, + review_id: "r1".into(), + provenance: provenance.clone(), + annotation: "可用审核产物".into(), + }, + ) + .unwrap(); + let capsule = HandoffCapsule { + fork_id: "f1".into(), + review_id: "r1".into(), + parent_branch_id: "main".into(), + parent_anchor_message_id: "u1".into(), + source_start_sequence: 5, + source_end_sequence: 5, + objective: "审阅分叉结果".into(), + conclusion: "继续主干".into(), + open_questions: vec!["补充验证".into()], + confirmed_items: vec!["锚点准确".into()], + evidence_references: vec!["消息 a1".into()], + generated_at: Some("元数据".into()), + provenance: provenance.clone(), + }; + assert!(matches!( + SessionJourneyApplicationService::publish_handoff_capsule( + &mut conn, + PublishHandoffCapsuleRequest { + session_id: "s".into(), + expected_revision: 2, + fork_id: "f1".into(), + capsule: capsule.clone(), + }, + ), + Err(JourneyApplicationError::修订冲突 { + expected: 2, + actual: 3 + }) + )); + SessionJourneyApplicationService::publish_handoff_capsule( + &mut conn, + PublishHandoffCapsuleRequest { + session_id: "s".into(), + expected_revision: 3, + fork_id: "f1".into(), + capsule, + }, + ) + .unwrap(); + let returned = SessionJourneyApplicationService::return_to_parent( + &mut conn, + ReturnToParentRequest { + session_id: "s".into(), + expected_revision: 4, + review_id: "r1".into(), + }, + ) + .unwrap(); + assert_eq!( + ( + returned.parent_branch_id.as_str(), + returned.parent_anchor_message_id.as_str(), + returned.parent_anchor_sequence, + ), + ("main", "u1", 4) + ); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s").unwrap(); + assert_eq!(snapshot.snapshot.active_branch_id, "main"); + assert_eq!( + snapshot.snapshot.branches["f1"] + .handoff_capsule + .as_ref() + .unwrap() + .provenance, + provenance + ); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/journey_context_visibility.rs b/src-tauri/crates/agent-core/src/core/session/journey_context_visibility.rs new file mode 100644 index 0000000000..7e411d05cf --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_context_visibility.rs @@ -0,0 +1,199 @@ +//! Deterministic prompt-context visibility for persisted Journey messages. +//! +//! The projector is deliberately independent from SQLite and provider wire +//! formatting. Persistence supplies exact message/sequence memberships, then +//! the prompt boundary filters raw rows before they are reconstructed. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::core::journey_lifecycle::{JourneyError, SessionJourney}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedContextMessage { + pub message_id: String, + pub sequence: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JourneyMessageMembership { + pub message_id: String, + pub sequence: u64, + pub branch_id: String, + pub task_id: Option, +} + +/// Return the exact persisted message IDs which may enter `viewer_branch_id`'s +/// provider prompt. Once a session has any Journey memberships, an unassigned +/// or sequence-mismatched row is denied rather than guessed from timestamps. +/// A session with no memberships is a legacy transcript and remains intact. +pub fn project_visible_message_ids( + journey: &SessionJourney, + viewer_branch_id: &str, + messages: &[PersistedContextMessage], + memberships: &[JourneyMessageMembership], +) -> Result, JourneyError> { + if memberships.is_empty() { + return Ok(messages + .iter() + .map(|message| message.message_id.clone()) + .collect()); + } + + let membership_by_id: BTreeMap<&str, &JourneyMessageMembership> = memberships + .iter() + .map(|membership| (membership.message_id.as_str(), membership)) + .collect(); + let mut visible = BTreeSet::new(); + for message in messages { + let Some(membership) = membership_by_id.get(message.message_id.as_str()) else { + continue; + }; + if membership.sequence != message.sequence { + continue; + } + if journey.can_browse_sequence( + viewer_branch_id, + &membership.branch_id, + membership.sequence, + )? { + visible.insert(message.message_id.clone()); + } + } + Ok(visible) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn message(id: &str, sequence: u64) -> PersistedContextMessage { + PersistedContextMessage { + message_id: id.into(), + sequence, + } + } + + fn membership(id: &str, sequence: u64, branch_id: &str) -> JourneyMessageMembership { + JourneyMessageMembership { + message_id: id.into(), + sequence, + branch_id: branch_id.into(), + task_id: None, + } + } + + fn journey() -> SessionJourney { + let mut journey = SessionJourney::new("s", "main"); + journey + .start_fork( + 0, + "fork-a".into(), + "task-a".into(), + "分叉 A".into(), + "anchor-10".into(), + 10, + ) + .unwrap(); + journey.active_branch_id = "main".into(); + journey.active_task_id = None; + journey + .start_fork( + 1, + "fork-b".into(), + "task-b".into(), + "分叉 B".into(), + "anchor-10".into(), + 10, + ) + .unwrap(); + journey.active_branch_id = "main".into(); + journey.active_task_id = None; + journey + .start_fork( + 2, + "fork-c".into(), + "task-c".into(), + "分叉 C".into(), + "anchor-11".into(), + 11, + ) + .unwrap(); + journey + } + + #[test] + fn projector_enforces_exact_branch_and_anchor_visibility() { + let journey = journey(); + let messages = [ + message("parent-anchor", 10), + message("parent-future", 11), + message("a-assistant", 12), + message("b-tool", 12), + message("c-tool", 12), + ]; + let memberships = [ + membership("parent-anchor", 10, "main"), + membership("parent-future", 11, "main"), + membership("a-assistant", 12, "fork-a"), + membership("b-tool", 12, "fork-b"), + membership("c-tool", 12, "fork-c"), + ]; + + let main = project_visible_message_ids(&journey, "main", &messages, &memberships).unwrap(); + assert_eq!(main.len(), 5, "main sees all descendant forks"); + + let child = + project_visible_message_ids(&journey, "fork-a", &messages, &memberships).unwrap(); + assert!(child.contains("parent-anchor")); + assert!(!child.contains("parent-future")); + assert!(child.contains("a-assistant")); + assert!(child.contains("b-tool")); + assert!(!child.contains("c-tool")); + } + + #[test] + fn main_descendant_and_same_anchor_sibling_visibility_are_explicit() { + let journey = journey(); + let messages = [ + message("main", 9), + message("a", 12), + message("b", 12), + message("c", 12), + ]; + let memberships = [ + membership("main", 9, "main"), + membership("a", 12, "fork-a"), + membership("b", 12, "fork-b"), + membership("c", 12, "fork-c"), + ]; + + let main = project_visible_message_ids(&journey, "main", &messages, &memberships) + .expect("main projection"); + assert_eq!( + main, + BTreeSet::from(["a".into(), "b".into(), "c".into(), "main".into()]) + ); + + let child = project_visible_message_ids(&journey, "fork-a", &messages, &memberships) + .expect("child projection"); + assert!(child.contains("b"), "same-anchor sibling stays visible"); + assert!(!child.contains("c"), "different-anchor sibling is denied"); + } + + #[test] + fn projector_keeps_legacy_transcript_and_denies_mismatched_membership() { + let journey = journey(); + let messages = [message("legacy", 1), message("bad", 2)]; + let legacy = project_visible_message_ids(&journey, "fork-a", &messages, &[]).unwrap(); + assert_eq!(legacy, BTreeSet::from(["bad".into(), "legacy".into()])); + + let denied = project_visible_message_ids( + &journey, + "fork-a", + &messages, + &[membership("bad", 3, "fork-a")], + ) + .unwrap(); + assert!(denied.is_empty()); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/journey_embedding.rs b/src-tauri/crates/agent-core/src/core/session/journey_embedding.rs new file mode 100644 index 0000000000..167f5575c2 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_embedding.rs @@ -0,0 +1,163 @@ +//! Durable embedding outbox for Journey annotations and confirmed facts. +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JourneyEmbeddingKind { + ReviewAnnotation, + ConfirmedFact, +} +impl JourneyEmbeddingKind { + fn as_str(self) -> &'static str { + match self { + Self::ReviewAnnotation => "review_annotation", + Self::ConfirmedFact => "confirmed_fact", + } + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JourneyEmbeddingJob { + pub source_id: String, + pub source_kind: JourneyEmbeddingKind, + pub session_id: String, + pub workspace_id: Option, + pub project_id: Option, + pub fork_id: String, + pub review_id: String, + pub content: String, + pub attempt: u64, +} + +pub fn ensure_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( +"CREATE TABLE IF NOT EXISTS session_journey_embedding_outbox(source_id TEXT PRIMARY KEY,source_kind TEXT NOT NULL,session_id TEXT NOT NULL,workspace_id TEXT,project_id TEXT,fork_id TEXT NOT NULL,review_id TEXT NOT NULL,content TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'queued',attempt INTEGER NOT NULL DEFAULT 0,error TEXT,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP); +CREATE INDEX IF NOT EXISTS idx_journey_embedding_outbox_state ON session_journey_embedding_outbox(state,created_at); +CREATE TABLE IF NOT EXISTS session_journey_embedding_index(source_id TEXT PRIMARY KEY,source_kind TEXT NOT NULL,session_id TEXT NOT NULL,workspace_id TEXT,project_id TEXT,fork_id TEXT NOT NULL,review_id TEXT NOT NULL,content TEXT NOT NULL,embedding BLOB NOT NULL,embedding_model TEXT NOT NULL,embedding_source TEXT NOT NULL,embedding_dimensions INTEGER NOT NULL,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);") +} +fn scope(conn: &Connection, sid: &str) -> (Option, Option) { + conn.query_row("SELECT m.workspace_id,s.project_id FROM agent_sessions s LEFT JOIN session_journey_metadata m ON m.session_id=s.session_id WHERE s.session_id=?1",[sid],|r|Ok((r.get(0)?,r.get(1)?))).optional().ok().flatten().unwrap_or((None,None)) +} +pub fn enqueue( + conn: &Connection, + id: &str, + kind: JourneyEmbeddingKind, + sid: &str, + fork: &str, + review: &str, + content: &str, +) -> rusqlite::Result<()> { + ensure_schema(conn)?; + let (ws, project) = scope(conn, sid); + conn.execute("INSERT INTO session_journey_embedding_outbox(source_id,source_kind,session_id,workspace_id,project_id,fork_id,review_id,content,state) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,'queued') ON CONFLICT(source_id) DO UPDATE SET content=excluded.content,workspace_id=excluded.workspace_id,project_id=excluded.project_id,state=CASE WHEN session_journey_embedding_outbox.content=excluded.content THEN session_journey_embedding_outbox.state ELSE 'queued' END,error=NULL,updated_at=CURRENT_TIMESTAMP",params![id,kind.as_str(),sid,ws,project,fork,review,content])?; + crate::core::session::journey_review_queue::notify_review_queue(); + Ok(()) +} +pub fn discard_review_annotation(conn: &Connection, review: &str) -> rusqlite::Result<()> { + ensure_schema(conn)?; + conn.execute("DELETE FROM session_journey_embedding_outbox WHERE review_id=?1 AND source_kind='review_annotation'",[review])?; + conn.execute("DELETE FROM session_journey_embedding_index WHERE review_id=?1 AND source_kind='review_annotation'",[review])?; + Ok(()) +} +pub fn claim(conn: &mut Connection) -> rusqlite::Result> { + ensure_schema(conn)?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let id:Option=tx.query_row("SELECT source_id FROM session_journey_embedding_outbox WHERE state='queued' ORDER BY created_at LIMIT 1",[],|r|r.get(0)).optional()?; + let Some(id) = id else { + tx.commit()?; + return Ok(None); + }; + tx.execute("UPDATE session_journey_embedding_outbox SET state='running',attempt=attempt+1,error=NULL WHERE source_id=?1",[&id])?; + let job=tx.query_row("SELECT source_id,source_kind,session_id,workspace_id,project_id,fork_id,review_id,content,attempt FROM session_journey_embedding_outbox WHERE source_id=?1",[&id],|r|{let k:String=r.get(1)?;Ok(JourneyEmbeddingJob{source_id:r.get(0)?,source_kind:if k=="confirmed_fact"{JourneyEmbeddingKind::ConfirmedFact}else{JourneyEmbeddingKind::ReviewAnnotation},session_id:r.get(2)?,workspace_id:r.get(3)?,project_id:r.get(4)?,fork_id:r.get(5)?,review_id:r.get(6)?,content:r.get(7)?,attempt:r.get::<_,i64>(8)? as u64})})?; + tx.commit()?; + Ok(Some(job)) +} +pub fn complete( + conn: &Connection, + j: &JourneyEmbeddingJob, + v: &[f32], + model: &str, + source: &str, +) -> rusqlite::Result<()> { + ensure_schema(conn)?; + let bytes: Vec = v.iter().flat_map(|x| x.to_le_bytes()).collect(); + let tx = conn.unchecked_transaction()?; + let owned = tx.execute( + "UPDATE session_journey_embedding_outbox SET state='completed',error=NULL,updated_at=CURRENT_TIMESTAMP WHERE source_id=?1 AND state='running'", + [&j.source_id], + )?; + if owned == 0 { + tx.commit()?; + return Ok(()); + } + tx.execute( + "INSERT INTO session_journey_embedding_index(source_id,source_kind,session_id,workspace_id,project_id,fork_id,review_id,content,embedding,embedding_model,embedding_source,embedding_dimensions) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12) ON CONFLICT(source_id) DO UPDATE SET content=excluded.content,embedding=excluded.embedding,embedding_model=excluded.embedding_model,embedding_source=excluded.embedding_source,embedding_dimensions=excluded.embedding_dimensions,updated_at=CURRENT_TIMESTAMP", + params![j.source_id,j.source_kind.as_str(),j.session_id,j.workspace_id,j.project_id,j.fork_id,j.review_id,j.content,bytes,model,source,v.len() as i64], + )?; + tx.commit() +} + +pub fn retry(conn: &Connection, id: &str) -> rusqlite::Result<()> { + ensure_schema(conn)?; + conn.execute( + "UPDATE session_journey_embedding_outbox SET state='queued',error=NULL,updated_at=CURRENT_TIMESTAMP WHERE source_id=?1 AND state='failed'", + [id], + )?; + crate::core::session::journey_review_queue::notify_review_queue(); + Ok(()) +} + +pub fn fail(conn: &Connection, id: &str, error: &str) -> rusqlite::Result<()> { + conn.execute( + "UPDATE session_journey_embedding_outbox SET state='failed',error=?2 WHERE source_id=?1", + params![id, error], + )?; + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + fn db() -> Connection { + let c = Connection::open_in_memory().unwrap(); + c.execute_batch("CREATE TABLE agent_sessions(session_id TEXT PRIMARY KEY,project_id TEXT);CREATE TABLE session_journey_metadata(session_id TEXT PRIMARY KEY,workspace_id TEXT);INSERT INTO agent_sessions VALUES('s','p');INSERT INTO session_journey_metadata VALUES('s','w');").unwrap(); + c + } + #[test] + fn kinds_are_scoped_and_discard_only_removes_annotation() { + let mut c = db(); + enqueue( + &c, + "review:r", + JourneyEmbeddingKind::ReviewAnnotation, + "s", + "f", + "r", + "draft", + ) + .unwrap(); + enqueue( + &c, + "fact:x", + JourneyEmbeddingKind::ConfirmedFact, + "s", + "f", + "r", + "fact", + ) + .unwrap(); + let j = claim(&mut c).unwrap().unwrap(); + assert_eq!( + (j.workspace_id.as_deref(), j.project_id.as_deref()), + (Some("w"), Some("p")) + ); + discard_review_annotation(&c, "r").unwrap(); + // A completion racing after discard cannot resurrect annotation data. + complete(&c, &j, &[1., 2.], "m", "p/m/2").unwrap(); + let resurrected: i64 = c.query_row( + "SELECT COUNT(*) FROM session_journey_embedding_index WHERE source_kind='review_annotation'", + [], |r| r.get(0)).unwrap(); + assert_eq!(resurrected, 0); + let n:i64=c.query_row("SELECT COUNT(*) FROM session_journey_embedding_outbox WHERE source_kind='confirmed_fact'",[],|r|r.get(0)).unwrap(); + assert_eq!(n, 1); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs b/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs new file mode 100644 index 0000000000..ad79b4b338 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs @@ -0,0 +1,2136 @@ +//! Durable domain model for Session Journey tasks and forks. +//! +//! This module intentionally has no dependency on todos, work items, Agent +//! Org tasks, git branches, clocks, or an LLM. Persistence adapters store the +//! serialized [`SessionJourney`] atomically using `revision` as their CAS +//! coordinate. The desktop and Gateway command adapters call this same +//! application service; their parsing must never invoke a provider. + +use std::collections::{BTreeMap, BTreeSet}; + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskState { + PendingNextUser, + Active, + Finished, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskOutcome { + Completed, + PartiallyCompleted, + Paused, + Abandoned, + Redirected, +} + +impl TaskOutcome { + pub fn chinese_label(&self) -> &'static str { + match self { + Self::Completed => "完成", + Self::PartiallyCompleted => "部分完成", + Self::Paused => "暂停", + Self::Abandoned => "放弃", + Self::Redirected => "转向", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ForkState { + Active, + Closing, + CloseFailed, + Closed, + Discarded, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewState { + Queued, + Ready, + Confirmed, + Discarded, + Failed, +} + +impl ReviewState { + fn chinese_label(&self) -> &'static str { + match self { + Self::Queued => "待审核", + Self::Ready => "可审核", + Self::Confirmed => "已确认", + Self::Discarded => "已丢弃", + Self::Failed => "失败", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProvenance { + pub model_id: String, + pub account_id: String, + pub protocol: String, +} + +impl RuntimeProvenance { + fn validate(&self) -> Result<(), JourneyError> { + if self.model_id.trim().is_empty() + || self.account_id.trim().is_empty() + || self.protocol.trim().is_empty() + { + return Err(JourneyError::MissingRuntimeProvenance); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JourneyTask { + pub id: String, + pub name: String, + pub branch_id: String, + pub state: TaskState, + pub start_sequence: Option, + pub finish_sequence: Option, + pub outcome: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JourneyCheckpoint { + pub id: String, + pub task_id: String, + pub message_id: String, + pub sequence: u64, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JourneyFork { + pub id: String, + pub parent_branch_id: String, + /// Durable message identity for the parent prefix boundary. Sequences are + /// ordering coordinates only; a return-to-parent action must name this ID. + #[serde(default)] + pub parent_anchor_message_id: Option, + pub anchor_sequence: u64, + pub source_start_sequence: u64, + pub state: ForkState, + pub frozen_end_sequence: Option, + pub close_work_id: Option, + pub handoff_capsule: Option, +} + +/// Immutable, reviewed result of a closed fork. This is deliberately a small +/// domain value, not a transcript slice: it is the only fork-derived content +/// eligible for the parent prompt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandoffCapsule { + pub fork_id: String, + pub review_id: String, + pub parent_branch_id: String, + pub parent_anchor_message_id: String, + pub source_start_sequence: u64, + pub source_end_sequence: u64, + pub objective: String, + pub conclusion: String, + pub open_questions: Vec, + pub confirmed_items: Vec, + pub evidence_references: Vec, + /// Metadata only. It is never used to select an anchor or order prompt + /// content, preserving cache identity across process restarts. + pub generated_at: Option, + pub provenance: RuntimeProvenance, +} + +impl HandoffCapsule { + pub fn synthetic_prompt_message(&self) -> serde_json::Value { + let lines = [ + "【分叉交接】以下是已审核的压缩结论,请从主干锚点继续,不要重放分叉记录。".to_string(), + format!("分叉ID:{}", self.fork_id), + format!("审阅ID:{}", self.review_id), + format!("源锚点:{}", self.parent_anchor_message_id), + format!("父分支:{}", self.parent_branch_id), + format!( + "来源区间:{}-{}", + self.source_start_sequence, self.source_end_sequence + ), + format!("模型:{}", self.provenance.model_id), + format!("账户:{}", self.provenance.account_id), + format!("协议:{}", self.provenance.protocol), + format!("目标:{}", self.objective), + format!("结论:{}", self.conclusion), + format!("未决项:{}", self.open_questions.join(";")), + format!("确认项:{}", self.confirmed_items.join(";")), + format!("证据引用:{}", self.evidence_references.join(";")), + match &self.generated_at { + Some(value) => format!("生成时间:{}", value), + None => "生成时间:未记录".to_string(), + }, + ]; + serde_json::json!({ "role": "user", "content": lines.join("\n") }) + } + + fn validate(&self) -> Result<(), JourneyError> { + if self.fork_id.trim().is_empty() + || self.review_id.trim().is_empty() + || self.parent_branch_id.trim().is_empty() + || self.parent_anchor_message_id.trim().is_empty() + || self.objective.trim().is_empty() + || self.conclusion.trim().is_empty() + || self.source_start_sequence > self.source_end_sequence + { + return Err(JourneyError::MissingHandoff); + } + self.provenance.validate() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewItem { + pub id: String, + pub fork_id: String, + pub state: ReviewState, + /// Empty while a close is waiting for an annotation producer. Provenance + /// belongs to that producer, not to the user's close command. + pub provenance: Option, + pub source_start_sequence: u64, + pub source_end_sequence: u64, + /// AI output is never a fact until the user promotes an item from it. + pub annotation: Option, + pub promoted_fact_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConfirmedFact { + pub id: String, + pub review_id: String, + pub evidence_start_message_id: String, + pub evidence_start_sequence: u64, + pub evidence_end_message_id: String, + pub evidence_end_sequence: u64, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionJourney { + pub session_id: String, + pub revision: u64, + pub active_branch_id: String, + pub active_task_id: Option, + pub branches: BTreeMap, + pub tasks: BTreeMap, + pub checkpoints: BTreeMap, + pub reviews: BTreeMap, + pub facts: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FinishDisposition { + StayInFork, + CloseFork, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JourneyError { + RevisionConflict { expected: u64, actual: u64 }, + DuplicateId(String), + UnknownBranch(String), + UnknownTask(String), + UnknownReview(String), + NoActiveTask, + ActiveTaskExists, + InvalidState(&'static str), + InvalidSequence, + MissingRuntimeProvenance, + AccessDenied, + MissingHandoff, +} + +impl std::fmt::Display for JourneyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} +impl std::error::Error for JourneyError {} + +impl SessionJourney { + pub fn new(session_id: impl Into, main_branch_id: impl Into) -> Self { + let main_branch_id = main_branch_id.into(); + let branch = JourneyFork { + id: main_branch_id.clone(), + parent_branch_id: main_branch_id.clone(), + parent_anchor_message_id: None, + anchor_sequence: 0, + source_start_sequence: 1, + state: ForkState::Active, + frozen_end_sequence: None, + close_work_id: None, + handoff_capsule: None, + }; + Self { + session_id: session_id.into(), + revision: 0, + active_branch_id: main_branch_id.clone(), + active_task_id: None, + branches: BTreeMap::from([(main_branch_id, branch)]), + tasks: BTreeMap::new(), + checkpoints: BTreeMap::new(), + reviews: BTreeMap::new(), + facts: BTreeMap::new(), + } + } + + fn mutate( + &mut self, + expected_revision: u64, + f: impl FnOnce(&mut Self) -> Result<(), JourneyError>, + ) -> Result<(), JourneyError> { + if self.revision != expected_revision { + return Err(JourneyError::RevisionConflict { + expected: expected_revision, + actual: self.revision, + }); + } + f(self)?; + self.revision += 1; + Ok(()) + } + + pub fn start_task( + &mut self, + expected_revision: u64, + id: String, + name: String, + next_user_message: bool, + latest_user_sequence: Option, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + if s.active_task_id.is_some() { + return Err(JourneyError::ActiveTaskExists); + } + if id.trim().is_empty() || name.trim().is_empty() { + return Err(JourneyError::InvalidState("任务名称和 ID 不能为空")); + } + if s.tasks.contains_key(&id) { + return Err(JourneyError::DuplicateId(id)); + } + let (state, start_sequence) = if next_user_message { + (TaskState::PendingNextUser, None) + } else { + ( + TaskState::Active, + Some(latest_user_sequence.ok_or(JourneyError::InvalidSequence)?), + ) + }; + s.tasks.insert( + id.clone(), + JourneyTask { + id: id.clone(), + name, + branch_id: s.active_branch_id.clone(), + state, + start_sequence, + finish_sequence: None, + outcome: None, + }, + ); + s.active_task_id = Some(id); + Ok(()) + }) + } + + /// Must be called in the same successful message-persistence transaction. + pub fn on_user_message_persisted( + &mut self, + expected_revision: u64, + sequence: u64, + ) -> Result<(), JourneyError> { + let Some(task_id) = self.active_task_id.as_deref() else { + return Ok(()); + }; + if self.tasks.get(task_id).map(|task| &task.state) != Some(&TaskState::PendingNextUser) { + return Ok(()); + } + self.mutate(expected_revision, |s| { + let task_id = s + .active_task_id + .as_deref() + .ok_or(JourneyError::NoActiveTask)?; + let task = s + .tasks + .get_mut(task_id) + .ok_or_else(|| JourneyError::UnknownTask(task_id.into()))?; + if task.state == TaskState::PendingNextUser { + task.state = TaskState::Active; + task.start_sequence = Some(sequence); + } + Ok(()) + }) + } + + pub fn start_fork( + &mut self, + expected_revision: u64, + fork_id: String, + task_id: String, + task_name: String, + parent_anchor_message_id: String, + anchor_sequence: u64, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + if s.branches.contains_key(&fork_id) || s.tasks.contains_key(&task_id) { + return Err(JourneyError::DuplicateId(fork_id)); + } + if s.active_task_id.is_some() { + return Err(JourneyError::ActiveTaskExists); + } + let parent = s.active_branch_id.clone(); + s.branches.insert( + fork_id.clone(), + JourneyFork { + id: fork_id.clone(), + parent_branch_id: parent, + parent_anchor_message_id: Some(parent_anchor_message_id), + anchor_sequence, + source_start_sequence: anchor_sequence.saturating_add(1), + state: ForkState::Active, + frozen_end_sequence: None, + close_work_id: None, + handoff_capsule: None, + }, + ); + s.active_branch_id = fork_id.clone(); + s.tasks.insert( + task_id.clone(), + JourneyTask { + id: task_id.clone(), + name: task_name, + branch_id: fork_id, + state: TaskState::Active, + start_sequence: Some(anchor_sequence), + finish_sequence: None, + outcome: None, + }, + ); + s.active_task_id = Some(task_id); + Ok(()) + }) + } + + pub fn checkpoint( + &mut self, + expected_revision: u64, + id: String, + name: String, + message_id: String, + sequence: u64, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + let task_id = s.active_task_id.clone().ok_or(JourneyError::NoActiveTask)?; + if s.checkpoints.contains_key(&id) { + return Err(JourneyError::DuplicateId(id)); + } + if s.tasks[&task_id].state != TaskState::Active { + return Err(JourneyError::InvalidState("任务尚未激活")); + } + s.checkpoints.insert( + id.clone(), + JourneyCheckpoint { + id, + task_id, + message_id, + sequence, + name, + }, + ); + Ok(()) + }) + } + + pub fn finish_task( + &mut self, + expected_revision: u64, + outcome: TaskOutcome, + finish_sequence: u64, + disposition: FinishDisposition, + close_work_id: Option, + provenance: Option, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + let task_id = s.active_task_id.clone().ok_or(JourneyError::NoActiveTask)?; + let branch_id = s.active_branch_id.clone(); + let task = s + .tasks + .get_mut(&task_id) + .ok_or_else(|| JourneyError::UnknownTask(task_id.clone()))?; + if task.state != TaskState::Active { + return Err(JourneyError::InvalidState("任务尚未激活")); + } + task.state = TaskState::Finished; + task.outcome = Some(outcome); + task.finish_sequence = Some(finish_sequence); + s.active_task_id = None; + if disposition == FinishDisposition::CloseFork { + let branch = s + .branches + .get_mut(&branch_id) + .ok_or_else(|| JourneyError::UnknownBranch(branch_id.clone()))?; + if branch.parent_branch_id == branch.id { + return Err(JourneyError::InvalidState("主分支不可关闭")); + } + let work = close_work_id.ok_or(JourneyError::InvalidState("缺少关闭工作 ID"))?; + provenance + .ok_or(JourneyError::MissingRuntimeProvenance)? + .validate()?; + branch.state = ForkState::Closing; + branch.frozen_end_sequence = Some(finish_sequence); + branch.close_work_id = Some(work); + } + Ok(()) + }) + } + + /// Records a user-requested close without claiming that a background + /// annotation or summary has run. The review remains queued until a + /// separate producer supplies an explicitly sourced result. + pub fn request_fork_close( + &mut self, + expected_revision: u64, + fork_id: &str, + review_id: String, + outcome: TaskOutcome, + finish_sequence: u64, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + if s.active_branch_id != fork_id { + return Err(JourneyError::InvalidState("只能关闭当前活动分叉")); + } + let task_id = s.active_task_id.clone().ok_or(JourneyError::NoActiveTask)?; + let task = s + .tasks + .get_mut(&task_id) + .ok_or_else(|| JourneyError::UnknownTask(task_id.clone()))?; + if task.state != TaskState::Active { + return Err(JourneyError::InvalidState("任务尚未激活")); + } + let branch = s + .branches + .get_mut(fork_id) + .ok_or_else(|| JourneyError::UnknownBranch(fork_id.into()))?; + if branch.id == branch.parent_branch_id { + return Err(JourneyError::InvalidState("主分支不可关闭")); + } + if s.reviews.contains_key(&review_id) { + return Err(JourneyError::DuplicateId(review_id)); + } + task.state = TaskState::Finished; + task.outcome = Some(outcome); + task.finish_sequence = Some(finish_sequence); + s.active_task_id = None; + branch.state = ForkState::Closing; + branch.frozen_end_sequence = Some(finish_sequence); + branch.close_work_id = None; + s.reviews.insert( + review_id.clone(), + ReviewItem { + id: review_id, + fork_id: fork_id.into(), + state: ReviewState::Queued, + provenance: None, + source_start_sequence: branch.source_start_sequence, + source_end_sequence: finish_sequence, + annotation: None, + promoted_fact_ids: Vec::new(), + }, + ); + Ok(()) + }) + } + + /// The sole authoritative close completion: it marks the fork closed and + /// creates one review item. A failed extractor must use `close_failed`. + pub fn complete_close( + &mut self, + expected_revision: u64, + fork_id: &str, + review_id: String, + provenance: Option, + annotation: Option, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + if let Some(provenance) = provenance.as_ref() { + provenance.validate()?; + } + let branch = s + .branches + .get_mut(fork_id) + .ok_or_else(|| JourneyError::UnknownBranch(fork_id.into()))?; + if !matches!(branch.state, ForkState::Closing | ForkState::Closed) { + return Err(JourneyError::InvalidState("分叉未处于关闭中")); + } + if s.reviews.contains_key(&review_id) { + return Err(JourneyError::DuplicateId(review_id)); + } + let end = branch + .frozen_end_sequence + .ok_or(JourneyError::InvalidSequence)?; + branch.state = ForkState::Closed; + s.reviews.insert( + review_id.clone(), + ReviewItem { + id: review_id, + fork_id: fork_id.into(), + state: ReviewState::Queued, + provenance, + source_start_sequence: branch.source_start_sequence, + source_end_sequence: end, + annotation, + promoted_fact_ids: Vec::new(), + }, + ); + Ok(()) + }) + } + + /// The annotation producer may mark a queued review ready, but it may + /// never promote a fact. Promotion remains an explicit user action. + pub fn mark_review_ready( + &mut self, + expected_revision: u64, + review_id: &str, + provenance: RuntimeProvenance, + annotation: String, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + provenance.validate()?; + let fork_id = { + let review = s + .reviews + .get(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))?; + review.fork_id.clone() + }; + let branch_state = s + .branches + .get(&fork_id) + .ok_or_else(|| JourneyError::UnknownBranch(fork_id.clone()))? + .state + .clone(); + if !matches!(branch_state, ForkState::Closing | ForkState::Closed) { + return Err(JourneyError::InvalidState("分叉未处于关闭中")); + } + let review = s + .reviews + .get_mut(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))?; + if review.state != ReviewState::Queued || annotation.trim().is_empty() { + return Err(JourneyError::InvalidState("审阅项不可就绪")); + } + review.provenance = Some(provenance); + review.annotation = Some(annotation); + review.state = ReviewState::Ready; + if let Some(branch) = s.branches.get_mut(&fork_id) { + branch.state = ForkState::Closed; + } + Ok(()) + }) + } + + pub fn fail_close( + &mut self, + expected_revision: u64, + fork_id: &str, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + let b = s + .branches + .get_mut(fork_id) + .ok_or_else(|| JourneyError::UnknownBranch(fork_id.into()))?; + if b.state != ForkState::Closing { + return Err(JourneyError::InvalidState("分叉未处于关闭中")); + } + b.state = ForkState::CloseFailed; + Ok(()) + }) + } + + /// A background annotation failure never publishes a capsule or fact. It + /// leaves the frozen fork intact and exposes a retryable typed review + /// state to adapters. + pub fn fail_review( + &mut self, + expected_revision: u64, + review_id: &str, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + let review = s + .reviews + .get_mut(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))?; + if !matches!(review.state, ReviewState::Queued | ReviewState::Failed) { + return Err(JourneyError::InvalidState("审阅项不可标记为失败")); + } + review.state = ReviewState::Failed; + Ok(()) + }) + } + + pub fn retry_review( + &mut self, + expected_revision: u64, + review_id: &str, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + let review = s + .reviews + .get_mut(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))?; + if review.state != ReviewState::Failed { + return Err(JourneyError::InvalidState("仅失败的审阅项可重试")); + } + review.state = ReviewState::Queued; + Ok(()) + }) + } + + pub fn promote_fact( + &mut self, + expected_revision: u64, + review_id: &str, + fact_id: String, + text: String, + start_message_id: String, + start: u64, + end_message_id: String, + end: u64, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + if start > end || text.trim().is_empty() { + return Err(JourneyError::InvalidSequence); + } + let review = s + .reviews + .get_mut(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))?; + if review.state != ReviewState::Ready { + return Err(JourneyError::InvalidState("审阅项不可确认")); + } + if start < review.source_start_sequence || end > review.source_end_sequence { + return Err(JourneyError::AccessDenied); + } + if s.facts.contains_key(&fact_id) { + return Err(JourneyError::DuplicateId(fact_id)); + } + review.promoted_fact_ids.push(fact_id.clone()); + review.state = ReviewState::Confirmed; + s.facts.insert( + fact_id.clone(), + ConfirmedFact { + id: fact_id, + review_id: review_id.into(), + evidence_start_message_id: start_message_id, + evidence_start_sequence: start, + evidence_end_message_id: end_message_id, + evidence_end_sequence: end, + text, + }, + ); + Ok(()) + }) + } + + pub fn discard_fork( + &mut self, + expected_revision: u64, + review_id: &str, + ) -> Result { + let mut parent_anchor = 0; + self.mutate(expected_revision, |s| { + let review = s + .reviews + .get_mut(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))?; + let branch = s + .branches + .get_mut(&review.fork_id) + .ok_or_else(|| JourneyError::UnknownBranch(review.fork_id.clone()))?; + if !matches!(branch.state, ForkState::Closing | ForkState::Closed) { + return Err(JourneyError::InvalidState("仅关闭中的或已关闭分叉可丢弃")); + } + parent_anchor = branch.anchor_sequence; + branch.state = ForkState::Discarded; + review.state = ReviewState::Discarded; + for fact_id in &review.promoted_fact_ids { + s.facts.remove(fact_id); + } + Ok(()) + })?; + Ok(parent_anchor) + } + + /// Writes no transcript. The caller appends this capsule at the preserved + /// parent continuation boundary, leaving its prompt-cache prefix bytewise + /// untouched and never injecting the fork transcript. + pub fn publish_handoff_capsule( + &mut self, + expected_revision: u64, + fork_id: &str, + capsule: HandoffCapsule, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + capsule.validate()?; + let review_snapshot = { + let review = s + .reviews + .get(&capsule.review_id) + .ok_or_else(|| JourneyError::UnknownReview(capsule.review_id.clone()))?; + ( + review.state.clone(), + review.fork_id.clone(), + review.source_start_sequence, + review.source_end_sequence, + review.provenance.clone(), + ) + }; + let branch = s + .branches + .get_mut(fork_id) + .ok_or_else(|| JourneyError::UnknownBranch(fork_id.into()))?; + if branch.state != ForkState::Closed + || !matches!( + review_snapshot.0, + ReviewState::Ready | ReviewState::Confirmed + ) + || review_snapshot.1 != fork_id + || capsule.fork_id != fork_id + || capsule.parent_branch_id != branch.parent_branch_id + || branch.parent_anchor_message_id.as_deref() + != Some(capsule.parent_anchor_message_id.as_str()) + || capsule.source_start_sequence != review_snapshot.2 + || capsule.source_end_sequence != review_snapshot.3 + || review_snapshot.4.as_ref() != Some(&capsule.provenance) + || branch.handoff_capsule.is_some() + { + return Err(JourneyError::MissingHandoff); + } + branch.handoff_capsule = Some(capsule); + Ok(()) + }) + } + + /// Switch to the exact recorded parent branch only after a usable capsule + /// was published. No transcript rows are deleted or moved. + pub fn return_to_parent( + &mut self, + expected_revision: u64, + review_id: &str, + ) -> Result<(String, String, u64), JourneyError> { + let mut parent = None; + self.mutate(expected_revision, |s| { + let fork_id = { + let review = s + .reviews + .get(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))?; + review.fork_id.clone() + }; + let branch_snapshot = { + let branch = s + .branches + .get(&fork_id) + .ok_or_else(|| JourneyError::UnknownBranch(fork_id.clone()))?; + ( + branch.id.clone(), + branch.state.clone(), + branch.parent_branch_id.clone(), + branch.parent_anchor_message_id.clone(), + branch.anchor_sequence, + branch + .handoff_capsule + .as_ref() + .map(|capsule| capsule.review_id.clone()), + ) + }; + let review_state = s + .reviews + .get(review_id) + .ok_or_else(|| JourneyError::UnknownReview(review_id.into()))? + .state + .clone(); + if s.active_branch_id != branch_snapshot.0 + || branch_snapshot.1 != ForkState::Closed + || !matches!(review_state, ReviewState::Ready | ReviewState::Confirmed) + || branch_snapshot.5.as_deref() != Some(review_id) + { + return Err(JourneyError::MissingHandoff); + } + let anchor = branch_snapshot.3.ok_or(JourneyError::MissingHandoff)?; + parent = Some((branch_snapshot.2.clone(), anchor, branch_snapshot.4)); + s.active_branch_id = branch_snapshot.2; + s.active_task_id = None; + Ok(()) + })?; + Ok(parent.expect("successful parent return supplies its exact anchor")) + } + + pub fn parent_handoff_capsules(&self, parent_branch_id: &str) -> Vec<&HandoffCapsule> { + self.branches + .values() + .filter(|branch| { + branch.parent_branch_id == parent_branch_id && branch.state == ForkState::Closed + }) + .filter_map(|branch| branch.handoff_capsule.as_ref()) + .collect() + } + + /// A branch sees itself, every descendant, and sibling forks with the + /// exact same anchor. It never sees parent continuation after its anchor. + pub fn can_browse_sequence( + &self, + viewer_branch_id: &str, + target_branch_id: &str, + sequence: u64, + ) -> Result { + let viewer = self + .branches + .get(viewer_branch_id) + .ok_or_else(|| JourneyError::UnknownBranch(viewer_branch_id.into()))?; + let target = self + .branches + .get(target_branch_id) + .ok_or_else(|| JourneyError::UnknownBranch(target_branch_id.into()))?; + if viewer_branch_id == target_branch_id { + return Ok(true); + } + if self.is_descendant(target_branch_id, viewer_branch_id) { + return Ok(true); + } + if viewer.parent_branch_id == target.parent_branch_id + && viewer.anchor_sequence == target.anchor_sequence + { + return Ok(true); + } + if target_branch_id == &viewer.parent_branch_id { + return Ok(sequence <= viewer.anchor_sequence); + } + Ok(false) + } + + fn is_descendant(&self, candidate: &str, ancestor: &str) -> bool { + let mut seen = BTreeSet::new(); + let mut cursor = candidate; + while seen.insert(cursor) { + let Some(branch) = self.branches.get(cursor) else { + return false; + }; + if branch.parent_branch_id == branch.id { + return false; + } + if branch.parent_branch_id == ancestor { + return true; + } + cursor = &branch.parent_branch_id; + } + false + } +} + +/// Parsed command intent shared by Desktop/Tauri and Gateway adapters. Parsing +/// and execution are local-only; neither layer may ask an LLM to interpret a +/// lifecycle mutation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JourneyCommand { + TaskStart { + task_id: String, + name: String, + start_from_recent: bool, + }, + TaskCheckpoint { + checkpoint_id: String, + message_id: String, + name: String, + }, + TaskFinish { + outcome: TaskOutcome, + message_id: String, + }, + ForkStart { + fork_id: String, + task_id: String, + anchor_message_id: String, + task_name: String, + }, + ForkClose { + fork_id: String, + review_id: String, + message_id: String, + outcome: TaskOutcome, + }, + Status, + ForkCompare, + ReviewList, + ReviewDiscard { + review_id: String, + }, + ReviewPromote { + review_id: String, + fact_id: String, + evidence_start_message_id: String, + evidence_end_message_id: String, + text: String, + }, +} + +/// One application boundary for every Journey mutation. Callers supply an +/// optional observed revision; commands without one read the current revision +/// under the sessions writer and still commit through repository CAS. +pub struct JourneyApplicationService; + +impl JourneyApplicationService { + fn execute_pre_application_service( + conn: &mut Connection, + session_id: &str, + expected_revision: Option, + command: JourneyCommand, + ) -> Result { + SqliteJourneyRepository::ensure_schema(conn).map_err(|error| error.to_string())?; + let mut journey = SqliteJourneyRepository::load(conn, session_id) + .map_err(journey_error_text)? + .unwrap_or_else(|| SessionJourney::new(session_id, "main")); + let previous_revision = journey.revision; + if let Some(expected_revision) = expected_revision { + if expected_revision != previous_revision { + return Err(journey_error_text(JourneyError::RevisionConflict { + expected: expected_revision, + actual: previous_revision, + })); + } + } + + let response = match command { + JourneyCommand::TaskStart { task_id, name, .. } => { + journey + .start_task(previous_revision, task_id.clone(), name, true, None) + .map_err(journey_error_text)?; + format!("任务已创建:{task_id};将在下一条用户消息持久化后精确激活。") + } + JourneyCommand::TaskCheckpoint { + checkpoint_id, + message_id, + name, + } => { + let sequence = message_sequence(conn, session_id, &message_id)?; + journey + .checkpoint( + previous_revision, + checkpoint_id.clone(), + name, + message_id, + sequence, + ) + .map_err(journey_error_text)?; + format!("检查点已记录:{checkpoint_id}。") + } + JourneyCommand::TaskFinish { + outcome, + message_id, + } => { + let sequence = message_sequence(conn, session_id, &message_id)?; + let label = outcome.chinese_label(); + journey + .finish_task( + previous_revision, + outcome, + sequence, + FinishDisposition::StayInFork, + None, + None, + ) + .map_err(journey_error_text)?; + format!("任务已结束,结果:{label}。") + } + JourneyCommand::ForkStart { + fork_id, + task_id, + anchor_message_id, + task_name, + } => { + let anchor_sequence = message_sequence(conn, session_id, &anchor_message_id)?; + journey + .start_fork( + previous_revision, + fork_id.clone(), + task_id.clone(), + task_name, + anchor_message_id, + anchor_sequence, + ) + .map_err(journey_error_text)?; + format!("分叉已启动:{fork_id};任务 {task_id} 已原子绑定到精确锚点。") + } + JourneyCommand::ForkClose { + fork_id, + review_id, + message_id, + outcome: _, + } => { + let sequence = message_sequence(conn, session_id, &message_id)?; + journey + .request_fork_close( + previous_revision, + &fork_id, + review_id.clone(), + TaskOutcome::Completed, + sequence, + ) + .map_err(journey_error_text)?; + format!("分叉已进入关闭中:{fork_id};审阅项 {review_id} 已进入待审核队列。") + } + JourneyCommand::Status => return Ok(journey_status_text(&journey)), + JourneyCommand::ForkCompare => return Ok(fork_compare_text(&journey)), + JourneyCommand::ReviewList => return Ok(review_list_text(&journey)), + JourneyCommand::ReviewDiscard { review_id } => { + let anchor = journey + .discard_fork(previous_revision, &review_id) + .map_err(journey_error_text)?; + format!("审阅项已丢弃,已回到精确锚点 sequence={anchor}。") + } + JourneyCommand::ReviewPromote { + review_id, + fact_id, + evidence_start_message_id, + evidence_end_message_id, + text, + } => { + let start = message_sequence(conn, session_id, &evidence_start_message_id)?; + let end = message_sequence(conn, session_id, &evidence_end_message_id)?; + journey + .promote_fact( + previous_revision, + &review_id, + fact_id.clone(), + text, + evidence_start_message_id, + start, + evidence_end_message_id, + end, + ) + .map_err(journey_error_text)?; + format!("已在显式审核后确认事实:{fact_id}。") + } + }; + + SqliteJourneyRepository::compare_and_store(conn, &journey, previous_revision) + .map_err(journey_error_text)?; + Ok(response) + } +} + +impl JourneyApplicationService { + /// Compatibility adapter for the existing explicit Gateway command parser. + /// New Desktop/Tauri/Gateway integrations should call the typed Session + /// application service directly. + pub fn execute( + conn: &mut Connection, + session_id: &str, + expected_revision: Option, + command: JourneyCommand, + ) -> Result { + Self::execute_with_provenance(conn, session_id, expected_revision, command, None) + } + + /// Execute a deterministic adapter command. Gateway callers must provide + /// the exact runtime route when closing a fork; domain code never resolves + /// credentials or session persistence by itself. + pub fn execute_with_provenance( + conn: &mut Connection, + session_id: &str, + expected_revision: Option, + command: JourneyCommand, + runtime_provenance: Option, + ) -> Result { + use crate::core::session::journey_application_service as service; + + let revision = match expected_revision { + Some(revision) => revision, + None => { + service::SessionJourneyApplicationService::snapshot(conn, session_id) + .map_err(|error| error.to_string())? + .revision + } + }; + let result = match command { + JourneyCommand::TaskStart { + task_id, + name, + start_from_recent, + } => { + let position = if start_from_recent { + service::TaskStartPosition::最近用户消息 + } else { + service::TaskStartPosition::下一条用户消息 + }; + service::SessionJourneyApplicationService::create_task( + conn, + service::CreateTaskRequest { + session_id: session_id.into(), + expected_revision: revision, + task_id, + name, + position, + }, + ) + .map(|response| { + if start_from_recent { + format!( + "任务已从最近一条用户消息开始(修订 {})。", + response.revision + ) + } else { + format!( + "任务已创建,将在下一条用户消息持久化后激活(修订 {})。", + response.revision + ) + } + }) + } + JourneyCommand::TaskCheckpoint { + checkpoint_id, + message_id, + name, + } => service::SessionJourneyApplicationService::create_checkpoint( + conn, + service::CreateCheckpointRequest { + session_id: session_id.into(), + expected_revision: revision, + checkpoint_id, + name, + message_id, + }, + ) + .map(|response| format!("检查点已记录(修订 {})。", response.revision)), + JourneyCommand::TaskFinish { + outcome, + message_id, + } => { + let label = outcome.chinese_label(); + let message_id = resolve_latest_message_id(conn, session_id, &message_id)?; + service::SessionJourneyApplicationService::finish_task( + conn, + service::FinishTaskRequest { + session_id: session_id.into(), + expected_revision: revision, + outcome, + message_id, + }, + ) + .map(|response| { + format!("任务已结束,结果:{label}(修订 {})。", response.revision) + }) + } + JourneyCommand::ForkStart { + fork_id, + task_id, + anchor_message_id, + task_name, + } => service::SessionJourneyApplicationService::create_fork( + conn, + service::CreateForkRequest { + session_id: session_id.into(), + expected_revision: revision, + fork_id, + task_id, + task_name, + anchor_message_id, + }, + ) + .map(|response| format!("分叉与活动任务已原子创建(修订 {})。", response.revision)), + JourneyCommand::ForkClose { + fork_id, + review_id, + message_id, + outcome, + } => { + let fork_id = resolve_active_fork_id(conn, session_id, &fork_id)?; + let review_id = resolve_active_review_id(conn, session_id, &review_id)?; + let message_id = resolve_latest_message_id(conn, session_id, &message_id)?; + let request = service::RequestForkCloseRequest { + session_id: session_id.into(), + expected_revision: revision, + fork_id, + review_id, + outcome, + message_id, + }; + let job_id = format!("journey-review-{}", request.review_id); + let provenance = runtime_provenance.ok_or_else(|| { + "关闭分叉需要当前会话的精确模型、账户和协议,未解析时禁止排队。".to_string() + })?; + service::SessionJourneyApplicationService::request_fork_close_and_enqueue( + conn, request, job_id, provenance, + ) + .map(|job| { + format!( + "分叉已进入关闭中,审阅项已进入待审核队列(任务 {});尚未生成总结。", + job.job_id + ) + }) + } + JourneyCommand::Status => { + service::SessionJourneyApplicationService::snapshot(conn, session_id) + .map(|response| journey_status_text(&response.snapshot)) + } + JourneyCommand::ForkCompare => { + service::SessionJourneyApplicationService::snapshot(conn, session_id) + .map(|response| fork_compare_text(&response.snapshot)) + } + JourneyCommand::ReviewList => service::SessionJourneyApplicationService::review_queue( + conn, session_id, + ) + .map(|reviews| { + if reviews.is_empty() { + "当前没有待审核项。".into() + } else { + format!( + "待审核项:{}", + reviews + .iter() + .map(|review| review.id.as_str()) + .collect::>() + .join("、") + ) + } + }), + JourneyCommand::ReviewDiscard { review_id } => { + service::SessionJourneyApplicationService::discard_fork( + conn, + service::DiscardForkRequest { + session_id: session_id.into(), + expected_revision: revision, + review_id, + }, + ) + .map(|response| { + format!( + "分叉已丢弃,父分叉 {} 的精确锚点为 sequence={}(修订 {})。", + response.parent_branch_id, + response.parent_anchor_sequence, + response.revision + ) + }) + } + JourneyCommand::ReviewPromote { + review_id, + fact_id, + evidence_start_message_id, + evidence_end_message_id, + text, + } => service::SessionJourneyApplicationService::promote_confirmed_fact( + conn, + service::PromoteFactRequest { + session_id: session_id.into(), + expected_revision: revision, + review_id, + fact_id, + evidence_start_message_id, + evidence_end_message_id, + text, + }, + ) + .map(|response| format!("已确认事实(修订 {})。", response.revision)), + }; + result.map_err(|error| error.to_string()) + } +} + +fn resolve_latest_message_id( + conn: &Connection, + session_id: &str, + requested: &str, +) -> Result { + if requested != "@latest" { + return Ok(requested.to_string()); + } + conn.query_row( + "SELECT id FROM agent_messages WHERE session_id=?1 ORDER BY sequence DESC LIMIT 1", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| "当前会话没有可用的精确消息锚点。".to_string()) +} + +fn resolve_active_fork_id( + conn: &Connection, + session_id: &str, + requested: &str, +) -> Result { + if requested != "@active" { + return Ok(requested.to_string()); + } + let snapshot = service_snapshot(conn, session_id)?; + (snapshot.active_branch_id != "main") + .then_some(snapshot.active_branch_id) + .ok_or_else(|| "当前不在活动分叉中,无法关闭。".to_string()) +} + +fn resolve_active_review_id( + conn: &Connection, + session_id: &str, + requested: &str, +) -> Result { + if requested != "@active" { + return Ok(requested.to_string()); + } + let snapshot = service_snapshot(conn, session_id)?; + Ok(format!("review-{}", snapshot.active_branch_id)) +} + +fn service_snapshot(conn: &Connection, session_id: &str) -> Result { + crate::core::session::journey_application_service::SessionJourneyApplicationService::snapshot( + conn, session_id, + ) + .map(|response| response.snapshot) + .map_err(|error| error.to_string()) +} + +fn journey_status_text(journey: &SessionJourney) -> String { + let task = journey.active_task_id.as_deref().unwrap_or("无"); + format!( + "Journey:修订 {};活动分支 `{}`;活动任务 `{task}`;审阅项 {} 个。", + journey.revision, + journey.active_branch_id, + journey.reviews.len() + ) +} + +fn fork_compare_text(journey: &SessionJourney) -> String { + let mut groups: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for fork in journey + .branches + .values() + .filter(|fork| fork.id != fork.parent_branch_id) + { + groups.entry(fork.anchor_sequence).or_default().push(fork); + } + let lines: Vec<_> = groups + .into_iter() + .filter(|(_, forks)| forks.len() > 1) + .map(|(anchor, forks)| { + format!( + "锚点 {anchor}:{}", + forks + .into_iter() + .map(|fork| fork.id.as_str()) + .collect::>() + .join("、") + ) + }) + .collect(); + if lines.is_empty() { + "当前没有同锚点分叉可对比。".into() + } else { + format!("同锚点分叉对比:\n{}", lines.join("\n")) + } +} + +fn message_sequence(conn: &Connection, session_id: &str, message_id: &str) -> Result { + conn.query_row( + "SELECT sequence FROM agent_messages WHERE session_id = ?1 AND id = ?2", + params![session_id, message_id], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|error| error.to_string())? + .filter(|sequence| *sequence >= 0) + .map(|sequence| sequence as u64) + .ok_or_else(|| "未找到该会话中的精确消息锚点。".to_string()) +} + +fn review_list_text(journey: &SessionJourney) -> String { + if journey.reviews.is_empty() { + return "当前没有审阅项。".to_string(); + } + let items = journey + .reviews + .values() + .map(|review| format!("{}:{}", review.id, review.state.chinese_label())) + .collect::>() + .join("\n"); + format!("审阅项:\n{items}") +} + +fn journey_error_text(error: JourneyError) -> String { + match error { + JourneyError::RevisionConflict { expected, actual } => { + format!("Journey 修订冲突:期望 {expected},当前 {actual}。") + } + JourneyError::DuplicateId(id) => format!("Journey ID 已存在:{id}。"), + JourneyError::UnknownBranch(id) => format!("未知分叉:{id}。"), + JourneyError::UnknownTask(id) => format!("未知任务:{id}。"), + JourneyError::UnknownReview(id) => format!("未知审阅项:{id}。"), + JourneyError::NoActiveTask => "当前没有活动任务。".to_string(), + JourneyError::ActiveTaskExists => "当前已有活动任务。".to_string(), + JourneyError::InvalidState(message) => message.to_string(), + JourneyError::InvalidSequence => "无效的精确 sequence。".to_string(), + JourneyError::MissingRuntimeProvenance => "缺少运行来源信息。".to_string(), + JourneyError::AccessDenied => "该精确锚点不在审阅范围内。".to_string(), + JourneyError::MissingHandoff => "缺少有效的中文交接胶囊。".to_string(), + } +} + +/// SQLite persistence boundary for the lifecycle aggregate. The aggregate is +/// deliberately one JSON document: a lifecycle change updates the active +/// branch, active task and revision in one transaction, so no reader can see +/// an impossible intermediate combination. +pub struct SqliteJourneyRepository; + +impl SqliteJourneyRepository { + pub fn ensure_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_journeys ( + session_id TEXT PRIMARY KEY NOT NULL, + revision INTEGER NOT NULL, + state_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS session_journey_memberships ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + branch_id TEXT NOT NULL, + task_id TEXT, + PRIMARY KEY (session_id, message_id) + ); + CREATE TABLE IF NOT EXISTS session_journey_turn_memberships ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + completed_sequence INTEGER NOT NULL, + branch_id TEXT NOT NULL, + task_id TEXT, + PRIMARY KEY (session_id, turn_id) + ); + CREATE INDEX IF NOT EXISTS idx_session_journey_memberships_sequence + ON session_journey_memberships (session_id, sequence)", + ) + } + + pub fn load( + conn: &Connection, + session_id: &str, + ) -> Result, JourneyError> { + Self::ensure_schema(conn) + .map_err(|_| JourneyError::InvalidState("无法初始化 Journey 存储"))?; + Self::load_existing(conn, session_id) + } + + /// Read a durable Journey aggregate without initializing schema. + /// + /// Read-only consumers (for example the canonical graph query) must not + /// turn a lookup into a database mutation. An older database with no + /// Journey table simply has no Journey aggregate yet; a present but + /// unreadable/corrupt record still fails closed. + pub fn load_existing( + conn: &Connection, + session_id: &str, + ) -> Result, JourneyError> { + let has_table: Option = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_journeys'", + [], + |row| row.get(0), + ) + .optional() + .map_err(|_| JourneyError::InvalidState("无法读取 Journey 存储"))?; + if has_table.is_none() { + return Ok(None); + } + let json: Option = conn + .query_row( + "SELECT state_json FROM session_journeys WHERE session_id = ?1", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|_| JourneyError::InvalidState("无法读取 Journey 存储"))?; + json.map(|value| { + serde_json::from_str(&value) + .map_err(|_| JourneyError::InvalidState("Journey 存储已损坏")) + }) + .transpose() + } + + pub fn compare_and_store( + conn: &mut Connection, + journey: &SessionJourney, + expected_previous_revision: u64, + ) -> Result<(), JourneyError> { + Self::ensure_schema(conn) + .map_err(|_| JourneyError::InvalidState("无法初始化 Journey 存储"))?; + if journey.revision != expected_previous_revision.saturating_add(1) { + return Err(JourneyError::RevisionConflict { + expected: expected_previous_revision.saturating_add(1), + actual: journey.revision, + }); + } + let tx = conn + .transaction() + .map_err(|_| JourneyError::InvalidState("无法开启 Journey 事务"))?; + let current: Option = tx + .query_row( + "SELECT revision FROM session_journeys WHERE session_id = ?1", + [&journey.session_id], + |row| row.get(0), + ) + .optional() + .map_err(|_| JourneyError::InvalidState("无法读取 Journey 修订"))?; + let actual = current.unwrap_or(0); + if current.is_some() && actual != expected_previous_revision { + return Err(JourneyError::RevisionConflict { + expected: expected_previous_revision, + actual, + }); + } + if current.is_none() && expected_previous_revision != 0 { + return Err(JourneyError::RevisionConflict { + expected: expected_previous_revision, + actual: 0, + }); + } + let state_json = serde_json::to_string(journey) + .map_err(|_| JourneyError::InvalidState("无法序列化 Journey"))?; + tx.execute( + "INSERT INTO session_journeys (session_id, revision, state_json) VALUES (?1, ?2, ?3) + ON CONFLICT(session_id) DO UPDATE SET revision = excluded.revision, state_json = excluded.state_json", + params![journey.session_id, journey.revision, state_json], + ).map_err(|_| JourneyError::InvalidState("无法写入 Journey"))?; + tx.commit() + .map_err(|_| JourneyError::InvalidState("无法提交 Journey"))?; + Ok(()) + } + + /// Store inside a caller-owned transaction. This is used when a durable + /// transcript write must activate a pending task atomically. + pub fn compare_and_store_in_transaction( + tx: &Transaction<'_>, + journey: &SessionJourney, + expected_previous_revision: u64, + ) -> Result<(), JourneyError> { + let current: Option = tx + .query_row( + "SELECT revision FROM session_journeys WHERE session_id = ?1", + [&journey.session_id], + |row| row.get(0), + ) + .optional() + .map_err(|_| JourneyError::InvalidState("无法读取 Journey 修订"))?; + let actual = current.unwrap_or(0); + if current.is_some() && actual != expected_previous_revision { + return Err(JourneyError::RevisionConflict { + expected: expected_previous_revision, + actual, + }); + } + if current.is_none() && expected_previous_revision != 0 { + return Err(JourneyError::RevisionConflict { + expected: expected_previous_revision, + actual: 0, + }); + } + let state_json = serde_json::to_string(journey) + .map_err(|_| JourneyError::InvalidState("无法序列化 Journey"))?; + tx.execute( + "INSERT INTO session_journeys (session_id, revision, state_json) VALUES (?1, ?2, ?3) + ON CONFLICT(session_id) DO UPDATE SET revision = excluded.revision, state_json = excluded.state_json", + params![journey.session_id, journey.revision, state_json], + ) + .map_err(|_| JourneyError::InvalidState("无法写入 Journey"))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn provenance() -> RuntimeProvenance { + RuntimeProvenance { + model_id: "m".into(), + account_id: "a".into(), + protocol: "openai".into(), + } + } + fn capsule() -> HandoffCapsule { + HandoffCapsule { + fork_id: "f1".into(), + review_id: "r".into(), + parent_branch_id: "main".into(), + parent_anchor_message_id: "anchor-10".into(), + source_start_sequence: 11, + source_end_sequence: 15, + objective: "核对分叉结论".into(), + conclusion: "可以从主干继续".into(), + open_questions: vec!["是否补充回归验证".into()], + confirmed_items: vec!["锚点保持不变".into()], + evidence_references: vec!["检查点 anchor-10".into()], + generated_at: Some("仅元数据".into()), + provenance: provenance(), + } + } + fn forked() -> SessionJourney { + let mut j = SessionJourney::new("s", "main"); + j.start_fork( + 0, + "f1".into(), + "t1".into(), + "调查".into(), + "anchor-10".into(), + 10, + ) + .unwrap(); + j + } + + #[test] + fn latest_and_next_task_activation_are_sequence_based() { + let mut j = SessionJourney::new("s", "main"); + j.start_task(0, "a".into(), "最新任务".into(), false, Some(7)) + .unwrap(); + assert_eq!(j.tasks["a"].start_sequence, Some(7)); + j.finish_task( + 1, + TaskOutcome::Completed, + 8, + FinishDisposition::StayInFork, + None, + None, + ) + .unwrap(); + j.start_task(2, "b".into(), "下一条".into(), true, None) + .unwrap(); + j.on_user_message_persisted(3, 9).unwrap(); + assert_eq!(j.tasks["b"].start_sequence, Some(9)); + } + #[test] + fn checkpoint_is_attached_to_exact_message() { + let mut j = forked(); + j.checkpoint(1, "c".into(), "结论".into(), "msg-12".into(), 12) + .unwrap(); + assert_eq!(j.checkpoints["c"].message_id, "msg-12"); + } + #[test] + fn fork_always_creates_active_task() { + let j = forked(); + assert_eq!(j.active_branch_id, "f1"); + assert_eq!(j.active_task_id.as_deref(), Some("t1")); + } + #[test] + fn sibling_is_visible_but_parent_future_is_denied() { + let mut j = forked(); + j.finish_task( + 1, + TaskOutcome::Completed, + 12, + FinishDisposition::StayInFork, + None, + None, + ) + .unwrap(); + j.active_branch_id = "main".into(); + j.start_fork( + 2, + "f2".into(), + "t2".into(), + "同锚点".into(), + "anchor-10".into(), + 10, + ) + .unwrap(); + assert!(j.can_browse_sequence("f1", "f2", 11).unwrap()); + assert!(!j.can_browse_sequence("f1", "main", 11).unwrap()); + assert!(j.can_browse_sequence("f1", "main", 10).unwrap()); + } + #[test] + fn close_freezes_range_and_queues_review() { + let mut j = forked(); + j.finish_task( + 1, + TaskOutcome::PartiallyCompleted, + 15, + FinishDisposition::CloseFork, + Some("work".into()), + Some(provenance()), + ) + .unwrap(); + assert_eq!(j.branches["f1"].frozen_end_sequence, Some(15)); + j.complete_close( + 2, + "f1", + "r".into(), + Some(provenance()), + Some("AI 标注".into()), + ) + .unwrap(); + assert_eq!(j.reviews["r"].state, ReviewState::Queued); + } + #[test] + fn discard_returns_exact_anchor_and_removes_promotions() { + let mut j = forked(); + j.finish_task( + 1, + TaskOutcome::Completed, + 15, + FinishDisposition::CloseFork, + Some("w".into()), + Some(provenance()), + ) + .unwrap(); + j.complete_close(2, "f1", "r".into(), Some(provenance()), None) + .unwrap(); + j.mark_review_ready(3, "r", provenance(), "待审核标注".into()) + .unwrap(); + j.promote_fact( + 4, + "r", + "fact".into(), + "确认".into(), + "m11".into(), + 11, + "m15".into(), + 15, + ) + .unwrap(); + assert_eq!(j.discard_fork(5, "r").unwrap(), 10); + assert!(j.facts.is_empty()); + } + #[test] + fn ready_closed_review_publishes_immutable_handoff_and_returns_exact_parent() { + let mut j = forked(); + j.finish_task( + 1, + TaskOutcome::Completed, + 15, + FinishDisposition::CloseFork, + Some("w".into()), + Some(provenance()), + ) + .unwrap(); + j.complete_close( + 2, + "f1", + "r".into(), + Some(provenance()), + Some("原始分叉记录不得进入主干".into()), + ) + .unwrap(); + j.mark_review_ready(3, "r", provenance(), "审核结论".into()) + .unwrap(); + j.publish_handoff_capsule(4, "f1", capsule()).unwrap(); + assert_eq!( + j.branches["f1"] + .handoff_capsule + .as_ref() + .map(|capsule| capsule.conclusion.as_str()), + Some("可以从主干继续") + ); + assert_ne!( + j.branches["f1"] + .handoff_capsule + .as_ref() + .map(HandoffCapsule::synthetic_prompt_message), + j.reviews["r"] + .annotation + .as_ref() + .map(|text| serde_json::json!({ "role": "user", "content": text })) + ); + assert_eq!( + j.return_to_parent(5, "r").unwrap(), + ("main".into(), "anchor-10".into(), 10) + ); + assert_eq!(j.active_branch_id, "main"); + } + + #[test] + fn queued_or_discarded_review_never_publishes_handoff() { + let mut j = forked(); + j.finish_task( + 1, + TaskOutcome::Completed, + 15, + FinishDisposition::CloseFork, + Some("w".into()), + Some(provenance()), + ) + .unwrap(); + j.complete_close(2, "f1", "r".into(), Some(provenance()), None) + .unwrap(); + assert!(matches!( + j.publish_handoff_capsule(3, "f1", capsule()), + Err(JourneyError::MissingHandoff) + )); + j.mark_review_ready(3, "r", provenance(), "审核结论".into()) + .unwrap(); + j.discard_fork(4, "r").unwrap(); + assert!(matches!( + j.publish_handoff_capsule(5, "f1", capsule()), + Err(JourneyError::MissingHandoff) + )); + } + #[test] + fn lifecycle_is_cas_and_never_needs_llm() { + let mut j = SessionJourney::new("s", "main"); + assert!(matches!( + j.start_task(1, "t".into(), "任务".into(), false, Some(1)), + Err(JourneyError::RevisionConflict { .. }) + )); + j.start_task(0, "t".into(), "任务".into(), false, Some(1)) + .unwrap(); + assert_eq!(TaskOutcome::Redirected.chinese_label(), "转向"); + } + #[test] + fn repository_preserves_revision_cas_across_reopen() { + let mut conn = Connection::open_in_memory().unwrap(); + let mut j = SessionJourney::new("s", "main"); + j.start_task(0, "t".into(), "持久任务".into(), false, Some(3)) + .unwrap(); + SqliteJourneyRepository::compare_and_store(&mut conn, &j, 0).unwrap(); + assert_eq!( + SqliteJourneyRepository::load(&conn, "s") + .unwrap() + .unwrap() + .active_task_id + .as_deref(), + Some("t") + ); + assert!(matches!( + SqliteJourneyRepository::compare_and_store(&mut conn, &j, 0), + Err(JourneyError::RevisionConflict { .. }) + )); + } + + #[test] + fn read_only_load_does_not_create_journey_schema() { + let conn = Connection::open_in_memory().unwrap(); + assert!(SqliteJourneyRepository::load_existing(&conn, "s") + .unwrap() + .is_none()); + let table_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'session_journeys'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(table_count, 0); + } + + #[test] + fn read_only_load_rejects_corrupt_existing_state() { + let conn = Connection::open_in_memory().unwrap(); + SqliteJourneyRepository::ensure_schema(&conn).unwrap(); + conn.execute( + "INSERT INTO session_journeys (session_id, revision, state_json) VALUES (?1, ?2, ?3)", + params!["s", 1_u64, "not-json"], + ) + .unwrap(); + assert!(matches!( + SqliteJourneyRepository::load_existing(&conn, "s"), + Err(JourneyError::InvalidState("Journey 存储已损坏")) + )); + } + + #[test] + fn persisted_user_membership_activates_pending_task_in_the_same_transaction() { + let mut conn = Connection::open_in_memory().unwrap(); + SqliteJourneyRepository::ensure_schema(&conn).unwrap(); + let mut journey = SessionJourney::new("s", "main"); + journey + .start_task(0, "t".into(), "下一条任务".into(), true, None) + .unwrap(); + SqliteJourneyRepository::compare_and_store(&mut conn, &journey, 0).unwrap(); + + let tx = conn.transaction().unwrap(); + let mut loaded = SqliteJourneyRepository::load(&tx, "s").unwrap().unwrap(); + loaded.on_user_message_persisted(1, 42).unwrap(); + SqliteJourneyRepository::compare_and_store_in_transaction(&tx, &loaded, 1).unwrap(); + tx.execute( + "INSERT INTO session_journey_memberships + (session_id, message_id, sequence, branch_id, task_id) + VALUES ('s', 'msg-42', 42, 'main', 't')", + [], + ) + .unwrap(); + tx.commit().unwrap(); + + let reopened = SqliteJourneyRepository::load(&conn, "s").unwrap().unwrap(); + assert_eq!(reopened.tasks["t"].start_sequence, Some(42)); + let membership: (String, i64, String, Option) = conn + .query_row( + "SELECT message_id, sequence, branch_id, task_id + FROM session_journey_memberships WHERE session_id = 's'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!( + membership, + ("msg-42".into(), 42, "main".into(), Some("t".into())) + ); + } + + fn application_test_schema(conn: &Connection) { + conn.execute_batch( + "CREATE TABLE agent_sessions (session_id TEXT PRIMARY KEY); + CREATE TABLE agent_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'user' + ); + INSERT INTO agent_sessions (session_id) VALUES ('s');", + ) + .unwrap(); + } + + #[test] + fn application_service_uses_exact_message_ids_and_cas() { + let mut conn = Connection::open_in_memory().unwrap(); + application_test_schema(&conn); + conn.execute( + "INSERT INTO agent_messages (id, session_id, sequence) VALUES ('anchor', 's', 7)", + [], + ) + .unwrap(); + + let reply = JourneyApplicationService::execute( + &mut conn, + "s", + Some(0), + JourneyCommand::TaskStart { + task_id: "task-a".into(), + name: "中文任务".into(), + start_from_recent: false, + }, + ) + .unwrap(); + assert_eq!( + reply, + "任务已创建,将在下一条用户消息持久化后激活(修订 1)。" + ); + assert_eq!( + JourneyApplicationService::execute( + &mut conn, + "s", + Some(0), + JourneyCommand::ReviewList, + ) + .unwrap(), + "当前没有待审核项。" + ); + } + + #[test] + fn fork_close_without_exact_runtime_provenance_fails_closed() { + let mut conn = Connection::open_in_memory().unwrap(); + application_test_schema(&conn); + conn.execute( + "INSERT INTO agent_messages (id, session_id, sequence) VALUES ('anchor', 's', 9)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (id, session_id, sequence) VALUES ('fork-end', 's', 10)", + [], + ) + .unwrap(); + JourneyApplicationService::execute( + &mut conn, + "s", + None, + JourneyCommand::ForkStart { + fork_id: "fork-a".into(), + task_id: "task-a".into(), + anchor_message_id: "anchor".into(), + task_name: "调查".into(), + }, + ) + .unwrap(); + let error = JourneyApplicationService::execute( + &mut conn, + "s", + None, + JourneyCommand::ForkClose { + fork_id: "fork-a".into(), + review_id: "review-a".into(), + message_id: "fork-end".into(), + outcome: TaskOutcome::Completed, + }, + ) + .unwrap_err(); + assert!(error.contains("精确模型、账户和协议")); + assert!(SqliteJourneyRepository::load(&conn, "s") + .unwrap() + .unwrap() + .reviews + .is_empty()); + } + + #[test] + fn application_service_fork_close_queues_review_at_exact_anchor() { + let mut conn = Connection::open_in_memory().unwrap(); + application_test_schema(&conn); + conn.execute( + "INSERT INTO agent_messages (id, session_id, sequence) VALUES ('anchor', 's', 9)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (id, session_id, sequence) VALUES ('fork-end', 's', 10)", + [], + ) + .unwrap(); + let reply = JourneyApplicationService::execute( + &mut conn, + "s", + None, + JourneyCommand::ForkStart { + fork_id: "fork-a".into(), + task_id: "task-a".into(), + anchor_message_id: "anchor".into(), + task_name: "调查".into(), + }, + ) + .unwrap(); + assert_eq!(reply, "分叉与活动任务已原子创建(修订 1)。"); + let reply = JourneyApplicationService::execute_with_provenance( + &mut conn, + "s", + None, + JourneyCommand::ForkClose { + fork_id: "fork-a".into(), + review_id: "review-a".into(), + message_id: "fork-end".into(), + outcome: TaskOutcome::Completed, + }, + Some(provenance()), + ) + .unwrap(); + assert_eq!( + reply, + "分叉已进入关闭中,审阅项已进入待审核队列(任务 journey-review-review-a);尚未生成总结。" + ); + assert_eq!( + JourneyApplicationService::execute(&mut conn, "s", None, JourneyCommand::ReviewList) + .unwrap(), + "待审核项:review-a" + ); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/journey_review_queue.rs b/src-tauri/crates/agent-core/src/core/session/journey_review_queue.rs new file mode 100644 index 0000000000..e168020f9a --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_review_queue.rs @@ -0,0 +1,1293 @@ +//! Durable, provider-neutral review queue for closed Session Journey forks. +//! +//! This module owns the durable queue contract and its host-neutral executor. +//! The desktop composition root starts the executor explicitly; closing a +//! fork remains a fast local operation and only emits a coalesced wake. + +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::Notify; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::core::journey_lifecycle::{RuntimeProvenance, SessionJourney}; +use crate::core::session::journey_application_service::{ + FailReviewRequest, MarkReviewReadyRequest, SessionJourneyApplicationService, +}; +use crate::core::side_query::{side_query_typed, SideQueryConfig, StructuredOutput}; +use crate::providers::traits::LLMProvider; + +const RECOVERY_TICK: Duration = Duration::from_secs(30); +const STALE_LEASE_SECS: u64 = 5 * 60; +const MAX_JOBS_PER_WAKE: usize = 4; + +/// Narrow host boundary: foreground application services can wake a worker +/// without knowing whether the host is Tauri, a test harness, or another UI. +pub trait ReviewQueueNotifier: Send + Sync { + fn notify_review_queue(&self); +} + +static REVIEW_QUEUE_NOTIFIER: OnceLock>>> = + OnceLock::new(); + +fn notifier_slot() -> &'static RwLock>> { + REVIEW_QUEUE_NOTIFIER.get_or_init(|| RwLock::new(None)) +} + +pub fn install_review_queue_notifier(notifier: Arc) { + if let Ok(mut slot) = notifier_slot().write() { + *slot = Some(notifier); + } +} + +pub fn notify_review_queue() { + if let Ok(slot) = notifier_slot().read() { + if let Some(notifier) = slot.as_ref() { + notifier.notify_review_queue(); + } + } +} + +#[derive(Clone)] +pub struct JourneyReviewExecutorHandle { + inner: Arc, +} + +struct JourneyReviewExecutorInner { + wake: Notify, + cancel: CancellationToken, + join: Mutex>>, + runner: Arc, +} + +#[async_trait] +trait ExecutorJobRunner: Send + Sync { + /// `true` means the executor may immediately attempt another job; `false` + /// means the queue was empty or unavailable and it should wait for a wake. + async fn run_one(&self) -> bool; +} + +struct DurableReviewJobRunner; + +#[async_trait] +impl ExecutorJobRunner for DurableReviewJobRunner { + async fn run_one(&self) -> bool { + if run_durable_job().await { + return true; + } + run_durable_embedding_job().await + } +} + +impl JourneyReviewExecutorHandle { + /// Starts one durable queue consumer. Calling this once from the desktop + /// composition root is intentional; database claims also protect against + /// a second process or an accidental second handle. + pub fn spawn() -> Self { + Self::spawn_with_runner(Arc::new(DurableReviewJobRunner)) + } + + fn spawn_with_runner(runner: Arc) -> Self { + let inner = Arc::new(JourneyReviewExecutorInner { + wake: Notify::new(), + cancel: CancellationToken::new(), + join: Mutex::new(None), + runner, + }); + let task_inner = Arc::clone(&inner); + let join = tokio::spawn(async move { run_executor(task_inner).await }); + if let Ok(mut slot) = inner.join.lock() { + *slot = Some(join); + } else { + join.abort(); + tracing::warn!("[journey-review] 无法保存 executor handle"); + } + Self { inner } + } + + /// Initialize the durable queue before the worker starts accepting wakes. + /// A missing database must not prevent the desktop application from + /// launching; the periodic worker recovery path will retry later. + pub fn ensure_schema_at_startup() { + match crate::foundation::db_bridge::get_connection() + .map_err(|error| QueueError::Storage(format!("无法打开审核队列数据库:{error}"))) + .and_then(|conn| ReviewJobRepository::ensure_schema(&conn)) + { + Ok(()) => {} + Err(error) => tracing::error!(error = %error, "[journey-review] 审核队列表初始化失败"), + } + } + + pub fn wake(&self) { + self.inner.wake.notify_one(); + } + + /// Synchronous, non-blocking half of shutdown. Hosts may call this from + /// event-loop callbacks where awaiting a task would deadlock or hang exit. + pub fn request_shutdown(&self) { + self.inner.cancel.cancel(); + self.inner.wake.notify_waiters(); + } + + pub async fn shutdown(&self) { + self.request_shutdown(); + let join = match self.inner.join.lock() { + Ok(mut slot) => slot.take(), + Err(_) => { + tracing::warn!("[journey-review] executor handle 锁已损坏"); + None + } + }; + if let Some(join) = join { + if let Err(error) = join.await { + tracing::warn!(error = %error, "[journey-review] executor join failed"); + } + } + } +} + +impl ReviewQueueNotifier for JourneyReviewExecutorHandle { + fn notify_review_queue(&self) { + self.wake(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewJobState { + Queued, + Running, + Ready, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewJob { + pub job_id: String, + pub review_id: String, + pub fork_id: String, + pub session_id: String, + pub frozen_start_sequence: u64, + pub frozen_end_sequence: u64, + pub exact_anchor_message_id: String, + pub revision: u64, + pub state: ReviewJobState, + pub attempt: u32, + pub error: Option, + pub model_id: String, + pub account_id: String, + pub protocol: String, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewDraftAnnotation { + pub objective: String, + pub conclusion: String, + pub open_questions: Vec, + pub confirmation_items: Vec, + pub evidence_message_ids: Vec, + pub possibly_no_value: bool, + pub source_range: (u64, u64), + pub provenance: RuntimeProvenance, + pub critic_notes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueueError { + Storage(String), + Conflict(String), + Invalid(String), +} + +impl std::fmt::Display for QueueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Storage(s) | Self::Conflict(s) | Self::Invalid(s) => f.write_str(s), + } + } +} +impl std::error::Error for QueueError {} + +fn now() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_else(|_| "0".into()) +} + +pub struct ReviewJobRepository; + +impl ReviewJobRepository { + pub fn ensure_schema(conn: &Connection) -> Result<(), QueueError> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_journey_review_jobs ( + job_id TEXT PRIMARY KEY NOT NULL, + review_id TEXT NOT NULL UNIQUE, + fork_id TEXT NOT NULL, + session_id TEXT NOT NULL, + frozen_start_sequence INTEGER NOT NULL, + frozen_end_sequence INTEGER NOT NULL, + exact_anchor_message_id TEXT NOT NULL, + revision INTEGER NOT NULL, + state TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + error TEXT, + model_id TEXT NOT NULL, + account_id TEXT NOT NULL, + protocol TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_journey_review_jobs_state + ON session_journey_review_jobs(state, created_at);", + ) + .map_err(|e| QueueError::Storage(format!("无法初始化审核队列:{e}"))) + } + + pub fn enqueue( + conn: &Connection, + journey: &SessionJourney, + job_id: impl Into, + review_id: &str, + provenance: &RuntimeProvenance, + ) -> Result { + Self::ensure_schema(conn)?; + let review = journey + .reviews + .get(review_id) + .ok_or_else(|| QueueError::Invalid("未找到待审核项。".into()))?; + let fork = journey + .branches + .get(&review.fork_id) + .ok_or_else(|| QueueError::Invalid("未找到分叉。".into()))?; + if review.source_start_sequence > review.source_end_sequence + || fork.parent_anchor_message_id.is_none() + { + return Err(QueueError::Invalid("审核范围或精确锚点无效。".into())); + } + provenance + .validate_for_queue() + .map_err(QueueError::Invalid)?; + if let Some(existing) = Self::get(conn, review_id)? { + return Ok(existing); + } + let job = ReviewJob { + job_id: job_id.into(), + review_id: review_id.into(), + fork_id: review.fork_id.clone(), + session_id: journey.session_id.clone(), + frozen_start_sequence: review.source_start_sequence, + frozen_end_sequence: review.source_end_sequence, + exact_anchor_message_id: fork.parent_anchor_message_id.clone().unwrap(), + revision: journey.revision, + state: ReviewJobState::Queued, + attempt: 0, + error: None, + model_id: provenance.model_id.clone(), + account_id: provenance.account_id.clone(), + protocol: provenance.protocol.clone(), + created_at: now(), + started_at: None, + finished_at: None, + }; + conn.execute( + "INSERT INTO session_journey_review_jobs + (job_id,review_id,fork_id,session_id,frozen_start_sequence,frozen_end_sequence, + exact_anchor_message_id,revision,state,attempt,error,model_id,account_id,protocol,created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,'queued',0,NULL,?9,?10,?11,?12)", + params![job.job_id, job.review_id, job.fork_id, job.session_id, + job.frozen_start_sequence as i64, job.frozen_end_sequence as i64, + job.exact_anchor_message_id, job.revision as i64, job.model_id, + job.account_id, job.protocol, job.created_at], + ) + .map_err(|e| QueueError::Storage(format!("无法排队审核:{e}")))?; + Ok(job) + } + + pub fn get(conn: &Connection, review_id: &str) -> Result, QueueError> { + Self::ensure_schema(conn)?; + conn.query_row("SELECT job_id,review_id,fork_id,session_id,frozen_start_sequence,frozen_end_sequence,exact_anchor_message_id,revision,state,attempt,error,model_id,account_id,protocol,created_at,started_at,finished_at FROM session_journey_review_jobs WHERE review_id=?1", [review_id], row_job) + .optional().map_err(|e| QueueError::Storage(format!("无法读取审核队列:{e}"))) + } + + pub fn list(conn: &Connection) -> Result, QueueError> { + Self::ensure_schema(conn)?; + let mut stmt = conn + .prepare("SELECT job_id,review_id,fork_id,session_id,frozen_start_sequence,frozen_end_sequence,exact_anchor_message_id,revision,state,attempt,error,model_id,account_id,protocol,created_at,started_at,finished_at FROM session_journey_review_jobs ORDER BY created_at") + .map_err(|e| QueueError::Storage(format!("无法列出审核队列:{e}")))?; + let rows = stmt + .query_map([], row_job) + .map_err(|e| QueueError::Storage(format!("无法列出审核队列:{e}")))?; + rows.map(|row| row.map_err(|e| QueueError::Storage(format!("无法读取审核任务:{e}")))) + .collect() + } + + pub fn claim( + conn: &mut Connection, + worker_id: &str, + stale_after_secs: u64, + ) -> Result, QueueError> { + Self::ensure_schema(conn)?; + conn.busy_timeout(Duration::from_secs(1)) + .map_err(|e| QueueError::Storage(e.to_string()))?; + let cutoff = now() + .parse::() + .unwrap_or(0) + .saturating_sub(stale_after_secs) + .to_string(); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| QueueError::Storage(e.to_string()))?; + tx.execute( + "UPDATE session_journey_review_jobs SET state='queued', started_at=NULL WHERE state='running' AND started_at < ?1", + [cutoff], + ) + .map_err(|e| QueueError::Storage(e.to_string()))?; + let review_id: Option = tx.query_row("SELECT review_id FROM session_journey_review_jobs WHERE state='queued' ORDER BY created_at LIMIT 1", [], |r| r.get(0)).optional().map_err(|e| QueueError::Storage(e.to_string()))?; + let Some(review_id) = review_id else { + tx.commit() + .map_err(|e| QueueError::Storage(e.to_string()))?; + return Ok(None); + }; + let changed = tx.execute("UPDATE session_journey_review_jobs SET state='running',started_at=?1,attempt=attempt+1,error=NULL WHERE review_id=?2 AND state='queued'", params![now(), review_id]).map_err(|e| QueueError::Storage(e.to_string()))?; + tx.commit() + .map_err(|e| QueueError::Storage(e.to_string()))?; + if changed != 1 { + return Ok(None); + } + let mut job = Self::get(conn, &review_id)? + .ok_or_else(|| QueueError::Conflict("审核任务在认领时消失。".into()))?; + job.error = Some(format!("worker={worker_id}")); + Ok(Some(job)) + } + + pub fn complete(conn: &Connection, job_id: &str) -> Result<(), QueueError> { + Self::transition(conn, job_id, "running", "ready", None) + } + pub fn fail(conn: &Connection, job_id: &str, error: &str) -> Result<(), QueueError> { + Self::transition(conn, job_id, "running", "failed", Some(error)) + } + pub fn retry(conn: &Connection, job_id: &str) -> Result<(), QueueError> { + Self::transition(conn, job_id, "failed", "queued", None) + } + fn transition( + conn: &Connection, + id: &str, + from: &str, + to: &str, + error: Option<&str>, + ) -> Result<(), QueueError> { + let n = conn.execute("UPDATE session_journey_review_jobs SET state=?1,error=?2,finished_at=CASE WHEN ?1 IN ('ready','failed') THEN ?3 ELSE finished_at END WHERE job_id=?4 AND state=?5", params![to,error,now(),id,from]).map_err(|e| QueueError::Storage(e.to_string()))?; + if n == 1 { + Ok(()) + } else { + Err(QueueError::Conflict("审核任务状态已改变。".into())) + } + } +} + +fn row_job(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let state: String = r.get(8)?; + Ok(ReviewJob { + job_id: r.get(0)?, + review_id: r.get(1)?, + fork_id: r.get(2)?, + session_id: r.get(3)?, + frozen_start_sequence: r.get::<_, i64>(4)? as u64, + frozen_end_sequence: r.get::<_, i64>(5)? as u64, + exact_anchor_message_id: r.get(6)?, + revision: r.get::<_, i64>(7)? as u64, + state: match state.as_str() { + "queued" => ReviewJobState::Queued, + "running" => ReviewJobState::Running, + "ready" => ReviewJobState::Ready, + "failed" => ReviewJobState::Failed, + _ => ReviewJobState::Failed, + }, + attempt: r.get::<_, i64>(9)? as u32, + error: r.get(10)?, + model_id: r.get(11)?, + account_id: r.get(12)?, + protocol: r.get(13)?, + created_at: r.get(14)?, + started_at: r.get(15)?, + finished_at: r.get(16)?, + }) +} + +impl RuntimeProvenance { + fn validate_for_queue(&self) -> Result<(), String> { + if self.model_id.trim().is_empty() + || self.account_id.trim().is_empty() + || self.protocol.trim().is_empty() + { + Err("模型、账户和协议 provenance 必须完整。".into()) + } else { + Ok(()) + } + } +} + +pub fn chinese_prompt(stage: &str, transcript: &str, anchor: &str) -> String { + format!("你是中文审核 worker。阶段:{stage}。只能依据冻结分叉区间消息;精确父锚点为 {anchor}。不得读取锚点之后的父分支续写、其他锚点兄弟分叉或臆造证据。输出严格 JSON,不要 Markdown。\n冻结内容:\n{transcript}") +} + +#[async_trait] +pub trait ReviewModel { + async fn run(&self, prompt: String, provenance: &RuntimeProvenance) -> Result; +} + +/// Adapter over the established provider-neutral side-query path. The host +/// supplies the runtime it resolved for the fork; a mismatch is an error, +/// never an opportunity to select a replacement account, model or protocol. +pub struct SideQueryReviewModel<'a> { + pub provider: &'a dyn LLMProvider, + pub resolved: RuntimeProvenance, +} + +#[async_trait] +impl ReviewModel for SideQueryReviewModel<'_> { + async fn run(&self, prompt: String, provenance: &RuntimeProvenance) -> Result { + if &self.resolved != provenance { + return Err("已解析的模型、账户或协议与审核任务锁定来源不一致。".into()); + } + let schema = serde_json::json!({ + "type": "object", + "required": ["目标", "结论", "未决项", "确认项", "证据 message IDs", "是否可能无价值", "批判"], + "properties": { + "目标": {"type": "string"}, "结论": {"type": "string"}, + "未决项": {"type": "array", "items": {"type": "string"}}, + "确认项": {"type": "array", "items": {"type": "string"}}, + "证据 message IDs": {"type": "array", "items": {"type": "string"}}, + "是否可能无价值": {"type": "boolean"}, + "批判": {"type": "array", "items": {"type": "string"}} + } + }); + let config = SideQueryConfig { + model: Some(provenance.model_id.clone()), + account_id: Some(provenance.account_id.clone()), + temperature: 0.0, + max_tokens: 1024, + structured: Some(StructuredOutput { + tool_name: "提交分叉审核".into(), + schema, + }), + ..SideQueryConfig::default() + }; + side_query_typed( + self.provider, + &[serde_json::json!({"role": "user", "content": prompt})], + &config, + &provenance.model_id, + ) + .await + .map_err(|error| format!("side-query 调用失败:{error}"))? + .structured + .ok_or_else(|| "side-query 未返回结构化 JSON。".into()) + } +} + +struct PreparedReviewJob { + job: ReviewJob, + transcript: String, + provenance: RuntimeProvenance, +} + +#[cfg(not(test))] +async fn run_durable_embedding_job() -> bool { + let job = match tokio::task::spawn_blocking(|| { + let mut conn = crate::foundation::db_bridge::get_connection() + .map_err(|_| "无法打开 Journey embedding 数据库。".to_string())?; + crate::core::session::journey_embedding::claim(&mut conn).map_err(|error| error.to_string()) + }) + .await + { + Ok(Ok(Some(job))) => job, + Ok(Ok(None)) => return false, + Ok(Err(error)) => { + tracing::warn!(error=%error, "[journey-review] 无法认领 embedding 任务"); + return false; + } + Err(error) => { + tracing::warn!(error=%error, "[journey-review] embedding 认领任务异常"); + return false; + } + }; + use crate::specialization::memory::embeddings::EmbeddingProvider; + let config = crate::state::integrations_store::integrations_store() + .snapshot() + .embedding; + let provider = crate::specialization::memory::embeddings::AutoEmbeddingProvider::new( + config.provider, + config.model, + ); + let embedding_source = provider.provider_name().to_string(); + let result = provider.embed(&job.content).await; + let id = job.source_id.clone(); + let commit = tokio::task::spawn_blocking(move || { + let conn = crate::foundation::db_bridge::get_connection().map_err(|e| e.to_string())?; + match result { + Ok(embedding) => crate::core::session::journey_embedding::complete( + &conn, + &job, + &embedding.vector, + &embedding.model, + &embedding_source, + ) + .map_err(|e| e.to_string()), + Err(error) => crate::core::session::journey_embedding::fail(&conn, &id, &error) + .map_err(|e| e.to_string()), + } + }) + .await; + if let Err(error) = commit { + tracing::warn!(error=%error, "[journey-review] embedding 提交任务异常"); + } + true +} + +#[cfg(test)] +async fn run_durable_embedding_job() -> bool { + false +} + +async fn run_executor(inner: Arc) { + loop { + if inner.cancel.is_cancelled() { + return; + } + for _ in 0..MAX_JOBS_PER_WAKE { + if inner.cancel.is_cancelled() || !inner.runner.run_one().await { + break; + } + } + tokio::select! { + _ = inner.cancel.cancelled() => return, + _ = inner.wake.notified() => {}, + _ = tokio::time::sleep(RECOVERY_TICK) => {}, + } + } +} + +/// Returns false only when no job was claimed. Every failure is committed as +/// a job failure and logged without content, so a bad provider cannot kill +/// the executor loop or leak a transcript into diagnostics. +async fn run_durable_job() -> bool { + let prepared = match tokio::task::spawn_blocking(|| { + let mut conn = crate::foundation::db_bridge::get_connection() + .map_err(|_| QueueError::Storage("无法打开审核队列数据库。".into()))?; + let Some(job) = + ReviewJobRepository::claim(&mut conn, "journey-review-executor", STALE_LEASE_SECS)? + else { + return Ok::, QueueError>(None); + }; + let transcript = frozen_transcript(&conn, &job)?; + Ok(Some(PreparedReviewJob { + provenance: RuntimeProvenance { + model_id: job.model_id.clone(), + account_id: job.account_id.clone(), + protocol: job.protocol.clone(), + }, + job, + transcript, + })) + }) + .await + { + Ok(Ok(Some(job))) => job, + Ok(Ok(None)) => return false, + Ok(Err(error)) => { + tracing::warn!(error = %error, "[journey-review] 无法认领审核任务"); + return false; + } + Err(error) => { + tracing::warn!(error = %error, "[journey-review] 审核数据库任务异常"); + return false; + } + }; + + let result = run_prepared_review(&prepared).await; + let job_id = prepared.job.job_id.clone(); + let session_id = prepared.job.session_id.clone(); + let review_id = prepared.job.review_id.clone(); + let commit = tokio::task::spawn_blocking(move || { + let mut conn = crate::foundation::db_bridge::get_connection() + .map_err(|_| QueueError::Storage("无法打开审核队列数据库。".into()))?; + match result { + Ok(draft) => match commit_ready_review(&mut conn, &prepared.job, draft) { + Ok(()) => Ok(()), + Err(error) => { + tracing::warn!(job_id = %job_id, error = %error, "[journey-review] 审核结果校验或提交失败"); + commit_failed_review(&mut conn, &job_id, &session_id, &review_id) + } + }, + Err(()) => commit_failed_review(&mut conn, &job_id, &session_id, &review_id), + } + }) + .await; + match commit { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!(error = %error, "[journey-review] 无法提交审核结果"), + Err(error) => tracing::warn!(error = %error, "[journey-review] 审核提交任务异常"), + } + true +} + +async fn run_prepared_review(prepared: &PreparedReviewJob) -> Result { + let provider = match crate::providers::factory::create_provider_for_protocol( + &prepared.provenance.model_id, + &prepared.provenance.account_id, + &prepared.provenance.protocol, + ) { + Ok(provider) => provider, + Err(error) => { + tracing::warn!(job_id = %prepared.job.job_id, error = %error, "[journey-review] 严格 provider 解析失败"); + return Err(()); + } + }; + let model = SideQueryReviewModel { + provider: provider.as_ref(), + resolved: prepared.provenance.clone(), + }; + let extract = model + .run( + chinese_prompt("side-query 提取", &prepared.transcript, &prepared.job.exact_anchor_message_id), + &prepared.provenance, + ) + .await + .map_err(|error| tracing::warn!(job_id = %prepared.job.job_id, error = %error, "[journey-review] 提取调用失败"))?; + let critic = model + .run( + chinese_prompt("grill-me 批判遗漏、矛盾和证据不足", &extract.to_string(), &prepared.job.exact_anchor_message_id), + &prepared.provenance, + ) + .await + .map_err(|error| tracing::warn!(job_id = %prepared.job.job_id, error = %error, "[journey-review] 批判调用失败"))?; + let revised = model + .run( + chinese_prompt("根据批判生成短 capsule annotation", &format!("提取:{extract}\n批判:{critic}"), &prepared.job.exact_anchor_message_id), + &prepared.provenance, + ) + .await + .map_err(|error| tracing::warn!(job_id = %prepared.job.job_id, error = %error, "[journey-review] 修订调用失败"))?; + parse_draft(revised, &prepared.job, prepared.provenance.clone()).map_err(|_| { + tracing::warn!(job_id = %prepared.job.job_id, "[journey-review] 模型返回无效审核结构"); + }) +} + +fn commit_ready_review( + conn: &mut Connection, + job: &ReviewJob, + draft: ReviewDraftAnnotation, +) -> Result<(), QueueError> { + validate_evidence_ids(conn, job, &draft.evidence_message_ids).map_err(QueueError::Invalid)?; + let revision = SessionJourneyApplicationService::snapshot(conn, &job.session_id) + .map_err(|e| QueueError::Conflict(e.to_string()))? + .revision; + let annotation = serde_json::to_string(&draft) + .map_err(|_| QueueError::Invalid("无法序列化审核 annotation。".into()))?; + SessionJourneyApplicationService::mark_review_ready( + conn, + MarkReviewReadyRequest { + session_id: job.session_id.clone(), + expected_revision: revision, + review_id: job.review_id.clone(), + provenance: draft.provenance, + annotation: annotation.clone(), + }, + ) + .map_err(|e| QueueError::Conflict(e.to_string()))?; + crate::core::session::journey_embedding::enqueue( + conn, + &format!("review:{}", job.review_id), + crate::core::session::journey_embedding::JourneyEmbeddingKind::ReviewAnnotation, + &job.session_id, + &job.fork_id, + &job.review_id, + &annotation, + ) + .map_err(|e| QueueError::Storage(format!("无法排队审核 embedding:{e}")))?; + ReviewJobRepository::complete(conn, &job.job_id) +} + +fn commit_failed_review( + conn: &mut Connection, + job_id: &str, + session_id: &str, + review_id: &str, +) -> Result<(), QueueError> { + let _ = ReviewJobRepository::fail(conn, job_id, "审核模型调用失败,请显式重试。"); + if let Ok(snapshot) = SessionJourneyApplicationService::snapshot(conn, session_id) { + let _ = SessionJourneyApplicationService::fail_review( + conn, + FailReviewRequest { + session_id: session_id.to_owned(), + expected_revision: snapshot.revision, + review_id: review_id.to_owned(), + }, + ); + } + Ok(()) +} + +pub struct ReviewWorker; +impl ReviewWorker { + pub async fn run_once( + conn: &mut Connection, + model: &M, + worker_id: &str, + stale_after_secs: u64, + ) -> Result, QueueError> { + let Some(job) = ReviewJobRepository::claim(conn, worker_id, stale_after_secs)? else { + return Ok(None); + }; + let provenance = RuntimeProvenance { + model_id: job.model_id.clone(), + account_id: job.account_id.clone(), + protocol: job.protocol.clone(), + }; + let result: Result = async { + let transcript = frozen_transcript(conn, &job).map_err(|error| error.to_string())?; + let extract = model + .run( + chinese_prompt("side-query 提取", &transcript, &job.exact_anchor_message_id), + &provenance, + ) + .await?; + let critic = model + .run( + chinese_prompt( + "grill-me 批判遗漏、矛盾和证据不足", + &extract.to_string(), + &job.exact_anchor_message_id, + ), + &provenance, + ) + .await?; + let revised = model + .run( + chinese_prompt( + "根据批判生成短 capsule annotation", + &format!("提取:{extract}\n批判:{critic}"), + &job.exact_anchor_message_id, + ), + &provenance, + ) + .await?; + let draft = parse_draft(revised, &job, provenance)?; + validate_evidence_ids(conn, &job, &draft.evidence_message_ids)?; + Ok(draft) + } + .await; + match result { + Ok(draft) => { + let revision = SessionJourneyApplicationService::snapshot(conn, &job.session_id) + .map_err(|e| QueueError::Conflict(e.to_string()))? + .revision; + let annotation = serde_json::to_string(&draft) + .map_err(|_| QueueError::Invalid("无法序列化审核 annotation。".into()))?; + SessionJourneyApplicationService::mark_review_ready( + conn, + MarkReviewReadyRequest { + session_id: job.session_id.clone(), + expected_revision: revision, + review_id: job.review_id.clone(), + provenance: draft.provenance.clone(), + annotation: annotation.clone(), + }, + ) + .map_err(|e| QueueError::Conflict(e.to_string()))?; + crate::core::session::journey_embedding::enqueue( + conn, + &format!("review:{}", job.review_id), + crate::core::session::journey_embedding::JourneyEmbeddingKind::ReviewAnnotation, + &job.session_id, + &job.fork_id, + &job.review_id, + &annotation, + ) + .map_err(|e| QueueError::Storage(e.to_string()))?; + ReviewJobRepository::complete(conn, &job.job_id)?; + Ok(Some(draft)) + } + Err(error) => { + let _ = ReviewJobRepository::fail(conn, &job.job_id, &error); + if let Ok(snapshot) = + SessionJourneyApplicationService::snapshot(conn, &job.session_id) + { + let _ = SessionJourneyApplicationService::fail_review( + conn, + FailReviewRequest { + session_id: job.session_id.clone(), + expected_revision: snapshot.revision, + review_id: job.review_id.clone(), + }, + ); + } + Err(QueueError::Invalid(format!("审核 worker 失败:{error}"))) + } + } + } +} + +fn validate_evidence_ids(conn: &Connection, job: &ReviewJob, ids: &[String]) -> Result<(), String> { + if ids.is_empty() { + return Err("审核产物必须引用冻结范围内的证据 message IDs。".into()); + } + for id in ids { + let allowed: Option = conn.query_row( + "SELECT 1 FROM session_journey_memberships WHERE session_id=?1 AND branch_id=?2 AND message_id=?3 AND sequence BETWEEN ?4 AND ?5", + params![job.session_id, job.fork_id, id, job.frozen_start_sequence as i64, job.frozen_end_sequence as i64], |r| r.get(0) + ).optional().map_err(|e| format!("无法校验证据范围:{e}"))?; + if allowed.is_none() { + return Err(format!("证据 message ID 不在冻结分叉范围内:{id}")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Mutex, Once}; + use std::time::Duration; + + use crate::core::journey_lifecycle::{ + ForkState, JourneyFork, ReviewItem, ReviewState, SqliteJourneyRepository, + }; + + #[derive(Default)] + struct FakeReviewModel { + responses: Mutex>>, + prompts: Mutex>, + provenances: Mutex>, + } + + impl FakeReviewModel { + fn with_responses(responses: impl IntoIterator>) -> Self { + Self { + responses: Mutex::new(responses.into_iter().collect()), + ..Self::default() + } + } + } + + #[async_trait] + impl ReviewModel for FakeReviewModel { + async fn run( + &self, + prompt: String, + provenance: &RuntimeProvenance, + ) -> Result { + self.prompts.lock().unwrap().push(prompt); + self.provenances.lock().unwrap().push(provenance.clone()); + self.responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| Err("fake model 响应不足。".into())) + } + } + + fn provenance() -> RuntimeProvenance { + RuntimeProvenance { + model_id: "模型甲".into(), + account_id: "账户甲".into(), + protocol: "协议甲".into(), + } + } + + fn valid_annotation() -> Value { + serde_json::json!({ + "目标": "核对分叉结论", + "结论": "可以继续主分支", + "未决项": ["补测边界"], + "确认项": ["证据已覆盖"], + "证据 message IDs": ["f3", "f5"], + "是否可能无价值": false, + "批判": ["无额外遗漏"] + }) + } + + fn worker_conn() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE agent_sessions (session_id TEXT PRIMARY KEY); + CREATE TABLE agent_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + sequence INTEGER NOT NULL, + content TEXT NOT NULL + ); + INSERT INTO agent_sessions (session_id) VALUES ('s'); + INSERT INTO agent_messages VALUES ('anchor', 's', 'user', 2, '父锚点'); + INSERT INTO agent_messages VALUES ('f3', 's', 'assistant', 3, '冻结分叉消息 3'); + INSERT INTO agent_messages VALUES ('f5', 's', 'assistant', 5, '冻结分叉消息 5'); + INSERT INTO agent_messages VALUES ('parent-future', 's', 'assistant', 6, '父分支后续不得泄露'); + INSERT INTO agent_messages VALUES ('sibling', 's', 'assistant', 6, '兄弟分叉不得泄露');", + ) + .unwrap(); + SqliteJourneyRepository::ensure_schema(&conn).unwrap(); + let mut j = journey(); + j.revision = 1; + SqliteJourneyRepository::compare_and_store(&mut conn, &j, 0).unwrap(); + for (message_id, sequence, branch_id) in [ + ("anchor", 2_i64, "main"), + ("f3", 3, "f"), + ("f5", 5, "f"), + ("parent-future", 6, "main"), + ("sibling", 6, "sibling"), + ] { + conn.execute( + "INSERT INTO session_journey_memberships (session_id, message_id, sequence, branch_id, task_id) VALUES ('s', ?1, ?2, ?3, NULL)", + params![message_id, sequence, branch_id], + ) + .unwrap(); + } + conn + } + + fn enqueue(conn: &Connection) -> ReviewJob { + ReviewJobRepository::enqueue(conn, &journey(), "job-1", "r", &provenance()).unwrap() + } + fn journey() -> SessionJourney { + let mut j = SessionJourney::new("s", "main"); + j.branches.insert( + "f".into(), + JourneyFork { + id: "f".into(), + parent_branch_id: "main".into(), + parent_anchor_message_id: Some("anchor".into()), + anchor_sequence: 2, + source_start_sequence: 3, + state: ForkState::Closing, + frozen_end_sequence: Some(5), + close_work_id: None, + handoff_capsule: None, + }, + ); + j.reviews.insert( + "r".into(), + ReviewItem { + id: "r".into(), + fork_id: "f".into(), + state: ReviewState::Queued, + provenance: None, + source_start_sequence: 3, + source_end_sequence: 5, + annotation: None, + promoted_fact_ids: Vec::new(), + }, + ); + j.revision = 7; + j + } + + #[test] + fn enqueue_is_idempotent_and_claim_is_single_consumer() { + let conn = Connection::open_in_memory().unwrap(); + let p = RuntimeProvenance { + model_id: "模型".into(), + account_id: "账户".into(), + protocol: "协议".into(), + }; + let j = journey(); + let first = ReviewJobRepository::enqueue(&conn, &j, "job-1", "r", &p).unwrap(); + let second = ReviewJobRepository::enqueue(&conn, &j, "job-2", "r", &p).unwrap(); + assert_eq!(first.job_id, second.job_id); + let mut conn = conn; + assert!(ReviewJobRepository::claim(&mut conn, "w1", 60) + .unwrap() + .is_some()); + assert!(ReviewJobRepository::claim(&mut conn, "w2", 60) + .unwrap() + .is_none()); + } + + #[test] + fn prompt_is_chinese_and_anchor_scoped() { + let prompt = chinese_prompt("side-query 提取", "m3@3: 分叉内容", "anchor"); + assert!(prompt.contains("只能依据冻结分叉区间消息")); + assert!(prompt.contains("不得读取锚点之后的父分支续写")); + assert!(prompt.contains("anchor")); + } + + #[test] + fn worker_runs_draft_critic_revision_with_exact_provenance_and_frozen_scope() { + let mut conn = worker_conn(); + enqueue(&conn); + let model = FakeReviewModel::with_responses([ + Ok(serde_json::json!({"draft": "候选"})), + Ok(serde_json::json!({"critic": "补充证据"})), + Ok(valid_annotation()), + ]); + + let draft = + futures::executor::block_on(ReviewWorker::run_once(&mut conn, &model, "worker-a", 60)) + .unwrap() + .unwrap(); + + assert_eq!(draft.provenance, provenance()); + assert_eq!( + model.provenances.lock().unwrap().as_slice(), + &[provenance(), provenance(), provenance()] + ); + let prompts = model.prompts.lock().unwrap(); + assert_eq!(prompts.len(), 3); + assert!(prompts[0].contains("冻结分叉消息 3")); + assert!(prompts[0].contains("冻结分叉消息 5")); + assert!(!prompts[0].contains("父分支后续不得泄露")); + assert!(!prompts[0].contains("兄弟分叉不得泄露")); + drop(prompts); + + let job = ReviewJobRepository::get(&conn, "r").unwrap().unwrap(); + assert_eq!(job.state, ReviewJobState::Ready); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s") + .unwrap() + .snapshot; + assert_eq!(snapshot.reviews["r"].state, ReviewState::Ready); + assert!(snapshot.reviews["r"].annotation.is_some()); + assert!(snapshot.branches["f"].handoff_capsule.is_none()); + assert!(snapshot.reviews["r"].promoted_fact_ids.is_empty()); + } + + #[test] + fn invalid_json_fails_closed_without_capsule_or_fact() { + let mut conn = worker_conn(); + enqueue(&conn); + let model = FakeReviewModel::with_responses([ + Ok(serde_json::json!({"draft": "候选"})), + Ok(serde_json::json!({"critic": "检查"})), + Ok(serde_json::json!({"目标": "缺少必须字段"})), + ]); + + let error = + futures::executor::block_on(ReviewWorker::run_once(&mut conn, &model, "worker-a", 60)) + .unwrap_err(); + assert!(error.to_string().contains("审核 worker 失败")); + assert_eq!( + ReviewJobRepository::get(&conn, "r").unwrap().unwrap().state, + ReviewJobState::Failed + ); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s") + .unwrap() + .snapshot; + assert_eq!(snapshot.reviews["r"].state, ReviewState::Failed); + assert!(snapshot.reviews["r"].annotation.is_none()); + assert!(snapshot.branches["f"].handoff_capsule.is_none()); + assert!(snapshot.reviews["r"].promoted_fact_ids.is_empty()); + } + + #[test] + fn stale_running_job_is_recovered_and_claimed_once() { + let mut conn = worker_conn(); + let job = enqueue(&conn); + let _ = ReviewJobRepository::claim(&mut conn, "worker-dead", 60) + .unwrap() + .unwrap(); + conn.execute( + "UPDATE session_journey_review_jobs SET started_at='0' WHERE job_id=?1", + [&job.job_id], + ) + .unwrap(); + + let recovered = ReviewJobRepository::claim(&mut conn, "worker-live", 1) + .unwrap() + .unwrap(); + assert_eq!(recovered.job_id, job.job_id); + assert_eq!(recovered.attempt, 2); + assert!(ReviewJobRepository::claim(&mut conn, "worker-other", 60) + .unwrap() + .is_none()); + } + + #[derive(Default)] + struct CountingRunner { + calls: AtomicUsize, + fail_first: bool, + } + + #[async_trait] + impl ExecutorJobRunner for CountingRunner { + async fn run_one(&self) -> bool { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + // This deliberately represents a provider failure. The runner + // reports no immediately available work and the executor must + // remain available for the next notification. + if self.fail_first && call == 0 { + return false; + } + false + } + } + + async fn wait_for_calls(runner: &CountingRunner, minimum: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + while runner.calls.load(Ordering::SeqCst) < minimum { + tokio::task::yield_now().await; + } + }) + .await + .expect("executor did not process the expected wake"); + } + + #[tokio::test] + async fn executor_spawn_and_notify_runs_injected_runner() { + let runner = Arc::new(CountingRunner::default()); + let executor = JourneyReviewExecutorHandle::spawn_with_runner(runner.clone()); + wait_for_calls(&runner, 1).await; + executor.wake(); + wait_for_calls(&runner, 2).await; + executor.shutdown().await; + } + + #[tokio::test] + async fn executor_shutdown_stops_the_task() { + let runner = Arc::new(CountingRunner::default()); + let executor = JourneyReviewExecutorHandle::spawn_with_runner(runner.clone()); + wait_for_calls(&runner, 1).await; + executor.shutdown().await; + let before = runner.calls.load(Ordering::SeqCst); + executor.wake(); + tokio::time::sleep(Duration::from_millis(30)).await; + assert_eq!(runner.calls.load(Ordering::SeqCst), before); + } + + #[tokio::test] + async fn executor_coalesces_many_notifications() { + let runner = Arc::new(CountingRunner::default()); + let executor = JourneyReviewExecutorHandle::spawn_with_runner(runner.clone()); + wait_for_calls(&runner, 1).await; + for _ in 0..32 { + executor.wake(); + } + wait_for_calls(&runner, 2).await; + tokio::time::sleep(Duration::from_millis(30)).await; + assert!(runner.calls.load(Ordering::SeqCst) <= 3); + executor.shutdown().await; + } + + #[tokio::test] + async fn executor_survives_a_provider_failure_and_accepts_next_wake() { + let runner = Arc::new(CountingRunner { + fail_first: true, + ..CountingRunner::default() + }); + let executor = JourneyReviewExecutorHandle::spawn_with_runner(runner.clone()); + wait_for_calls(&runner, 1).await; + executor.wake(); + wait_for_calls(&runner, 2).await; + executor.shutdown().await; + } + + #[test] + fn concurrent_connections_cannot_claim_the_same_job() { + static INIT: Once = Once::new(); + let path = std::env::temp_dir().join(format!( + "org2-journey-review-claim-{}-{}.sqlite", + std::process::id(), + now() + )); + INIT.call_once(|| {}); + let setup = Connection::open(&path).unwrap(); + enqueue(&setup); + drop(setup); + + let mut joins = Vec::new(); + for worker in ["w1", "w2"] { + let path = path.clone(); + joins.push(std::thread::spawn(move || { + let mut conn = Connection::open(path).unwrap(); + conn.busy_timeout(Duration::from_secs(1)).unwrap(); + ReviewJobRepository::claim(&mut conn, worker, 60) + .unwrap() + .map(|job| job.job_id) + })); + } + let claims: Vec<_> = joins.into_iter().map(|join| join.join().unwrap()).collect(); + assert_eq!(claims.iter().flatten().count(), 1); + let _ = std::fs::remove_file(path); + } +} + +fn frozen_transcript(conn: &Connection, job: &ReviewJob) -> Result { + let mut stmt = conn.prepare("SELECT m.message_id, m.sequence, a.content FROM session_journey_memberships m JOIN agent_messages a ON a.id=m.message_id AND a.session_id=m.session_id WHERE m.session_id=?1 AND m.branch_id=?2 AND m.sequence BETWEEN ?3 AND ?4 ORDER BY m.sequence").map_err(|e| QueueError::Storage(e.to_string()))?; + let rows = stmt + .query_map( + params![ + job.session_id, + job.fork_id, + job.frozen_start_sequence as i64, + job.frozen_end_sequence as i64 + ], + |r| { + Ok(format!( + "{}@{}: {}", + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, String>(2)? + )) + }, + ) + .map_err(|e| QueueError::Storage(e.to_string()))?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| QueueError::Storage(e.to_string()))?); + } + Ok(out.join("\n")) +} + +fn parse_draft( + value: Value, + job: &ReviewJob, + provenance: RuntimeProvenance, +) -> Result { + let obj = value.as_object().ok_or("模型输出不是 JSON 对象。")?; + let text = |key: &str| { + obj.get(key) + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("缺少字段:{key}")) + }; + let list = |key: &str| { + obj.get(key) + .and_then(Value::as_array) + .ok_or_else(|| format!("缺少数组字段:{key}")) + .and_then(|a| { + a.iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("字段 {key} 必须全为字符串")) + }) + .collect() + }) + }; + Ok(ReviewDraftAnnotation { + objective: text("目标")?, + conclusion: text("结论")?, + open_questions: list("未决项")?, + confirmation_items: list("确认项")?, + evidence_message_ids: list("证据 message IDs")?, + possibly_no_value: obj + .get("是否可能无价值") + .and_then(Value::as_bool) + .ok_or("缺少字段:是否可能无价值")?, + source_range: (job.frozen_start_sequence, job.frozen_end_sequence), + provenance, + critic_notes: list("批判")?, + }) +} diff --git a/src-tauri/crates/agent-core/src/core/session/mod.rs b/src-tauri/crates/agent-core/src/core/session/mod.rs index e43af5e885..7f95fdcb96 100644 --- a/src-tauri/crates/agent-core/src/core/session/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/mod.rs @@ -18,6 +18,10 @@ pub(crate) mod file_registry; pub mod gateway_pipeline; pub mod goal_loop; pub mod housekeeper_compaction; +pub mod journey_application_service; +pub mod journey_context_visibility; +pub mod journey_embedding; +pub mod journey_review_queue; pub mod launch; pub mod overrides; pub mod persistence; diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/journey_metadata.rs b/src-tauri/crates/agent-core/src/core/session/persistence/journey_metadata.rs new file mode 100644 index 0000000000..d46364bc30 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/persistence/journey_metadata.rs @@ -0,0 +1,125 @@ +//! Explicit metadata used by the read-only Journey projection. + +use database::db::{get_connection, with_sessions_writer}; +use rusqlite::{params, OptionalExtension, Result as SqliteResult}; + +/// Explicit, durable associations for the read-only Journey projection. +/// +/// This is deliberately separate from the session record: callers must supply +/// these values, and no session title, path, branch, or event ordering is used +/// to populate them. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExplicitJourneyMetadata { + pub workspace_id: Option, + pub topic_tags: Vec, +} + +const JOURNEY_METADATA_DDL: &str = r#" + CREATE TABLE IF NOT EXISTS session_journey_metadata ( + session_id TEXT PRIMARY KEY, + workspace_id TEXT, + topic_tags_json TEXT NOT NULL DEFAULT '[]' + ) +"#; + +fn ensure_journey_metadata_table(conn: &rusqlite::Connection) -> SqliteResult<()> { + conn.execute(JOURNEY_METADATA_DDL, [])?; + Ok(()) +} + +/// Store associations supplied explicitly at session creation. The caller +/// decides whether absent metadata should be written; this helper never +/// synthesizes values from other session fields. +pub fn upsert_explicit_journey_metadata( + session_id: &str, + metadata: &ExplicitJourneyMetadata, +) -> SqliteResult<()> { + with_sessions_writer(|| { + let conn = get_connection()?; + upsert_explicit_journey_metadata_with_conn(&conn, session_id, metadata) + }) +} + +fn upsert_explicit_journey_metadata_with_conn( + conn: &rusqlite::Connection, + session_id: &str, + metadata: &ExplicitJourneyMetadata, +) -> SqliteResult<()> { + ensure_journey_metadata_table(conn)?; + let topic_tags_json = serde_json::to_string(&metadata.topic_tags) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?; + conn.execute( + "INSERT INTO session_journey_metadata (session_id, workspace_id, topic_tags_json) + VALUES (?1, ?2, ?3) + ON CONFLICT(session_id) DO UPDATE SET + workspace_id = excluded.workspace_id, + topic_tags_json = excluded.topic_tags_json", + params![session_id, metadata.workspace_id, topic_tags_json], + )?; + Ok(()) +} + +/// Return only metadata a producer stored explicitly. `None` means historical +/// unknown, not a request to infer an association. +pub fn get_explicit_journey_metadata( + session_id: &str, +) -> SqliteResult> { + let conn = get_connection()?; + get_explicit_journey_metadata_with_conn(&conn, session_id) +} + +fn get_explicit_journey_metadata_with_conn( + conn: &rusqlite::Connection, + session_id: &str, +) -> SqliteResult> { + ensure_journey_metadata_table(conn)?; + conn.query_row( + "SELECT workspace_id, topic_tags_json FROM session_journey_metadata WHERE session_id = ?1", + [session_id], + |row| { + let topic_tags_json: String = row.get(1)?; + let topic_tags = serde_json::from_str(&topic_tags_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + Box::new(err), + ) + })?; + Ok(ExplicitJourneyMetadata { + workspace_id: row.get(0)?, + topic_tags, + }) + }, + ) + .optional() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_journey_metadata_round_trips_without_session_field_inference() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + let metadata = ExplicitJourneyMetadata { + workspace_id: Some("workspace-explicit".to_string()), + topic_tags: vec!["release".to_string(), "journey".to_string()], + }; + + upsert_explicit_journey_metadata_with_conn( + &conn, + "session-title-must-not-matter", + &metadata, + ) + .unwrap(); + assert_eq!( + get_explicit_journey_metadata_with_conn(&conn, "session-title-must-not-matter") + .unwrap(), + Some(metadata) + ); + assert_eq!( + get_explicit_journey_metadata_with_conn(&conn, "unseen-session").unwrap(), + None + ); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index 64ceb9e1c6..a30d40ec4e 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -11,6 +11,7 @@ //! - Message storage uses `agent_messages` (shared) mod crud; +mod journey_metadata; pub(crate) mod linked_work_item; mod messages; mod sidebar; @@ -37,6 +38,9 @@ pub use crud::{ pub(crate) use crud::{ delete_session_with_connection, finish_session_delete, prepare_session_delete, }; +pub use journey_metadata::{ + get_explicit_journey_metadata, upsert_explicit_journey_metadata, ExplicitJourneyMetadata, +}; pub use sidebar::{ list_agent_org_root_sessions_page, list_standalone_coding_sessions_page, list_unpinned_sessions_by_type_page, @@ -64,5 +68,7 @@ use rusqlite::{Connection, Result as SqliteResult}; /// is ready. Accepts a `&Connection` to avoid deadlock. pub fn init(conn: &Connection) -> SqliteResult<()> { crud::ensure_unified_schema(conn)?; + crate::core::journey_lifecycle::SqliteJourneyRepository::ensure_schema(conn)?; + crate::core::session::journey_embedding::ensure_schema(conn)?; Ok(()) } diff --git a/src-tauri/crates/orgtrack-core/src/canonical.rs b/src-tauri/crates/orgtrack-core/src/canonical.rs index f00e78c142..e593766499 100644 --- a/src-tauri/crates/orgtrack-core/src/canonical.rs +++ b/src-tauri/crates/orgtrack-core/src/canonical.rs @@ -81,6 +81,24 @@ pub struct SessionRecord { #[serde(default)] pub collaboration_origin: Option, pub metadata: AgentMetadata, + /// Explicit Journey associations supplied by the session producer. They + /// are never reconstructed from names, paths, branches, or event order. + #[serde(default)] + pub journey: JourneyMetadata, +} + +/// Optional durable metadata consumed by the read-only Journey projector. +/// `topic_tags` remains empty until an explicit producer supplies it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct JourneyMetadata { + pub project_id: Option, + pub workspace_id: Option, + pub work_item_id: Option, + pub agent_identity: Option, + pub agent_band: Option, + #[serde(default)] + pub topic_tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -175,6 +193,10 @@ pub struct SessionEditArtifactRecord { pub session_id: String, pub source_event_id: Option, pub turn_id: Option, + /// Exact event-provided execution turn. Journey does not infer this from + /// the legacy `turn_id` or from `sequence_index`. + #[serde(default)] + pub execution_turn_id: Option, pub sequence_index: i64, pub timestamp: Option, pub workspace_path: Option, diff --git a/src-tauri/crates/orgtrack-core/src/edit_extraction.rs b/src-tauri/crates/orgtrack-core/src/edit_extraction.rs index a1745f2efb..d45be1a680 100644 --- a/src-tauri/crates/orgtrack-core/src/edit_extraction.rs +++ b/src-tauri/crates/orgtrack-core/src/edit_extraction.rs @@ -15,6 +15,7 @@ pub struct EditArtifactContext { pub session_id: String, pub source_event_id: Option, pub turn_id: Option, + pub execution_turn_id: Option, pub sequence_index: i64, pub timestamp: Option, pub workspace_path: Option, @@ -68,6 +69,12 @@ pub fn artifacts_from_extracted_edit( session_id: context.session_id.clone(), source_event_id: context.source_event_id.clone(), turn_id: context.turn_id.clone(), + // Preserve only an explicit producer-supplied association. Legacy + // producers supplied the same exact boundary as `turn_id`. + execution_turn_id: context + .execution_turn_id + .clone() + .or_else(|| context.turn_id.clone()), sequence_index: context.sequence_index, timestamp: context.timestamp.clone(), workspace_path: context.workspace_path.clone(), diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs index 81ba17fb7a..edd20416b3 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs @@ -256,6 +256,7 @@ fn core_session_record_from_imported_input(input: &ImportedHistoryCacheInput) -> model: input.model.clone(), ..AgentMetadata::default() }, + journey: Default::default(), } } diff --git a/src-tauri/crates/orgtrack-graph/src/journey.rs b/src-tauri/crates/orgtrack-graph/src/journey.rs index 633566fdfb..6d30bc5b75 100644 --- a/src-tauri/crates/orgtrack-graph/src/journey.rs +++ b/src-tauri/crates/orgtrack-graph/src/journey.rs @@ -23,7 +23,13 @@ pub enum JourneyNodeKind { WorkItem, Session, Turn, + Branch, + Task, Checkpoint, + Review, + Annotation, + ConfirmedFact, + MessageAnchor, Artifact, File, Commit, @@ -42,6 +48,10 @@ pub enum JourneyEdgeKind { Modified, ValidatedBy, CommittedIn, + AnchoredAt, + Reviews, + PromotedFrom, + EvidenceAt, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -261,11 +271,9 @@ pub fn project_canonical_journey(input: &CanonicalJourneyInput) -> Result CanonicalJourneyInput { }], work_items: vec![CanonicalWorkItem { id: "w".into(), - project_id: "p".into(), + project_id: Some("p".into()), source_ref: "wi-record".into(), }], sessions: vec![ CanonicalSession { id: "parent".into(), - project_id: "p".into(), + project_id: Some("p".into()), work_item_id: Some("w".into()), resumed_from: None, compacted_to: None, forked_from: None, source_ref: "session-parent".into(), display_timestamp: Some("2026-01-01".into()), + agent_identity: Some("agent-a".into()), + agent_band: Some("review".into()), + topic_tags: vec!["explicit-topic".into()], }, CanonicalSession { id: "child".into(), - project_id: "p".into(), + project_id: Some("p".into()), work_item_id: Some("w".into()), resumed_from: Some("parent".into()), compacted_to: None, @@ -38,18 +41,21 @@ fn input() -> CanonicalJourneyInput { }), source_ref: "session-child".into(), display_timestamp: Some("2099-01-01".into()), + agent_identity: None, + agent_band: None, + topic_tags: vec![], }, ], turns: vec![ CanonicalTurn { session_id: "child".into(), - sequence: 1, + id: "execution-1".into(), source_ref: "turn-1".into(), display_timestamp: None, }, CanonicalTurn { session_id: "child".into(), - sequence: 2, + id: "execution-2".into(), source_ref: "turn-2".into(), display_timestamp: None, }, @@ -79,10 +85,13 @@ fn projector_uses_exact_lineage_anchors_not_timestamps() { assert_eq!(fork.to, "session/parent"); assert!(fork.source_ref.ends_with("#revision-7")); assert!(!fork.source_ref.contains("2099")); - assert!(graph - .edges + let parent = graph + .nodes .iter() - .any(|edge| edge.kind == JourneyEdgeKind::NextTurn)); + .find(|node| node.id == "session/parent") + .unwrap(); + assert_eq!(parent.metadata.agent_band.as_deref(), Some("review")); + assert_eq!(parent.metadata.topic_tags, ["explicit-topic"]); } #[test] fn projector_rejects_missing_parent_revision_and_first_session_ownership() { @@ -128,6 +137,38 @@ fn evidence_and_source_are_mandatory() { assert!(graph.edges.iter().all(|edge| !edge.source_ref.is_empty())); } +#[test] +fn absent_metadata_stays_unassociated_and_turns_never_follow_sequence() { + let mut canonical = input(); + canonical.sessions[0].project_id = None; + canonical.sessions[0].work_item_id = None; + canonical.sessions[0].agent_identity = None; + canonical.sessions[0].agent_band = None; + canonical.sessions[0].topic_tags.clear(); + canonical.turns[0].id.clear(); + + assert!(project_canonical_journey(&canonical).is_err()); + + canonical.turns.remove(0); + let graph = project_canonical_journey(&canonical).unwrap(); + let session = graph + .nodes + .iter() + .find(|node| node.id == "session/parent") + .unwrap(); + assert_eq!(session.metadata.agent_identity, None); + assert_eq!(session.metadata.agent_band, None); + assert!(session.metadata.topic_tags.is_empty()); + assert!(!graph + .edges + .iter() + .any(|edge| edge.from == "project/p" && edge.to == "session/parent")); + assert!(!graph + .edges + .iter() + .any(|edge| edge.kind == JourneyEdgeKind::NextTurn)); +} + #[test] fn query_scope_rejects_everything_except_project_or_session() { assert!(matches!( diff --git a/src-tauri/crates/orgtrack-graph/src/lib.rs b/src-tauri/crates/orgtrack-graph/src/lib.rs index b7b1c68f46..511f5cb91a 100644 --- a/src-tauri/crates/orgtrack-graph/src/lib.rs +++ b/src-tauri/crates/orgtrack-graph/src/lib.rs @@ -8,7 +8,7 @@ pub mod store; pub use audit::{audit_canonical_journey, JourneyAuditReport}; pub use journey::*; pub use project::project_record_to_graph; -pub use query::{GraphDirection, GraphNeighbor}; +pub use query::{GraphDirection, GraphNeighbor, JourneyScope}; pub use store::GraphStore; #[cfg(test)] diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/runtime_artifacts.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/runtime_artifacts.rs index 6c78c66b95..5d79aa9455 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/runtime_artifacts.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/runtime_artifacts.rs @@ -82,6 +82,7 @@ fn persist_runtime_orgtrack_records( source_session_id: Some(session.source_session_id.clone()), session_id: session.session_id.clone(), source_event_id: Some(event.id.clone()), + execution_turn_id: turn_id.clone(), turn_id, sequence_index: sequence_index as i64, timestamp: Some(event.created_at.clone()), @@ -142,6 +143,7 @@ pub(crate) fn runtime_artifact_session_record(session_id: &str) -> Result Result Result { + database::db::get_connection().map_err(|error| format!("无法打开会话旅程数据库:{error}")) +} + +fn service_error(error: impl std::fmt::Display) -> String { + format!("会话旅程操作失败:{error}") +} + +#[tauri::command] +pub async fn journey_snapshot(session_id: String) -> Result { + tokio::task::spawn_blocking(move || { + let conn = open_connection()?; + SessionJourneyApplicationService::snapshot(&conn, &session_id).map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程查询任务异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_task_start( + request: CreateTaskRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::create_task(&mut conn, request).map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程任务启动异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_checkpoint( + request: CreateCheckpointRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::create_checkpoint(&mut conn, request) + .map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程检查点异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_task_finish( + request: FinishTaskRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::finish_task(&mut conn, request).map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程任务完成异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_fork_start( + request: CreateForkRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::create_fork(&mut conn, request).map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程分叉启动异常:{error}"))? +} + +/// Closes a fork and durably queues a provider-neutral review job. The +/// provenance is supplied by the foreground runtime and is never resolved by +/// this command adapter. +#[tauri::command] +pub async fn journey_fork_close( + request: RequestForkCloseRequest, + job_id: String, + provenance: RuntimeProvenance, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::request_fork_close_and_enqueue( + &mut conn, request, job_id, provenance, + ) + .map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程分叉关闭异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_review_list( + session_id: String, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let conn = open_connection()?; + SessionJourneyApplicationService::review_queue(&conn, &session_id).map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程审核列表异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_ready_draft( + session_id: String, + review_id: String, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let conn = open_connection()?; + SessionJourneyApplicationService::get_review_draft(&conn, &session_id, &review_id) + .map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程审核草稿查询异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_confirm(request: PromoteFactRequest) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::promote_confirmed_fact(&mut conn, request) + .map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程审核确认异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_discard(request: DiscardForkRequest) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::discard_fork(&mut conn, request).map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程分叉丢弃异常:{error}"))? +} + +#[tauri::command] +pub async fn journey_return_parent( + request: ReturnToParentRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::return_to_parent(&mut conn, request) + .map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程返回主分支异常:{error}"))? +} + +#[cfg(test)] +mod tests { + use super::service_error; + use agent_core::session::journey_application_service::{ + CreateCheckpointRequest, CreateForkRequest, CreateTaskRequest, DiscardForkRequest, + FinishTaskRequest, PromoteFactRequest, RequestForkCloseRequest, ReturnToParentRequest, + TaskStartPosition, + }; + + #[test] + fn errors_have_a_deterministic_chinese_command_prefix() { + assert_eq!(service_error("原始错误"), "会话旅程操作失败:原始错误"); + } + + #[test] + fn task_start_accepts_a_typed_revisioned_request() { + let request: CreateTaskRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "session-1", + "expectedRevision": 4, + "taskId": "task-1", + "name": "核对锚点", + "position": "最近用户消息" + })) + .expect("command request must remain typed"); + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.expected_revision, 4); + assert_eq!(request.position, TaskStartPosition::最近用户消息); + } + + #[test] + fn all_desktop_mutation_requests_use_tauri_camel_case_fields() { + let payloads = [ + serde_json::to_value(CreateTaskRequest { + session_id: "s".into(), + expected_revision: 1, + task_id: "t".into(), + name: "任务".into(), + position: TaskStartPosition::下一条用户消息, + }) + .unwrap(), + serde_json::to_value(CreateForkRequest { + session_id: "s".into(), + expected_revision: 1, + fork_id: "f".into(), + task_id: "t".into(), + task_name: "分叉任务".into(), + anchor_message_id: "m".into(), + }) + .unwrap(), + serde_json::to_value(CreateCheckpointRequest { + session_id: "s".into(), + expected_revision: 1, + checkpoint_id: "c".into(), + name: "检查点".into(), + message_id: "m".into(), + }) + .unwrap(), + serde_json::to_value(FinishTaskRequest { + session_id: "s".into(), + expected_revision: 1, + outcome: agent_core::core::journey_lifecycle::TaskOutcome::Completed, + message_id: "m".into(), + }) + .unwrap(), + serde_json::to_value(RequestForkCloseRequest { + session_id: "s".into(), + expected_revision: 1, + fork_id: "f".into(), + review_id: "r".into(), + outcome: agent_core::core::journey_lifecycle::TaskOutcome::Completed, + message_id: "m".into(), + }) + .unwrap(), + serde_json::to_value(PromoteFactRequest { + session_id: "s".into(), + expected_revision: 1, + review_id: "r".into(), + fact_id: "fact".into(), + text: "确认".into(), + evidence_start_message_id: "a".into(), + evidence_end_message_id: "b".into(), + }) + .unwrap(), + serde_json::to_value(DiscardForkRequest { + session_id: "s".into(), + expected_revision: 1, + review_id: "r".into(), + }) + .unwrap(), + serde_json::to_value(ReturnToParentRequest { + session_id: "s".into(), + expected_revision: 1, + review_id: "r".into(), + }) + .unwrap(), + ]; + for payload in payloads { + let serialized = payload.to_string(); + assert!(!serialized.contains("session_id"), "{serialized}"); + assert!(!serialized.contains("expected_revision"), "{serialized}"); + assert!(serialized.contains("sessionId"), "{serialized}"); + assert!(serialized.contains("expectedRevision"), "{serialized}"); + } + } + + #[test] + fn every_journey_adapter_is_registered_once_for_tauri() { + let handlers = include_str!("../commands/handler_list.inc"); + for command in [ + "journey_snapshot", + "journey_task_start", + "journey_checkpoint", + "journey_task_finish", + "journey_fork_start", + "journey_fork_close", + "journey_review_list", + "journey_ready_draft", + "journey_confirm", + "journey_discard", + "journey_return_parent", + ] { + assert_eq!( + handlers.matches(command).count(), + 1, + "{command} must have one desktop adapter registration" + ); + } + } +} diff --git a/src-tauri/src/agent_sessions/mod.rs b/src-tauri/src/agent_sessions/mod.rs index c529e3f5b5..448b3474af 100644 --- a/src-tauri/src/agent_sessions/mod.rs +++ b/src-tauri/src/agent_sessions/mod.rs @@ -18,4 +18,5 @@ pub mod cli; pub mod event_pipeline; pub mod external_cli_adapter; pub mod human; +pub mod journey_commands; pub mod session_directory; diff --git a/src-tauri/src/agent_sessions/session_directory/orgtrack_adapter.rs b/src-tauri/src/agent_sessions/session_directory/orgtrack_adapter.rs index 08a79499ce..544c7c182d 100644 --- a/src-tauri/src/agent_sessions/session_directory/orgtrack_adapter.rs +++ b/src-tauri/src/agent_sessions/session_directory/orgtrack_adapter.rs @@ -169,6 +169,7 @@ fn aggregate_to_core_session(record: &SessionAggregateRecord) -> SessionRecord { .or_else(|| Some(record.name.clone())), parsed_categories: Default::default(), }, + journey: Default::default(), } } diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 10b8e3bda1..b72f1dbe48 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -322,6 +322,18 @@ project_management::lineage::get_session_impact, project_management::lineage::get_provenance_session_ids, // Orgtrack - repo-shareable lineage orgtrack::journey_graph_query, +// Session Journey desktop lifecycle commands +agent_sessions::journey_commands::journey_snapshot, +agent_sessions::journey_commands::journey_task_start, +agent_sessions::journey_commands::journey_checkpoint, +agent_sessions::journey_commands::journey_task_finish, +agent_sessions::journey_commands::journey_fork_start, +agent_sessions::journey_commands::journey_fork_close, +agent_sessions::journey_commands::journey_review_list, +agent_sessions::journey_commands::journey_ready_draft, +agent_sessions::journey_commands::journey_confirm, +agent_sessions::journey_commands::journey_discard, +agent_sessions::journey_commands::journey_return_parent, orgtrack::orgtrack_initialize, orgtrack::orgtrack_sync_core_repo, orgtrack::orgtrack_export, diff --git a/src-tauri/src/orgtrack/diff_commands.rs b/src-tauri/src/orgtrack/diff_commands.rs index 59a46011d4..9b8b4fa9cf 100644 --- a/src-tauri/src/orgtrack/diff_commands.rs +++ b/src-tauri/src/orgtrack/diff_commands.rs @@ -345,6 +345,7 @@ pub async fn debug_seed_final_diff( org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: Default::default(), })?; let record_id = record_id(&["debug_seed_final_diff", &session_id, &file_path]); let words: Vec<&str> = diff.lines().collect(); diff --git a/src-tauri/src/orgtrack/journey_canonical.rs b/src-tauri/src/orgtrack/journey_canonical.rs new file mode 100644 index 0000000000..94753e1b9c --- /dev/null +++ b/src-tauri/src/orgtrack/journey_canonical.rs @@ -0,0 +1,283 @@ +//! Canonical Journey graph construction from orgtrack canonical records. +//! +//! This is the real data path behind `journey_graph_query`: it reads the +//! persisted canonical stores (sessions / edit artifacts / commit links), +//! filters them to the requested scope, runs the canonical projector +//! (`orgtrack_graph::journey::project_canonical_journey`) and fails closed +//! when the independent audit reports uncovered canonical units. +//! +//! The persisted canonical graph is not optional: absent scope data is an +//! error, never a synthesized partial response. + +use std::collections::{HashMap, HashSet}; + +use agent_core::core::journey_lifecycle::SqliteJourneyRepository; +use orgtrack_core::canonical::SessionEditKind; +use orgtrack_core::store::sqlite::SqliteRecordStore; +use orgtrack_core::store::RecordStore; +use orgtrack_graph::audit::audit_canonical_journey; +use orgtrack_graph::journey::{ + ArtifactRelation, CanonicalArtifact, CanonicalCommit, CanonicalJourneyInput, CanonicalProject, + CanonicalSession, CanonicalTurn, CoverageStatus, JourneyGraph, +}; +use orgtrack_graph::JourneyScope; +use rusqlite::Connection; + +/// Build the canonical Journey graph for a scope. +/// +/// Scope semantics (no guessing, no synthesized data): +/// - `project/{id}` selects only sessions whose durable canonical +/// `journey.project_id` exactly matches the requested id. +/// - `session/{id}` selects the session plus its full parent lineage chain +/// (`parent_session_id` recursion), so fork edges stay verifiable. +pub fn build_journey_graph( + store: &SqliteRecordStore, + conn: &Connection, + scope: &JourneyScope, +) -> Result { + let mut sessions = store.list_sessions(None)?; + let mut artifacts = store.list_edit_artifacts(None, None)?; + let mut commits = store.list_commit_links()?; + + // Store queries may have timestamp/sequence ties. Canonical graph array + // order must depend only on durable identities, never SQLite tie order. + sessions.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + artifacts.sort_by(|left, right| { + (&left.session_id, left.sequence_index, &left.record_id).cmp(&( + &right.session_id, + right.sequence_index, + &right.record_id, + )) + }); + commits.sort_by(|left, right| { + (&left.commit_sha, &left.record_id).cmp(&(&right.commit_sha, &right.record_id)) + }); + + let selected = select_sessions(&sessions, scope)?; + if selected.is_empty() { + return Err(format!( + "canonical Journey graph store is not initialized for this project; \ + no canonical sessions for scope {}", + scope_label(scope) + )); + } + + let mut input = CanonicalJourneyInput::default(); + let mut seen_projects: HashSet = HashSet::new(); + let mut work_item_projects: HashMap = HashMap::new(); + for session in &sessions { + if !selected.contains(&session.session_id) { + continue; + } + let project_id = session + .journey + .project_id + .as_ref() + .filter(|id| !id.trim().is_empty()) + .cloned() + .ok_or_else(|| { + format!( + "会话 {} 缺少显式 Journey project_id,拒绝推断项目归属", + session.session_id + ) + })?; + if seen_projects.insert(project_id.clone()) { + input.projects.push(CanonicalProject { + id: project_id.clone(), + source_ref: format!("orgtrack:project:{project_id}"), + }); + } + if let Some(work_item_id) = session + .journey + .work_item_id + .as_ref() + .filter(|id| !id.trim().is_empty()) + { + match work_item_projects.get(work_item_id) { + Some(existing_project_id) if existing_project_id != &project_id => { + return Err(format!( + "Journey work item {} 同时关联项目 {} 与 {},拒绝构建跨项目关系", + work_item_id, existing_project_id, project_id + )); + } + Some(_) => {} + None => { + work_item_projects.insert(work_item_id.clone(), project_id.clone()); + input.work_items.push(orgtrack_graph::CanonicalWorkItem { + id: work_item_id.clone(), + project_id: project_id.clone(), + source_ref: format!("orgtrack:session-work-item:{}", session.session_id), + }); + } + } + } + input.sessions.push(CanonicalSession { + id: session.session_id.clone(), + project_id, + work_item_id: session + .journey + .work_item_id + .clone() + .filter(|id| !id.trim().is_empty()), + resumed_from: session + .parent_session_id + .clone() + .filter(|parent| selected.contains(parent)), + compacted_to: None, + forked_from: None, + source_ref: format!("orgtrack:session:{}", session.session_id), + display_timestamp: session.created_at.clone(), + }); + } + + // Canonical edit sequence is the exact durable turn boundary. Negative + // values are invalid storage state and must not be clamped or inferred. + let mut seen_turns: HashSet<(String, u64)> = HashSet::new(); + for artifact in &artifacts { + if !selected.contains(&artifact.session_id) { + continue; + } + let sequence = u64::try_from(artifact.sequence_index).map_err(|_| { + format!( + "会话 {} 的编辑制品 {} 包含无效负数 sequence_index={},拒绝构建 Journey 图", + artifact.session_id, artifact.record_id, artifact.sequence_index + ) + })?; + if seen_turns.insert((artifact.session_id.clone(), sequence)) { + input.turns.push(CanonicalTurn { + session_id: artifact.session_id.clone(), + sequence, + source_ref: format!("orgtrack:artifact:{}", artifact.record_id), + display_timestamp: artifact.timestamp.clone(), + }); + } + } + + for artifact in &artifacts { + if !selected.contains(&artifact.session_id) { + continue; + } + let relation = match artifact.edit_kind { + SessionEditKind::Write => ArtifactRelation::Produced, + SessionEditKind::Patch | SessionEditKind::Delete => ArtifactRelation::Modified, + _ => continue, + }; + input.artifacts.push(CanonicalArtifact { + source: artifact.source.clone(), + id: artifact.record_id.clone(), + session_id: artifact.session_id.clone(), + file_repo: artifact.workspace_path.clone(), + file_path: Some(artifact.file_path.clone()), + relation, + source_ref: format!("orgtrack:artifact:{}", artifact.record_id), + }); + } + + for commit in &commits { + let linked_session = commit + .session_ids + .iter() + .filter(|session_id| selected.contains(*session_id)) + .min(); + if let Some(session_id) = linked_session { + input.commits.push(CanonicalCommit { + repo: "unknown".to_string(), + sha: commit.commit_sha.clone(), + session_id: Some(session_id.clone()), + work_item_id: None, + source_ref: format!("orgtrack:commit:{}", commit.commit_sha), + }); + } + } + + let mut graph = orgtrack_graph::journey::project_canonical_journey(&input)?; + append_session_journey_lifecycle(&conn, &mut graph, &selected)?; + let report = audit_canonical_journey(&input, &graph); + if !report.trustworthy { + let uncovered: Vec<_> = report + .coverage + .iter() + .filter(|entry| matches!(entry.status, CoverageStatus::Uncovered)) + .map(|entry| entry.source_ref.clone()) + .collect(); + return Err(format!( + "canonical Journey graph store is incomplete for this project; \ + refusing partial data, uncovered canonical units: {}", + uncovered.join(", ") + )); + } + Ok(graph) +} + +/// Append durable Session Journey lifecycle entities for the exact canonical +/// sessions already selected for this graph. A lifecycle record without its +/// canonical session is rejected; message anchors are verified by +/// `session_id + message_id + sequence` in the same sessions database. +fn append_session_journey_lifecycle( + conn: &Connection, + graph: &mut JourneyGraph, + selected: &HashSet, +) -> Result<(), String> { + let mut session_ids: Vec<_> = selected.iter().collect(); + session_ids.sort_unstable(); + for session_id in session_ids { + if let Some(journey) = SqliteJourneyRepository::load_existing(&conn, session_id) + .map_err(|error| format!("无法读取会话旅程 {session_id}:{error}"))? + { + if journey.session_id != *session_id { + return Err(format!( + "会话旅程存储身份不一致:row_session={session_id}, state_session={}", + journey.session_id + )); + } + super::journey_lifecycle_graph::append(&conn, graph, &journey)?; + } + } + Ok(()) +} + +fn select_sessions( + sessions: &[orgtrack_core::canonical::SessionRecord], + scope: &JourneyScope, +) -> Result, String> { + let mut selected = HashSet::new(); + match scope { + JourneyScope::Project(id) => { + for session in sessions { + if session.journey.project_id.as_deref() == Some(id) { + selected.insert(session.session_id.clone()); + } + } + } + JourneyScope::Session(id) => { + let by_id: HashMap<&str, &orgtrack_core::canonical::SessionRecord> = sessions + .iter() + .map(|session| (session.session_id.as_str(), session)) + .collect(); + if !by_id.contains_key(id.as_str()) { + return Err(format!( + "canonical Journey graph store is not initialized for this project; \ + no canonical session for scope {}", + scope_label(scope) + )); + } + let mut cursor = Some(id.as_str()); + while let Some(session_id) = cursor { + if !selected.insert(session_id.to_string()) { + break; + } + cursor = by_id + .get(session_id) + .and_then(|session| session.parent_session_id.as_deref()); + } + } + } + Ok(selected) +} + +fn scope_label(scope: &JourneyScope) -> String { + match scope { + JourneyScope::Project(id) => format!("project/{id}"), + JourneyScope::Session(id) => format!("session/{id}"), + } +} diff --git a/src-tauri/src/orgtrack/journey_lifecycle_graph.rs b/src-tauri/src/orgtrack/journey_lifecycle_graph.rs new file mode 100644 index 0000000000..5385ce28db --- /dev/null +++ b/src-tauri/src/orgtrack/journey_lifecycle_graph.rs @@ -0,0 +1,432 @@ +//! Durable Session Journey lifecycle aggregate -> canonical graph projection. +//! Exact boundaries are verified through membership rows; no timestamp inference. + +use agent_core::core::journey_lifecycle::{ReviewState, SessionJourney}; +use orgtrack_graph::{ + CoverageEntry, CoverageStatus, EvidenceClass, JourneyEdge, JourneyEdgeKind, JourneyGraph, + JourneyNode, JourneyNodeKind, +}; +use rusqlite::{Connection, OptionalExtension}; +use std::collections::HashSet; + +fn sid(session: &str, kind: &str, id: &str) -> String { + format!("journey/{session}/{kind}/{id}") +} +fn src(session: &str, kind: &str, id: &str) -> String { + format!("session-journey:{session}:{kind}:{id}") +} +fn node( + g: &mut JourneyGraph, + ids: &mut HashSet, + id: String, + kind: JourneyNodeKind, + evidence: EvidenceClass, + source: String, +) -> Result<(), String> { + if id.trim().is_empty() || source.trim().is_empty() { + return Err("Journey lifecycle identity is empty".into()); + } + if !ids.insert(id.clone()) { + return Err(format!("duplicate Journey lifecycle node: {id}")); + } + g.nodes.push(JourneyNode { + id, + kind, + evidence_class: evidence, + source_ref: source.clone(), + display_timestamp: None, + }); + g.coverage.push(CoverageEntry { + source_ref: source, + status: CoverageStatus::Represented, + }); + Ok(()) +} +fn edge( + g: &mut JourneyGraph, + from: String, + to: String, + kind: JourneyEdgeKind, + evidence: EvidenceClass, + source: String, +) { + g.edges.push(JourneyEdge { + from, + to, + kind, + evidence_class: evidence, + source_ref: source, + }); +} + +/// A JSON aggregate is only structurally trustworthy when each map key agrees +/// with the immutable ID carried by its entity. Otherwise later reference +/// checks can accept a map key while graph IDs are built from a different ID, +/// creating an unverifiable dangling edge. +fn verify_record_key(kind: &str, key: &str, entity_id: &str) -> Result<(), String> { + if key != entity_id || entity_id.trim().is_empty() { + return Err(format!( + "Journey {kind} 存储键与实体 ID 不一致:key={key}, id={entity_id}" + )); + } + Ok(()) +} + +fn message_anchor( + conn: &Connection, + g: &mut JourneyGraph, + ids: &mut HashSet, + session: &str, + message: &str, + sequence: u64, +) -> Result { + let found:Option=conn.query_row("SELECT sequence FROM session_journey_memberships WHERE session_id=?1 AND message_id=?2",(session,message),|r|r.get(0)).optional().map_err(|e|format!("读取 Journey 精确锚点失败:{e}"))?; + if found != Some(sequence as i64) { + return Err(format!("Journey 精确锚点不一致:session={session}, message={message}, expected_sequence={sequence}")); + } + let id = sid(session, "message", message); + if !ids.contains(&id) { + node( + g, + ids, + id.clone(), + JourneyNodeKind::MessageAnchor, + EvidenceClass::Canonical, + format!("session-journey-membership:{session}:{message}:{sequence}"), + )?; + } + Ok(id) +} +pub fn append(conn: &Connection, g: &mut JourneyGraph, j: &SessionJourney) -> Result<(), String> { + let session_node = format!("session/{}", j.session_id); + if !g.nodes.iter().any(|n| n.id == session_node) { + return Err(format!( + "Journey aggregate has no canonical session: {}", + j.session_id + )); + } + let mut ids = g.nodes.iter().map(|n| n.id.clone()).collect::>(); + for (key, b) in &j.branches { + verify_record_key("分支", key, &b.id)?; + let id = sid(&j.session_id, "branch", &b.id); + let source = src(&j.session_id, "branch", &b.id); + node( + g, + &mut ids, + id.clone(), + JourneyNodeKind::Branch, + EvidenceClass::Canonical, + source.clone(), + )?; + edge( + g, + session_node.clone(), + id.clone(), + JourneyEdgeKind::Contains, + EvidenceClass::Canonical, + source.clone(), + ); + if b.parent_branch_id != b.id { + let expected_source_start = b + .anchor_sequence + .checked_add(1) + .ok_or_else(|| format!("Journey 分叉 {} 的父锚点序号溢出", b.id))?; + if b.source_start_sequence != expected_source_start { + return Err(format!( + "Journey 分叉 {} 的来源起点 {} 与父锚点 {} 不连续", + b.id, b.source_start_sequence, b.anchor_sequence + )); + } + if let Some(end) = b.frozen_end_sequence { + if end < b.source_start_sequence { + return Err(format!( + "Journey 分叉 {} 的冻结终点 {} 早于来源起点 {}", + b.id, end, b.source_start_sequence + )); + } + } + if !j.branches.contains_key(&b.parent_branch_id) { + return Err(format!("Journey 分叉 {} 缺少父分支", b.id)); + } + edge( + g, + sid(&j.session_id, "branch", &b.parent_branch_id), + id.clone(), + JourneyEdgeKind::ForkedFrom, + EvidenceClass::Canonical, + source.clone(), + ); + let message = b + .parent_anchor_message_id + .as_deref() + .ok_or_else(|| format!("Journey 分叉 {} 缺少精确父消息锚点", b.id))?; + let anchor = + message_anchor(conn, g, &mut ids, &j.session_id, message, b.anchor_sequence)?; + edge( + g, + id, + anchor, + JourneyEdgeKind::AnchoredAt, + EvidenceClass::Canonical, + source, + ); + } + } + for (key, t) in &j.tasks { + verify_record_key("任务", key, &t.id)?; + if !j.branches.contains_key(&t.branch_id) { + return Err(format!("Journey 任务 {} 缺少所属分支", t.id)); + } + match t.state { + agent_core::core::journey_lifecycle::TaskState::PendingNextUser => { + if t.start_sequence.is_some() || t.finish_sequence.is_some() || t.outcome.is_some() + { + return Err(format!("Journey 待激活任务 {} 包含不允许的完成状态", t.id)); + } + } + agent_core::core::journey_lifecycle::TaskState::Active => { + if t.start_sequence.is_none() || t.finish_sequence.is_some() || t.outcome.is_some() + { + return Err(format!("Journey 活跃任务 {} 的状态字段不一致", t.id)); + } + } + agent_core::core::journey_lifecycle::TaskState::Finished => { + if t.start_sequence.is_none() || t.finish_sequence.is_none() || t.outcome.is_none() + { + return Err(format!("Journey 已完成任务 {} 缺少起止序号或结果", t.id)); + } + } + } + if let (Some(start), Some(finish)) = (t.start_sequence, t.finish_sequence) { + if start > finish { + return Err(format!( + "Journey 任务 {} 的起止序号反向:start={} finish={}", + t.id, start, finish + )); + } + } + let id = sid(&j.session_id, "task", &t.id); + let source = src(&j.session_id, "task", &t.id); + node( + g, + &mut ids, + id.clone(), + JourneyNodeKind::Task, + EvidenceClass::Canonical, + source.clone(), + )?; + edge( + g, + sid(&j.session_id, "branch", &t.branch_id), + id, + JourneyEdgeKind::Contains, + EvidenceClass::Canonical, + source, + ); + } + for (key, c) in &j.checkpoints { + verify_record_key("检查点", key, &c.id)?; + if !j.tasks.contains_key(&c.task_id) { + return Err(format!("Journey 检查点 {} 缺少所属任务", c.id)); + } + let id = sid(&j.session_id, "checkpoint", &c.id); + let source = src(&j.session_id, "checkpoint", &c.id); + node( + g, + &mut ids, + id.clone(), + JourneyNodeKind::Checkpoint, + EvidenceClass::Canonical, + source.clone(), + )?; + edge( + g, + sid(&j.session_id, "task", &c.task_id), + id.clone(), + JourneyEdgeKind::Contains, + EvidenceClass::Canonical, + source.clone(), + ); + let anchor = message_anchor(conn, g, &mut ids, &j.session_id, &c.message_id, c.sequence)?; + edge( + g, + id, + anchor, + JourneyEdgeKind::AnchoredAt, + EvidenceClass::Canonical, + source, + ); + } + for (key, r) in &j.reviews { + verify_record_key("审阅", key, &r.id)?; + if !j.branches.contains_key(&r.fork_id) { + return Err(format!("Journey 审阅 {} 缺少所属分叉", r.id)); + } + let branch = j + .branches + .get(&r.fork_id) + .ok_or_else(|| format!("Journey 审阅 {} 缺少所属分叉", r.id))?; + if r.source_start_sequence > r.source_end_sequence { + return Err(format!( + "Journey 审阅 {} 的来源范围反向:start={} end={}", + r.id, r.source_start_sequence, r.source_end_sequence + )); + } + if r.source_start_sequence != branch.source_start_sequence + || branch.frozen_end_sequence != Some(r.source_end_sequence) + { + return Err(format!( + "Journey 审阅 {} 的来源范围 {}..{} 与分叉 {} 的冻结范围不一致", + r.id, r.source_start_sequence, r.source_end_sequence, r.fork_id + )); + } + match r.state { + ReviewState::Queued => { + if r.annotation.is_some() || !r.promoted_fact_ids.is_empty() { + return Err(format!("Journey 待审核项 {} 包含已发布内容", r.id)); + } + } + ReviewState::Ready => { + if r.provenance.is_none() || !r.promoted_fact_ids.is_empty() { + return Err(format!("Journey 可审核项 {} 的来源或确认状态不一致", r.id)); + } + } + ReviewState::Confirmed => { + if r.provenance.is_none() || r.promoted_fact_ids.is_empty() { + return Err(format!("Journey 已确认审阅 {} 缺少来源或确认事实", r.id)); + } + } + ReviewState::Failed => { + if r.annotation.is_some() || !r.promoted_fact_ids.is_empty() { + return Err(format!("Journey 失败审阅 {} 包含已发布内容", r.id)); + } + } + ReviewState::Discarded => { + if !r.promoted_fact_ids.is_empty() { + return Err(format!("Journey 已丢弃审阅 {} 仍引用确认事实", r.id)); + } + } + } + let id = sid(&j.session_id, "review", &r.id); + let source = src(&j.session_id, "review", &r.id); + node( + g, + &mut ids, + id.clone(), + JourneyNodeKind::Review, + EvidenceClass::Canonical, + source.clone(), + )?; + edge( + g, + sid(&j.session_id, "branch", &r.fork_id), + id.clone(), + JourneyEdgeKind::Reviews, + EvidenceClass::Canonical, + source, + ); + // An annotation is a review projection, not immutable transcript. + // Queued/failed/discarded reviews never publish it; discard may leave + // the old serialized field behind, but it must not resurrect the AI + // annotation in the canonical graph. + if matches!(r.state, ReviewState::Ready | ReviewState::Confirmed) { + let _annotation = r + .annotation + .as_ref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("Journey 审阅 {} 缺少有效 annotation", r.id))?; + let aid = sid(&j.session_id, "annotation", &r.id); + let asrc = src(&j.session_id, "annotation", &r.id); + node( + g, + &mut ids, + aid.clone(), + JourneyNodeKind::Annotation, + EvidenceClass::AiAnnotation, + asrc.clone(), + )?; + edge( + g, + id, + aid, + JourneyEdgeKind::Produced, + EvidenceClass::AiAnnotation, + asrc, + ); + } + } + for (key, f) in &j.facts { + verify_record_key("事实", key, &f.id)?; + let review = j + .reviews + .get(&f.review_id) + .ok_or_else(|| format!("Journey 已确认事实 {} 缺少来源审阅", f.id))?; + if review.state != ReviewState::Confirmed + || !review + .promoted_fact_ids + .iter() + .any(|fact_id| fact_id == &f.id) + { + return Err(format!("Journey 已确认事实 {} 与来源审阅状态不一致", f.id)); + } + if f.evidence_start_message_id.trim().is_empty() + || f.evidence_end_message_id.trim().is_empty() + { + return Err(format!( + "Journey 已确认事实 {} 的证据消息 ID 不能为空", + f.id + )); + } + if f.evidence_start_sequence > f.evidence_end_sequence { + return Err(format!( + "Journey 已确认事实 {} 的证据序号范围反向:start={} end={}", + f.id, f.evidence_start_sequence, f.evidence_end_sequence + )); + } + if f.evidence_start_sequence < review.source_start_sequence + || f.evidence_end_sequence > review.source_end_sequence + { + return Err(format!( + "Journey 已确认事实 {} 的证据范围 {}..{} 超出来源审阅范围 {}..{}", + f.id, + f.evidence_start_sequence, + f.evidence_end_sequence, + review.source_start_sequence, + review.source_end_sequence + )); + } + let id = sid(&j.session_id, "fact", &f.id); + let source = src(&j.session_id, "fact", &f.id); + node( + g, + &mut ids, + id.clone(), + JourneyNodeKind::ConfirmedFact, + EvidenceClass::Canonical, + source.clone(), + )?; + edge( + g, + sid(&j.session_id, "review", &f.review_id), + id.clone(), + JourneyEdgeKind::PromotedFrom, + EvidenceClass::Canonical, + source.clone(), + ); + for (message_id, seq) in [ + (&f.evidence_start_message_id, f.evidence_start_sequence), + (&f.evidence_end_message_id, f.evidence_end_sequence), + ] { + let anchor = message_anchor(conn, g, &mut ids, &j.session_id, message_id, seq)?; + edge( + g, + id.clone(), + anchor, + JourneyEdgeKind::EvidenceAt, + EvidenceClass::Canonical, + source.clone(), + ); + } + } + Ok(()) +} diff --git a/src-tauri/src/orgtrack/mod.rs b/src-tauri/src/orgtrack/mod.rs index f40f7d1a4c..dde0ba7865 100644 --- a/src-tauri/src/orgtrack/mod.rs +++ b/src-tauri/src/orgtrack/mod.rs @@ -7,7 +7,9 @@ pub mod history_commands; mod history_scan_coordinator; pub mod impact_indexer; pub mod importer; +#[path = "journey_canonical.rs"] pub mod journey; +pub mod journey_lifecycle_graph; pub mod paths; pub mod session_provenance; pub mod types; @@ -47,7 +49,7 @@ pub async fn journey_graph_query(scope: String) -> Result ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); + +describe("session Journey typed desktop API", () => { + beforeEach(() => invoke.mockReset()); + + it("uses centralized command names and camelCase Tauri request fields", async () => { + await sessionJourneyApi.startTask({ + sessionId: "s", + expectedRevision: 3, + taskId: "t", + name: "任务", + position: "最近用户消息", + }); + expect(invoke).toHaveBeenCalledWith("journey_task_start", { + request: { + sessionId: "s", + expectedRevision: 3, + taskId: "t", + name: "任务", + position: "最近用户消息", + }, + }); + }); + + it("keeps review confirmation, explicit discard, and exact return anchor behind typed API methods", async () => { + await sessionJourneyApi.confirm({ + sessionId: "s", + expectedRevision: 3, + reviewId: "r", + factId: "f", + text: "确认", + evidenceStartMessageId: "m1", + evidenceEndMessageId: "m2", + }); + await sessionJourneyApi.discard({ + sessionId: "s", + expectedRevision: 4, + reviewId: "r", + }); + await sessionJourneyApi.returnToParent({ + sessionId: "s", + expectedRevision: 5, + reviewId: "r", + }); + expect(invoke.mock.calls.map(([command]) => command)).toEqual([ + "journey_confirm", + "journey_discard", + "journey_return_parent", + ]); + }); +}); diff --git a/src/api/tauri/sessionJourney/index.ts b/src/api/tauri/sessionJourney/index.ts new file mode 100644 index 0000000000..fa2bb45165 --- /dev/null +++ b/src/api/tauri/sessionJourney/index.ts @@ -0,0 +1,152 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type TaskStartPosition = "最近用户消息" | "下一条用户消息"; +export type TaskOutcome = + | "completed" + | "partially_completed" + | "paused" + | "abandoned" + | "redirected"; +export type ReviewState = + | "queued" + | "ready" + | "confirmed" + | "discarded" + | "failed"; + +export interface JourneyTask { + id: string; + name: string; + branch_id: string; + state: string; + start_sequence: number | null; + finish_sequence: number | null; + outcome: TaskOutcome | null; +} +export interface JourneyCheckpoint { + id: string; + task_id: string; + message_id: string; + sequence: number; + name: string; +} +export interface JourneyFork { + id: string; + parent_branch_id: string; + parent_anchor_message_id: string | null; + anchor_sequence: number; + state: string; + handoff_capsule: { + objective: string; + conclusion: string; + open_questions: string[]; + confirmed_items: string[]; + } | null; +} +export interface JourneyReview { + id: string; + fork_id: string; + state: ReviewState; + annotation: string | null; + source_start_sequence: number; + source_end_sequence: number; + promoted_fact_ids: string[]; +} +export interface JourneySnapshot { + session_id: string; + revision: number; + active_branch_id: string; + active_task_id: string | null; + tasks: Record; + checkpoints: Record; + branches: Record; + reviews: Record; +} +export interface JourneySnapshotResponse { + snapshot: JourneySnapshot; + revision: number; +} +export interface JourneyWriteResponse { + revision: number; +} + +export interface CreateTaskRequest { + sessionId: string; + expectedRevision: number; + taskId: string; + name: string; + position: TaskStartPosition; +} +export interface CreateForkRequest { + sessionId: string; + expectedRevision: number; + forkId: string; + taskId: string; + taskName: string; + anchorMessageId: string; +} +export interface CreateCheckpointRequest { + sessionId: string; + expectedRevision: number; + checkpointId: string; + name: string; + messageId: string; +} +export interface FinishTaskRequest { + sessionId: string; + expectedRevision: number; + outcome: TaskOutcome; + messageId: string; +} +export interface PromoteFactRequest { + sessionId: string; + expectedRevision: number; + reviewId: string; + factId: string; + text: string; + evidenceStartMessageId: string; + evidenceEndMessageId: string; +} +export interface DiscardForkRequest { + sessionId: string; + expectedRevision: number; + reviewId: string; +} +export interface ReturnToParentRequest { + sessionId: string; + expectedRevision: number; + reviewId: string; +} + +/** Typed desktop boundary. UI code must not invoke Journey command strings. */ +export const sessionJourneyApi = { + snapshot: (sessionId: string) => + invoke("journey_snapshot", { sessionId }), + startTask: (request: CreateTaskRequest) => + invoke("journey_task_start", { request }), + checkpoint: (request: CreateCheckpointRequest) => + invoke("journey_checkpoint", { request }), + finishTask: (request: FinishTaskRequest) => + invoke("journey_task_finish", { request }), + startFork: (request: CreateForkRequest) => + invoke("journey_fork_start", { request }), + reviews: (sessionId: string) => + invoke("journey_review_list", { sessionId }), + readyDraft: (sessionId: string, reviewId: string) => + invoke("journey_ready_draft", { sessionId, reviewId }), + confirm: (request: PromoteFactRequest) => + invoke("journey_confirm", { request }), + discard: (request: DiscardForkRequest) => + invoke<{ + revision: number; + parent_branch_id: string; + parent_anchor_sequence: number; + }>("journey_discard", { request }), + returnToParent: (request: ReturnToParentRequest) => + invoke<{ + revision: number; + parent_branch_id: string; + parent_anchor_message_id: string; + parent_anchor_sequence: number; + }>("journey_return_parent", { request }), +}; diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx index 1dbf92d2eb..5bdda364ff 100644 --- a/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx @@ -3,13 +3,19 @@ import React from "react"; import type { JourneyScope } from "@src/api/tauri/journeyGraph"; import { JourneyContainer } from "./JourneyContainer"; +import { SessionJourneySnapshot } from "./SessionJourneySnapshot"; export const SessionJourneyPage: React.FC<{ sessionId: string; sessionName?: string; }> = ({ sessionId, sessionName }) => ( - +
+ +
+ +
+
); diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx new file mode 100644 index 0000000000..4f108d1c51 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx @@ -0,0 +1,91 @@ +import { Flag, GitFork, ListChecks, MapPin } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; + +import { + type JourneySnapshot, + sessionJourneyApi, +} from "@src/api/tauri/sessionJourney"; +import { requestJourneyMessageJump } from "@src/modules/WorkStation/Chat/Journey/journeyMessageJump"; + +export const SessionJourneySnapshot: React.FC<{ sessionId: string }> = ({ + sessionId, +}) => { + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const reload = useCallback(async () => { + try { + const response = await sessionJourneyApi.snapshot(sessionId); + setSnapshot(response.snapshot); + setError(null); + } catch (reason) { + setError(String(reason)); + } + }, [sessionId]); + useEffect(() => { + const timer = window.setTimeout(() => void reload(), 0); + return () => window.clearTimeout(timer); + }, [reload]); + if (error) + return ( +

旅程快照不可用:{error}

+ ); + if (!snapshot) + return

正在加载会话旅程...

; + return ( +
+
+ 会话旅程 + 修订 {snapshot.revision} +
+
+ {Object.values(snapshot.tasks).map((task) => ( +
+ + {task.name} +

+ {task.state} + {task.outcome ? ` · ${task.outcome}` : ""} +

+
+ ))} + {Object.values(snapshot.branches) + .filter((fork) => fork.id !== fork.parent_branch_id) + .map((fork) => ( +
+ + {fork.id} +

+ 锚点 {fork.anchor_sequence} · {fork.state} +

+
+ ))} + {Object.values(snapshot.checkpoints).map((checkpoint) => ( + + ))} + {Object.values(snapshot.reviews).map((review) => ( +
+ + 审核 +

+ {review.state} · {review.fork_id} +

+
+ ))} +
+
+ ); +}; diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts index 7edbc50575..efb14be3d1 100644 --- a/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts @@ -8,18 +8,58 @@ import { BranchesGraph } from "../components/BranchesGraph"; import { CoverageLedger } from "../components/CoverageLedger"; import { FileLineagePanel } from "../components/FileLineagePanel"; import { StorylineTimeline } from "../components/StorylineTimeline"; -import { graphToBranchesViewModel, graphToCoverageLedgerViewModel, graphToFileLineageViewModel, graphToStorylineViewModel } from "../viewModel"; +import { + graphToBranchesViewModel, + graphToCoverageLedgerViewModel, + graphToFileLineageViewModel, + graphToStorylineViewModel, +} from "../viewModel"; const graph: JourneyGraphPayload = { nodes: [ - { id: "session/s", kind: "session", evidenceClass: "canonical", sourceRef: "session:s", displayTimestamp: "2026-07-30T08:00:00.000Z" }, - { id: "session/parent", kind: "session", evidenceClass: "canonical", sourceRef: "session:parent", displayTimestamp: "2026-07-30T07:00:00.000Z" }, - { id: "artifact/orgtrack/a", kind: "artifact", evidenceClass: "canonical", sourceRef: "artifact:a" }, - { id: "file/repo/a.ts", kind: "file", evidenceClass: "canonical", sourceRef: "file:a" }, + { + id: "session/s", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:s", + displayTimestamp: "2026-07-30T08:00:00.000Z", + metadata: { topicTags: ["explicit-topic"] }, + }, + { + id: "session/parent", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:parent", + displayTimestamp: "2026-07-30T07:00:00.000Z", + }, + { + id: "artifact/orgtrack/a", + kind: "artifact", + evidenceClass: "canonical", + sourceRef: "artifact:a", + }, + { + id: "file/repo/a.ts", + kind: "file", + evidenceClass: "canonical", + sourceRef: "file:a", + }, ], edges: [ - { from: "session/s", to: "session/parent", kind: "forkedFrom", evidenceClass: "canonical", sourceRef: "fork:s:parent" }, - { from: "artifact/orgtrack/a", to: "file/repo/a.ts", kind: "produced", evidenceClass: "canonical", sourceRef: "artifact:a" }, + { + from: "session/s", + to: "session/parent", + kind: "forkedFrom", + evidenceClass: "canonical", + sourceRef: "fork:s:parent", + }, + { + from: "artifact/orgtrack/a", + to: "file/repo/a.ts", + kind: "produced", + evidenceClass: "canonical", + sourceRef: "artifact:a", + }, ], coverage: [{ sourceRef: "session:s", status: "represented" }], }; @@ -27,14 +67,63 @@ const graph: JourneyGraphPayload = { describe("P2 Journey components", () => { it("renders evidence badges and source drill targets for every graph view", () => { const markup = [ - renderToStaticMarkup(createElement(StorylineTimeline, { viewModel: graphToStorylineViewModel(graph) })), - renderToStaticMarkup(createElement(BranchesGraph, { viewModel: graphToBranchesViewModel(graph) })), - renderToStaticMarkup(createElement(FileLineagePanel, { viewModel: graphToFileLineageViewModel(graph) })), - renderToStaticMarkup(createElement(CoverageLedger, { viewModel: graphToCoverageLedgerViewModel(graph) })), + renderToStaticMarkup( + createElement(StorylineTimeline, { + viewModel: graphToStorylineViewModel(graph), + }) + ), + renderToStaticMarkup( + createElement(BranchesGraph, { + viewModel: graphToBranchesViewModel(graph), + }) + ), + renderToStaticMarkup( + createElement(FileLineagePanel, { + viewModel: graphToFileLineageViewModel(graph), + }) + ), + renderToStaticMarkup( + createElement(CoverageLedger, { + viewModel: graphToCoverageLedgerViewModel(graph), + }) + ), ].join("\n"); expect(markup).toContain("canonical"); expect(markup).toContain("Source: session:s"); expect(markup).toContain("#journey-source-session%3As"); expect(markup).toContain("Independent provenance audit"); + expect(markup).toContain("explicit-topic"); + }); + + it("does not render unknown or non-canonical topic tags", () => { + const unknownGraph: JourneyGraphPayload = { + ...graph, + nodes: [ + { + id: "session/unknown", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:unknown", + displayTimestamp: "2026-07-30T08:00:00.000Z", + }, + { + id: "session/overlay", + kind: "session", + evidenceClass: "userOverlay", + sourceRef: "overlay", + displayTimestamp: "2026-07-30T09:00:00.000Z", + metadata: { topicTags: ["not-canonical"] }, + }, + ], + edges: [], + }; + + const markup = renderToStaticMarkup( + createElement(StorylineTimeline, { + viewModel: graphToStorylineViewModel(unknownGraph), + }) + ); + expect(markup).not.toContain("storyline-topic-tags"); + expect(markup).not.toContain("not-canonical"); }); }); diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts index bffa72c23e..7adf20577f 100644 --- a/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts @@ -30,6 +30,7 @@ function milestone( sourceRef: `src:${id}`, displayTimestamp: ts, sequence, + topicTags: [], }; } diff --git a/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts index a1c794c5cc..b4bb652c1a 100644 --- a/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts @@ -11,21 +11,102 @@ import { const payload: JourneyGraphPayload = { nodes: [ - { id: "session/z", kind: "session", evidenceClass: "canonical", sourceRef: "session:z", displayTimestamp: "2026-07-30T10:00:00.000Z" }, - { id: "session/a", kind: "session", evidenceClass: "canonical", sourceRef: "session:a", displayTimestamp: "2026-07-30T08:00:00.000Z" }, - { id: "turn/a/2", kind: "turn", evidenceClass: "canonical", sourceRef: "turn:a:2", displayTimestamp: "2026-07-30T08:30:00.000Z" }, - { id: "turn/a/1", kind: "turn", evidenceClass: "canonical", sourceRef: "turn:a:1", displayTimestamp: "2026-07-30T08:00:00.000Z" }, - { id: "artifact/orgtrack/a", kind: "artifact", evidenceClass: "canonical", sourceRef: "artifact:a" }, - { id: "file/repo/src/a.ts", kind: "file", evidenceClass: "canonical", sourceRef: "file:a" }, - { id: "commit/repo/abc", kind: "commit", evidenceClass: "canonical", sourceRef: "commit:abc" }, + { + id: "session/z", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:z", + displayTimestamp: "2026-07-30T10:00:00.000Z", + metadata: { + agentIdentity: "agent-z", + agentBand: "review", + topicTags: ["explicit"], + }, + }, + { + id: "session/a", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:a", + displayTimestamp: "2026-07-30T08:00:00.000Z", + metadata: { agentIdentity: "agent-a", agentBand: "build", topicTags: [] }, + }, + { + id: "turn/a/2", + kind: "turn", + evidenceClass: "canonical", + sourceRef: "turn:a:2", + displayTimestamp: "2026-07-30T08:30:00.000Z", + }, + { + id: "turn/a/1", + kind: "turn", + evidenceClass: "canonical", + sourceRef: "turn:a:1", + displayTimestamp: "2026-07-30T08:00:00.000Z", + }, + { + id: "artifact/orgtrack/a", + kind: "artifact", + evidenceClass: "canonical", + sourceRef: "artifact:a", + }, + { + id: "file/repo/src/a.ts", + kind: "file", + evidenceClass: "canonical", + sourceRef: "file:a", + }, + { + id: "commit/repo/abc", + kind: "commit", + evidenceClass: "canonical", + sourceRef: "commit:abc", + }, ], edges: [ - { from: "session/a", to: "turn/a/1", kind: "contains", evidenceClass: "canonical", sourceRef: "turn:a:1" }, - { from: "session/a", to: "turn/a/2", kind: "contains", evidenceClass: "canonical", sourceRef: "turn:a:2" }, - { from: "session/z", to: "session/a", kind: "forkedFrom", evidenceClass: "canonical", sourceRef: "fork:z:a" }, - { from: "session/a", to: "session/z", kind: "resumedFrom", evidenceClass: "canonical", sourceRef: "resume:a:z" }, - { from: "artifact/orgtrack/a", to: "file/repo/src/a.ts", kind: "modified", evidenceClass: "canonical", sourceRef: "artifact:a" }, - { from: "session/a", to: "commit/repo/abc", kind: "committedIn", evidenceClass: "canonical", sourceRef: "commit:abc" }, + { + from: "session/a", + to: "turn/a/1", + kind: "contains", + evidenceClass: "canonical", + sourceRef: "turn:a:1", + }, + { + from: "session/a", + to: "turn/a/2", + kind: "contains", + evidenceClass: "canonical", + sourceRef: "turn:a:2", + }, + { + from: "session/z", + to: "session/a", + kind: "forkedFrom", + evidenceClass: "canonical", + sourceRef: "fork:z:a", + }, + { + from: "session/a", + to: "session/z", + kind: "resumedFrom", + evidenceClass: "canonical", + sourceRef: "resume:a:z", + }, + { + from: "artifact/orgtrack/a", + to: "file/repo/src/a.ts", + kind: "modified", + evidenceClass: "canonical", + sourceRef: "artifact:a", + }, + { + from: "session/a", + to: "commit/repo/abc", + kind: "committedIn", + evidenceClass: "canonical", + sourceRef: "commit:abc", + }, ], coverage: [ { sourceRef: "turn:a:1", status: "represented" }, @@ -37,33 +118,148 @@ const payload: JourneyGraphPayload = { describe("P2 Journey view models", () => { it("creates explicit idle compression and stable session lanes", () => { const view = graphToStorylineViewModel(payload, 10 * 60 * 1000); - expect(view.lanes.map((lane) => lane.id)).toEqual(["session/a", "session/z"]); - expect(view.lanes[0].milestones.map((milestone) => milestone.id)).toEqual(["session/a", "turn/a/1", "turn/a/2"]); - expect(view.lanes[0].gaps).toEqual([{ kind: "idleGap", fromTimestamp: "2026-07-30T08:00:00.000Z", toTimestamp: "2026-07-30T08:30:00.000Z", durationMs: 30 * 60 * 1000 }]); - expect(view.unpositioned.map((item) => item.id)).toContain("artifact/orgtrack/a"); + expect(view.lanes.map((lane) => lane.id)).toEqual(["build", "review"]); + expect(view.lanes[0].milestones.map((milestone) => milestone.id)).toEqual([ + "session/a", + "turn/a/1", + "turn/a/2", + ]); + expect(view.lanes[0].gaps).toEqual([ + { + kind: "idleGap", + fromTimestamp: "2026-07-30T08:00:00.000Z", + toTimestamp: "2026-07-30T08:30:00.000Z", + durationMs: 30 * 60 * 1000, + }, + ]); + expect(view.unpositioned.map((item) => item.id)).toContain( + "artifact/orgtrack/a" + ); + expect(view.lanes[0].milestones[0].topicTags).toEqual([]); + expect(view.lanes[0].milestones[1].sequence).toBeNull(); }); it("uses actual branch edges only, never timestamp proximity", () => { - const graph = { ...payload, edges: payload.edges.filter((edge) => edge.kind !== "forkedFrom" && edge.kind !== "resumedFrom") }; + const graph = { + ...payload, + edges: payload.edges.filter( + (edge) => edge.kind !== "forkedFrom" && edge.kind !== "resumedFrom" + ), + }; expect(graphToBranchesViewModel(graph).links).toEqual([]); - expect(graphToBranchesViewModel(payload).links.map((link) => link.kind)).toEqual(["resumedFrom", "forkedFrom"]); + expect( + graphToBranchesViewModel(payload).links.map((link) => link.kind) + ).toEqual(["resumedFrom", "forkedFrom"]); + }); + + it("keeps absent agent and topic metadata unknown instead of deriving it from ids", () => { + const graph: JourneyGraphPayload = { + ...payload, + nodes: [ + { + id: "session/sdeagent-review", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:s", + displayTimestamp: "2026-07-30T08:00:00.000Z", + }, + ], + edges: [], + }; + const view = graphToStorylineViewModel(graph); + expect(view.lanes).toHaveLength(1); + expect(view.lanes[0].id).toBe("unknown-agent"); + expect(view.lanes[0].milestones[0].topicTags).toEqual([]); + }); + + it("uses the explicit session node kind instead of a session-id prefix", () => { + const graph: JourneyGraphPayload = { + ...payload, + nodes: [ + { + id: "opaque-owner", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:opaque", + displayTimestamp: "2026-07-30T08:00:00.000Z", + metadata: { + agentIdentity: "agent-a", + agentBand: "build", + topicTags: [], + }, + }, + { + id: "opaque-turn", + kind: "turn", + evidenceClass: "canonical", + sourceRef: "turn:opaque", + displayTimestamp: "2026-07-30T08:01:00.000Z", + }, + ], + edges: [ + { + from: "opaque-owner", + to: "opaque-turn", + kind: "contains", + evidenceClass: "canonical", + sourceRef: "turn:opaque", + }, + ], + }; + + expect(graphToStorylineViewModel(graph).lanes[0].id).toBe("build"); }); it("includes only produced or modified edges connected to factual file nodes", () => { - const graph: JourneyGraphPayload = { ...payload, edges: [...payload.edges, { from: "session/a", to: "commit/repo/abc", kind: "produced", evidenceClass: "canonical", sourceRef: "not-a-file" }] }; + const graph: JourneyGraphPayload = { + ...payload, + edges: [ + ...payload.edges, + { + from: "session/a", + to: "commit/repo/abc", + kind: "produced", + evidenceClass: "canonical", + sourceRef: "not-a-file", + }, + ], + }; const view = graphToFileLineageViewModel(graph); - expect(view.links).toEqual([{ from: "artifact/orgtrack/a", to: "file/repo/src/a.ts", kind: "modified", evidenceClass: "canonical", sourceRef: "artifact:a" }]); + expect(view.links).toEqual([ + { + from: "artifact/orgtrack/a", + to: "file/repo/src/a.ts", + kind: "modified", + evidenceClass: "canonical", + sourceRef: "artifact:a", + }, + ]); expect(view.files.map((file) => file.id)).toEqual(["file/repo/src/a.ts"]); }); it("keeps coverage and independent provenance state separate", () => { const view = graphToCoverageLedgerViewModel(payload); - expect(view.summary).toEqual({ represented: 1, mergedInto: 1, excluded: 1, uncovered: 0 }); - expect(view.entries.find((entry) => entry.sourceRef === "policy")?.detail).toBe("Excluded: retention"); + expect(view.summary).toEqual({ + represented: 1, + mergedInto: 1, + excluded: 1, + uncovered: 0, + }); + expect( + view.entries.find((entry) => entry.sourceRef === "policy")?.detail + ).toBe("Excluded: retention"); expect(view.provenanceAudit).toBe("notProvided"); }); it("fails closed for uncovered coverage", () => { - expect(() => graphToCoverageLedgerViewModel({ ...payload, coverage: [...payload.coverage, { sourceRef: "missing", status: "uncovered" }] })).toThrow("incomplete"); + expect(() => + graphToCoverageLedgerViewModel({ + ...payload, + coverage: [ + ...payload.coverage, + { sourceRef: "missing", status: "uncovered" }, + ], + }) + ).toThrow("incomplete"); }); }); diff --git a/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx b/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx index d9fe032fea..aef18c9730 100644 --- a/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx +++ b/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx @@ -266,6 +266,15 @@ export const StorylineTimeline: React.FC<{ viewModel: StorylineViewModel }> = ({ ? ` · turn ${milestone.sequence}` : ""}
+ {milestone.evidenceClass === "canonical" && + milestone.topicTags.length > 0 && ( +
+ {milestone.topicTags.join(" · ")} +
+ )} - edge.to === node.id && - edge.kind === "contains" && - edge.from.startsWith("session/") + const session = + node.kind === "session" + ? node + : (() => { + const owner = edges.find( + (edge) => + edge.to === node.id && + edge.kind === "contains" && + nodes.find((candidate) => candidate.id === edge.from)?.kind === + "session" + ); + return owner + ? nodes.find((candidate) => candidate.id === owner.from) + : undefined; + })(); + return ( + session?.metadata?.agentBand ?? session?.metadata?.agentIdentity ?? null ); - return owner?.from ?? null; } /** Pure presentation mapping. It cannot infer or repair graph facts. */ @@ -179,13 +191,11 @@ export function graphToStorylineViewModel( for (const node of [...graph.nodes].sort(compareNodes)) { const timestamp = parseTimestamp(node.displayTimestamp); if (timestamp === null) { - unpositioned.push({ - ...toDisplayNode(node), - sequence: turnSequence(node.id), - }); + unpositioned.push({ ...toDisplayNode(node), sequence: null }); continue; } - const laneId = sessionIdForNode(node, edges) ?? "unlinked-facts"; + const laneId = + agentLaneForNode(node, graph.nodes, edges) ?? "unknown-agent"; const lane = laneMembers.get(laneId) ?? []; lane.push(node); laneMembers.set(laneId, lane); @@ -202,10 +212,7 @@ export function graphToStorylineViewModel( leftTimestamp - rightTimestamp || left.id.localeCompare(right.id) ); }) - .map((node) => ({ - ...toDisplayNode(node), - sequence: turnSequence(node.id), - })); + .map((node) => ({ ...toDisplayNode(node), sequence: null })); const gaps: StorylineIdleGap[] = []; for (let index = 1; index < milestones.length; index += 1) { const previous = milestones[index - 1]; @@ -227,7 +234,7 @@ export function graphToStorylineViewModel( } return { id, - label: id === "unlinked-facts" ? "Unlinked facts" : titleFromId(id), + label: id === "unknown-agent" ? "Unknown agent" : id, milestones, gaps, }; diff --git a/src/modules/WorkStation/Chat/Communication/index.tsx b/src/modules/WorkStation/Chat/Communication/index.tsx index 212beb64a9..06e2062abb 100644 --- a/src/modules/WorkStation/Chat/Communication/index.tsx +++ b/src/modules/WorkStation/Chat/Communication/index.tsx @@ -1,5 +1,5 @@ import { useAtomValue, useSetAtom } from "jotai"; -import React, { memo, useCallback, useMemo, useRef } from "react"; +import React, { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { EDITOR_TAB_CANVAS_BG_CLASS } from "@src/config/workstation/tokens"; @@ -14,6 +14,8 @@ import { } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { AppType } from "@src/engines/Simulator/types/appTypes"; import { matchesCanvasEvent } from "@src/modules/WorkStation/Canvas/config"; +import { SessionJourneyControls } from "@src/modules/WorkStation/Chat/Journey/SessionJourneyControls"; +import { listenForJourneyMessageJump } from "@src/modules/WorkStation/Chat/Journey/journeyMessageJump"; import { TextSelectionDropdown, useTextSelectionDropdown, @@ -184,6 +186,10 @@ const SimulatorMessagesComponent: React.FC = ({ }, [handleViewModeChange, jumpToMessage, messageViewModel.previewMessages] ); + useEffect( + () => listenForJourneyMessageJump(handleMessageClick), + [handleMessageClick] + ); const handlePlanEditToggle = useCallback(() => { if (!isEditing) handleViewModeChange("preview"); handleEditToggle(); @@ -277,7 +283,16 @@ const SimulatorMessagesComponent: React.FC = ({ ? () => handlePreviewModeChange(!effectivePreviewMode) : undefined } - extraActions={planHeaderActions} + extraActions={ + <> + {planHeaderActions} + + + } />
{ + it("wires both task start modes and exact checkpoint/fork anchors", () => { + expect(source).toContain('"最近用户消息"'); + expect(source).toContain('"下一条用户消息"'); + expect(source).toContain("sessionJourneyApi.startTask"); + expect(source).toContain("sessionJourneyApi.checkpoint"); + expect(source).toContain("sessionJourneyApi.startFork"); + expect(source).toContain("anchorMessageId: messageId"); + expect(source).toContain("messageId,"); + }); + it("wires finish, conflict reload and recovery without opening Workstation", () => { + expect(source).toContain("sessionJourneyApi.finishTask"); + expect(source).toContain("isRevisionConflict(reason)"); + expect(source).toContain("await reload()"); + expect(source).toContain("恢复会话旅程"); + expect(source).not.toContain("openFileInWorkStation"); + expect(source).not.toContain("chatPanelMaximizedAtom"); + }); + it("keeps review dock float hide reopen, explicit discard and same-anchor compare", () => { + expect(source).toContain('setMode("dock")'); + expect(source).toContain('mode === "dock" ? "float" : "dock"'); + expect(source).toContain('onMode("hidden")'); + expect(source).toContain("sessionJourneyApi.discard"); + expect(source).toContain("compareSameAnchorForks(snapshot)"); + }); +}); diff --git a/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx new file mode 100644 index 0000000000..3c8558822b --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx @@ -0,0 +1,539 @@ +import { + ChevronLeft, + Flag, + GitFork, + MapPin, + PanelLeft, + Play, + X, +} from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { + type JourneySnapshot, + type TaskOutcome, + sessionJourneyApi, +} from "@src/api/tauri/sessionJourney"; +import Button from "@src/components/Button"; +import Modal from "@src/scaffold/ModalSystem"; + +import { + REVIEW_PANEL_STORAGE_KEY, + type ReviewPanelMode, + activeTask, + compareSameAnchorForks, + hasRecoverableJourney, + isRevisionConflict, + visibleReviews, +} from "./sessionJourneyModel"; + +const outcomeOptions: Array<{ value: TaskOutcome; label: string }> = [ + { value: "completed", label: "完成" }, + { value: "partially_completed", label: "部分完成" }, + { value: "paused", label: "暂停" }, + { value: "abandoned", label: "放弃" }, + { value: "redirected", label: "转向" }, +]; + +const makeId = (prefix: string) => `${prefix}-${crypto.randomUUID()}`; + +export const SessionJourneyControls: React.FC<{ + sessionId: string | null; + messageId?: string | null; + onJumpToMessage?: (messageId: string) => void; +}> = ({ sessionId, messageId, onJumpToMessage }) => { + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const [dialog, setDialog] = useState< + "task" | "checkpoint" | "finish" | "fork" | null + >(null); + const [name, setName] = useState(""); + const [position, setPosition] = useState<"最近用户消息" | "下一条用户消息">( + "最近用户消息" + ); + const [outcome, setOutcome] = useState("completed"); + const [showRecovery, setShowRecovery] = useState(false); + const [panelMode, setPanelMode] = useState( + () => + (localStorage.getItem(REVIEW_PANEL_STORAGE_KEY) as ReviewPanelMode) || + "hidden" + ); + const reload = useCallback(async () => { + if (!sessionId) return setSnapshot(null); + try { + const response = await sessionJourneyApi.snapshot(sessionId); + setSnapshot(response.snapshot); + setError(null); + } catch (reason) { + setError(String(reason)); + } + }, [sessionId]); + useEffect(() => { + const timer = window.setTimeout(() => void reload(), 0); + return () => window.clearTimeout(timer); + }, [reload]); + useEffect(() => { + if ( + snapshot && + hasRecoverableJourney(snapshot) && + sessionStorage.getItem(`orgii-journey-recovery:${sessionId}`) !== "seen" + ) + queueMicrotask(() => setShowRecovery(true)); + }, [sessionId, snapshot]); + useEffect(() => { + if (panelMode !== "hidden") { + const timer = window.setInterval(() => void reload(), 5000); + return () => window.clearInterval(timer); + } + }, [panelMode, reload]); + const setMode = (mode: ReviewPanelMode) => { + localStorage.setItem(REVIEW_PANEL_STORAGE_KEY, mode); + setPanelMode(mode); + }; + const mutate = async (operation: () => Promise) => { + try { + await operation(); + setDialog(null); + setName(""); + await reload(); + } catch (reason) { + if (isRevisionConflict(reason)) await reload(); + setError(String(reason)); + } + }; + const task = activeTask(snapshot); + const reviews = visibleReviews(snapshot); + const revision = snapshot?.revision ?? 0; + const needsAnchor = !messageId; + const action = useMemo(() => { + if (!sessionId) return null; + if (dialog === "task") + return () => + sessionJourneyApi.startTask({ + sessionId, + expectedRevision: revision, + taskId: makeId("task"), + name, + position, + }); + if (dialog === "checkpoint" && messageId) + return () => + sessionJourneyApi.checkpoint({ + sessionId, + expectedRevision: revision, + checkpointId: makeId("checkpoint"), + name, + messageId, + }); + if (dialog === "finish" && messageId) + return () => + sessionJourneyApi.finishTask({ + sessionId, + expectedRevision: revision, + outcome, + messageId, + }); + if (dialog === "fork" && messageId) + return () => + sessionJourneyApi.startFork({ + sessionId, + expectedRevision: revision, + forkId: makeId("fork"), + taskId: makeId("task"), + taskName: name, + anchorMessageId: messageId, + }); + return null; + }, [dialog, messageId, name, outcome, position, revision, sessionId]); + if (!sessionId) return null; + return ( + <> +
+ {task ? ( + + + 任务:{task.name} + + ) : ( + + )} + + {task && ( + <> + + + + )} + +
+ {error && ( + + 旅程操作失败:{error} + + )} + setDialog(null)} + onName={setName} + onPosition={setPosition} + onOutcome={setOutcome} + onSubmit={() => action && void mutate(action)} + /> + + + +
+ } + onClose={() => { + sessionStorage.setItem(`orgii-journey-recovery:${sessionId}`, "seen"); + setShowRecovery(false); + }} + > +

+ 此会话有未结束的任务或分叉。你可以继续当前会话,或查看 Journey + 后再决定。 +

+ + {panelMode !== "hidden" && ( + + )} + + ); +}; + +const JourneyDialog: React.FC<{ + kind: "task" | "checkpoint" | "finish" | "fork" | null; + name: string; + position: "最近用户消息" | "下一条用户消息"; + outcome: TaskOutcome; + needsAnchor: boolean; + onClose: () => void; + onName: (value: string) => void; + onPosition: (value: "最近用户消息" | "下一条用户消息") => void; + onOutcome: (value: TaskOutcome) => void; + onSubmit: () => void; +}> = ({ + kind, + name, + position, + outcome, + needsAnchor, + onClose, + onName, + onPosition, + onOutcome, + onSubmit, +}) => ( + + {kind === "task" || kind === "checkpoint" || kind === "fork" ? ( + + ) : null} + {kind === "task" ? ( + + ) : null} + {kind === "finish" ? ( + + ) : null} + {kind !== "task" && needsAnchor ? ( +

+ 请先在消息列表中选择精确消息锚点。 +

+ ) : null} +
+); + +const ReviewPanel: React.FC<{ + mode: ReviewPanelMode; + reviews: ReturnType; + snapshot: JourneySnapshot | null; + sessionId: string; + onMode: (mode: ReviewPanelMode) => void; + onReload: () => Promise; + onJump?: (messageId: string) => void; +}> = ({ mode, reviews, snapshot, sessionId, onMode, onReload, onJump }) => { + const panelClass = + mode === "float" + ? "fixed right-5 top-20 z-50 w-80 shadow-lg" + : "fixed left-0 top-20 z-40 w-80 border-r"; + const mutate = async (operation: () => Promise) => { + await operation(); + await onReload(); + }; + return ( + + ); +}; diff --git a/src/modules/WorkStation/Chat/Journey/journeyMessageJump.test.ts b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.test.ts new file mode 100644 index 0000000000..3798adb3f6 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + listenForJourneyMessageJump, + requestJourneyMessageJump, +} from "./journeyMessageJump"; + +describe("Journey exact message jump", () => { + it("forwards the durable checkpoint message ID without a sequence fallback", () => { + let received: string | null = null; + let listener: ((event: Event) => void) | undefined; + const addEventListener = vi + .spyOn(window, "addEventListener") + .mockImplementation((_type, callback) => { + listener = callback as (event: Event) => void; + }); + const dispatchEvent = vi + .spyOn(window, "dispatchEvent") + .mockImplementation((event) => { + listener?.(event); + return true; + }); + const stop = listenForJourneyMessageJump((messageId) => { + received = messageId; + }); + requestJourneyMessageJump("message-exact-42"); + stop(); + addEventListener.mockRestore(); + dispatchEvent.mockRestore(); + expect(received).toBe("message-exact-42"); + }); +}); diff --git a/src/modules/WorkStation/Chat/Journey/journeyMessageJump.ts b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.ts new file mode 100644 index 0000000000..5a181d2c80 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.ts @@ -0,0 +1,19 @@ +const JOURNEY_MESSAGE_JUMP_EVENT = "orgii:journey-message-jump"; + +export function requestJourneyMessageJump(messageId: string): void { + if (messageId) + window.dispatchEvent( + new window.CustomEvent(JOURNEY_MESSAGE_JUMP_EVENT, { detail: messageId }) + ); +} + +export function listenForJourneyMessageJump( + onJump: (messageId: string) => void +): () => void { + const listener = (event: Event) => { + const messageId = (event as CustomEvent).detail; + if (typeof messageId === "string" && messageId) onJump(messageId); + }; + window.addEventListener(JOURNEY_MESSAGE_JUMP_EVENT, listener); + return () => window.removeEventListener(JOURNEY_MESSAGE_JUMP_EVENT, listener); +} diff --git a/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts new file mode 100644 index 0000000000..65741c7900 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import type { JourneySnapshot } from "@src/api/tauri/sessionJourney"; + +import { + activeTask, + compareSameAnchorForks, + hasRecoverableJourney, + isRevisionConflict, + visibleReviews, +} from "./sessionJourneyModel"; + +const snapshot = ( + overrides: Partial = {} +): JourneySnapshot => ({ + session_id: "s1", + revision: 4, + active_branch_id: "main", + active_task_id: "task-1", + tasks: { + "task-1": { + id: "task-1", + name: "核对锚点", + branch_id: "main", + state: "active", + start_sequence: 10, + finish_sequence: null, + outcome: null, + }, + }, + checkpoints: { + cp: { + id: "cp", + task_id: "task-1", + message_id: "message-10", + sequence: 10, + name: "证据", + }, + }, + branches: { + main: { + id: "main", + parent_branch_id: "main", + parent_anchor_message_id: null, + anchor_sequence: 0, + state: "active", + handoff_capsule: null, + }, + forkA: { + id: "forkA", + parent_branch_id: "main", + parent_anchor_message_id: "message-10", + anchor_sequence: 10, + state: "closing", + handoff_capsule: { + objective: "比较", + conclusion: "保留 A", + open_questions: ["待确认"], + confirmed_items: ["已验证"], + }, + }, + forkB: { + id: "forkB", + parent_branch_id: "main", + parent_anchor_message_id: "message-10", + anchor_sequence: 10, + state: "closed", + handoff_capsule: { + objective: "比较", + conclusion: "保留 B", + open_questions: [], + confirmed_items: [], + }, + }, + }, + reviews: { + queued: { + id: "queued", + fork_id: "forkA", + state: "queued", + annotation: null, + source_start_sequence: 10, + source_end_sequence: 13, + promoted_fact_ids: [], + }, + ready: { + id: "ready", + fork_id: "forkB", + state: "ready", + annotation: "可审核", + source_start_sequence: 10, + source_end_sequence: 13, + promoted_fact_ids: [], + }, + done: { + id: "done", + fork_id: "forkB", + state: "confirmed", + annotation: "已完成", + source_start_sequence: 10, + source_end_sequence: 13, + promoted_fact_ids: [], + }, + }, + ...overrides, +}); + +describe("session Journey UI model", () => { + it("renders the active task indicator and preserves start-mode data in the snapshot", () => { + expect(activeTask(snapshot())?.name).toBe("核对锚点"); + expect(activeTask(snapshot())?.start_sequence).toBe(10); + }); + + it("keeps checkpoint exact message identity for the jump action", () => { + expect(snapshot().checkpoints.cp.message_id).toBe("message-10"); + }); + + it("keeps task finish outcome as a typed durable value", () => { + const finished = snapshot({ + tasks: { + "task-1": { + ...snapshot().tasks["task-1"], + outcome: "partially_completed", + finish_sequence: 12, + }, + }, + }); + expect(activeTask(finished)?.outcome).toBe("partially_completed"); + }); + + it("shows queued, ready, and failed reviews without exposing discarded rows", () => { + expect(visibleReviews(snapshot()).map((review) => review.id)).toEqual([ + "queued", + "ready", + ]); + }); + + it("recognizes a recoverable task or fork once per opened session", () => { + expect(hasRecoverableJourney(snapshot())).toBe(true); + expect( + hasRecoverableJourney( + snapshot({ + active_task_id: null, + branches: { main: snapshot().branches.main }, + }) + ) + ).toBe(false); + }); + + it("compares only forks with the same exact anchor", () => { + const groups = compareSameAnchorForks(snapshot()); + expect(groups).toHaveLength(1); + expect(groups[0][1].map((fork) => fork.id)).toEqual(["forkA", "forkB"]); + }); + + it("refreshes a snapshot rather than treating a revision conflict as success", () => { + expect( + isRevisionConflict("会话旅程操作失败:会话旅程修订冲突:期望 2,当前 3。") + ).toBe(true); + expect(isRevisionConflict("网络错误")).toBe(false); + }); +}); diff --git a/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts new file mode 100644 index 0000000000..3cde7eb526 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts @@ -0,0 +1,54 @@ +import type { + JourneyReview, + JourneySnapshot, +} from "@src/api/tauri/sessionJourney"; + +export type ReviewPanelMode = "dock" | "float" | "hidden"; +export const REVIEW_PANEL_STORAGE_KEY = "orgii-session-journey-review-panel"; + +export function activeTask(snapshot: JourneySnapshot | null) { + return snapshot?.active_task_id + ? (snapshot.tasks[snapshot.active_task_id] ?? null) + : null; +} + +export function visibleReviews( + snapshot: JourneySnapshot | null +): JourneyReview[] { + return Object.values(snapshot?.reviews ?? {}).filter( + (review) => + review.state === "queued" || + review.state === "ready" || + review.state === "failed" + ); +} + +export function isRevisionConflict(error: unknown): boolean { + return String(error).includes("修订冲突"); +} + +export function hasRecoverableJourney( + snapshot: JourneySnapshot | null +): boolean { + if (!snapshot) return false; + return ( + Boolean(snapshot.active_task_id) || + Object.values(snapshot.branches).some( + (fork) => + fork.id !== fork.parent_branch_id && + (fork.state === "active" || fork.state === "closing") + ) + ); +} + +export function compareSameAnchorForks(snapshot: JourneySnapshot | null) { + const groups = new Map(); + for (const fork of Object.values(snapshot?.branches ?? {})) { + if (fork.id === fork.parent_branch_id) continue; + groups.set(fork.anchor_sequence, [ + ...(groups.get(fork.anchor_sequence) ?? []), + fork, + ]); + } + return [...groups.entries()].filter(([, forks]) => forks.length > 1); +} From 03b998ba641c98a1c15f91c4c72d554a07cd4298 Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Sat, 8 Aug 2026 10:32:10 +0800 Subject: [PATCH 16/32] fix(journey): enforce session hierarchy and atomic lifecycle (cherry picked from commit 4262031e6325c2118d0a1908c0dbae15100c8b23) --- .../session/journey_application_service.rs | 344 ++++++++++++++---- .../src/core/session/journey_lifecycle.rs | 3 +- src/api/tauri/sessionJourney/index.ts | 1 + .../JourneyGraph/SessionJourneyPage.tsx | 2 +- .../Chat/Journey/SessionJourneyControls.tsx | 42 ++- .../Chat/Journey/sessionJourneyModel.test.ts | 3 +- .../Chat/Journey/sessionJourneyModel.ts | 9 +- 7 files changed, 309 insertions(+), 95 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs b/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs index 8e845a445c..fa6daa3e91 100644 --- a/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs +++ b/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs @@ -6,7 +6,7 @@ use std::fmt; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use serde::{Deserialize, Serialize}; use crate::core::journey_lifecycle::{ @@ -164,6 +164,7 @@ pub struct JourneyWriteResponse { pub struct DiscardForkResponse { pub revision: u64, pub parent_branch_id: String, + pub parent_anchor_message_id: String, pub parent_anchor_sequence: u64, } @@ -283,19 +284,37 @@ impl SessionJourneyApplicationService { conn: &mut Connection, request: RetryReviewRequest, ) -> JourneyApplicationResult { - let response = Self::write( - conn, - &request.session_id, + Self::ensure_session(conn, &request.session_id)?; + SqliteJourneyRepository::ensure_schema(conn).map_err(Self::domain_error)?; + ReviewJobRepository::ensure_schema(conn) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let mut snapshot = Self::load_snapshot(&tx, &request.session_id)?; + if snapshot.revision != request.expected_revision { + return Err(JourneyApplicationError::修订冲突 { + expected: request.expected_revision, + actual: snapshot.revision, + }); + } + snapshot + .retry_review(snapshot.revision, &request.review_id) + .map_err(Self::domain_error)?; + SqliteJourneyRepository::compare_and_store_in_transaction( + &tx, + &snapshot, request.expected_revision, - |_conn, journey, revision| { - journey - .retry_review(revision, &request.review_id) - .map_err(Self::domain_error) - }, - )?; - ReviewJobRepository::retry(conn, &request.job_id) + ) + .map_err(Self::domain_error)?; + ReviewJobRepository::retry(&tx, &request.job_id) .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; - Ok(response) + tx.commit() + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + crate::core::session::journey_review_queue::notify_review_queue(); + Ok(JourneyWriteResponse { + revision: snapshot.revision, + }) } pub fn snapshot( conn: &Connection, @@ -459,16 +478,42 @@ impl SessionJourneyApplicationService { job_id: String, provenance: RuntimeProvenance, ) -> JourneyApplicationResult { - let response = Self::request_fork_close(conn, request.clone())?; - let snapshot = Self::snapshot(conn, &request.session_id)?.snapshot; + Self::ensure_session(conn, &request.session_id)?; + SqliteJourneyRepository::ensure_schema(conn).map_err(Self::domain_error)?; + ReviewJobRepository::ensure_schema(conn) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let anchor = Self::message_anchor(conn, &request.session_id, &request.message_id)?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let mut snapshot = Self::load_snapshot(&tx, &request.session_id)?; + if snapshot.revision != request.expected_revision { + return Err(JourneyApplicationError::修订冲突 { + expected: request.expected_revision, + actual: snapshot.revision, + }); + } + Self::validate_active_task_anchor(&tx, &request.session_id, &anchor, &snapshot)?; + snapshot + .request_fork_close( + snapshot.revision, + &request.fork_id, + request.review_id.clone(), + request.outcome, + anchor.sequence, + ) + .map_err(Self::domain_error)?; + SqliteJourneyRepository::compare_and_store_in_transaction( + &tx, + &snapshot, + request.expected_revision, + ) + .map_err(Self::domain_error)?; let job = - ReviewJobRepository::enqueue(conn, &snapshot, job_id, &request.review_id, &provenance) - .map_err(|error| { - JourneyApplicationError::存储失败(format!( - "{}(修订 {})", - error, response.revision - )) - })?; + ReviewJobRepository::enqueue(&tx, &snapshot, job_id, &request.review_id, &provenance) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + tx.commit() + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; crate::core::session::journey_review_queue::notify_review_queue(); Ok(job) } @@ -478,18 +523,7 @@ impl SessionJourneyApplicationService { session_id: &str, ) -> JourneyApplicationResult> { let snapshot = Self::snapshot(conn, session_id)?; - Ok(snapshot - .snapshot - .reviews - .values() - .filter(|item| { - matches!( - item.state, - crate::core::journey_lifecycle::ReviewState::Queued - ) - }) - .cloned() - .collect()) + Ok(snapshot.snapshot.reviews.into_values().collect()) } pub fn promote_confirmed_fact( @@ -503,34 +537,52 @@ impl SessionJourneyApplicationService { )?; let end = Self::message_anchor(conn, &request.session_id, &request.evidence_end_message_id)?; - let fact_id = request.fact_id.clone(); - let fact_text = request.text.clone(); - let response = Self::write( - conn, + Self::ensure_session(conn, &request.session_id)?; + SqliteJourneyRepository::ensure_schema(conn).map_err(Self::domain_error)?; + crate::core::session::journey_embedding::ensure_schema(conn) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let mut snapshot = Self::load_snapshot(&tx, &request.session_id)?; + if snapshot.revision != request.expected_revision { + return Err(JourneyApplicationError::修订冲突 { + expected: request.expected_revision, + actual: snapshot.revision, + }); + } + Self::validate_review_evidence( + &tx, &request.session_id, - request.expected_revision, - |_conn, journey, revision| { - journey - .promote_fact( - revision, - &request.review_id, - fact_id.clone(), - fact_text.clone(), - start.message_id.clone(), - start.sequence, - end.message_id.clone(), - end.sequence, - ) - .map_err(Self::domain_error) - }, + &snapshot, + &request.review_id, + &start, + &end, )?; - let snapshot = Self::load_snapshot(conn, &request.session_id)?; + snapshot + .promote_fact( + snapshot.revision, + &request.review_id, + request.fact_id.clone(), + request.text.clone(), + start.message_id.clone(), + start.sequence, + end.message_id.clone(), + end.sequence, + ) + .map_err(Self::domain_error)?; let review = snapshot .reviews .get(&request.review_id) .ok_or_else(|| JourneyApplicationError::校验失败("未知审阅项。".into()))?; + SqliteJourneyRepository::compare_and_store_in_transaction( + &tx, + &snapshot, + request.expected_revision, + ) + .map_err(Self::domain_error)?; crate::core::session::journey_embedding::enqueue( - conn, + &tx, &format!("fact:{}", request.fact_id), crate::core::session::journey_embedding::JourneyEmbeddingKind::ConfirmedFact, &request.session_id, @@ -541,7 +593,11 @@ impl SessionJourneyApplicationService { .map_err(|error| { JourneyApplicationError::存储失败(format!("无法排队事实 embedding:{error}")) })?; - Ok(response) + tx.commit() + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + Ok(JourneyWriteResponse { + revision: snapshot.revision, + }) } pub fn discard_fork( @@ -549,7 +605,10 @@ impl SessionJourneyApplicationService { request: DiscardForkRequest, ) -> JourneyApplicationResult { Self::ensure_session(conn, &request.session_id)?; - let mut snapshot = Self::load_snapshot(conn, &request.session_id)?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let mut snapshot = Self::load_snapshot(&tx, &request.session_id)?; if snapshot.revision != request.expected_revision { return Err(JourneyApplicationError::修订冲突 { expected: request.expected_revision, @@ -563,21 +622,29 @@ impl SessionJourneyApplicationService { JourneyApplicationError::校验失败(format!("未知分叉:{}。", review.fork_id)) })?; let parent_branch_id = branch.parent_branch_id.clone(); + let parent_anchor_message_id = + branch.parent_anchor_message_id.clone().ok_or_else(|| { + JourneyApplicationError::校验失败("分叉缺少精确父消息锚点。".into()) + })?; let parent_anchor_sequence = snapshot .discard_fork(snapshot.revision, &request.review_id) .map_err(Self::domain_error)?; - SqliteJourneyRepository::compare_and_store(conn, &snapshot, request.expected_revision) - .map_err(Self::domain_error)?; - crate::core::session::journey_embedding::discard_review_annotation( - conn, - &request.review_id, + SqliteJourneyRepository::compare_and_store_in_transaction( + &tx, + &snapshot, + request.expected_revision, ) - .map_err(|error| { - JourneyApplicationError::存储失败(format!("无法清理审核 embedding:{error}")) - })?; + .map_err(Self::domain_error)?; + crate::core::session::journey_embedding::discard_review_annotation(&tx, &request.review_id) + .map_err(|error| { + JourneyApplicationError::存储失败(format!("无法清理审核 embedding:{error}")) + })?; + tx.commit() + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; Ok(DiscardForkResponse { revision: snapshot.revision, parent_branch_id, + parent_anchor_message_id, parent_anchor_sequence, }) } @@ -624,7 +691,10 @@ impl SessionJourneyApplicationService { request: ReturnToParentRequest, ) -> JourneyApplicationResult { Self::ensure_session(conn, &request.session_id)?; - let mut snapshot = Self::load_snapshot(conn, &request.session_id)?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let mut snapshot = Self::load_snapshot(&tx, &request.session_id)?; if snapshot.revision != request.expected_revision { return Err(JourneyApplicationError::修订冲突 { expected: request.expected_revision, @@ -634,8 +704,14 @@ impl SessionJourneyApplicationService { let (parent_branch_id, parent_anchor_message_id, parent_anchor_sequence) = snapshot .return_to_parent(snapshot.revision, &request.review_id) .map_err(Self::domain_error)?; - SqliteJourneyRepository::compare_and_store(conn, &snapshot, request.expected_revision) - .map_err(Self::domain_error)?; + SqliteJourneyRepository::compare_and_store_in_transaction( + &tx, + &snapshot, + request.expected_revision, + ) + .map_err(Self::domain_error)?; + tx.commit() + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; Ok(ReturnToParentResponse { revision: snapshot.revision, parent_branch_id, @@ -743,10 +819,7 @@ impl SessionJourneyApplicationService { "SELECT branch_id FROM session_journey_memberships WHERE session_id = ?1 AND message_id = ?2", params![session_id, anchor.message_id], |row| row.get(0), ).optional().map_err(|_| JourneyApplicationError::存储失败("无法校验分叉锚点。".into()))?; - if membership - .as_deref() - .is_some_and(|branch| branch != branch_id) - { + if membership.as_deref() != Some(branch_id) { return Err(JourneyApplicationError::校验失败( "精确消息锚点不属于当前分叉。".into(), )); @@ -765,12 +838,14 @@ impl SessionJourneyApplicationService { "SELECT branch_id, task_id FROM session_journey_memberships WHERE session_id = ?1 AND message_id = ?2", params![session_id, anchor.message_id], |row| Ok((row.get(0)?, row.get(1)?)), ).optional().map_err(|_| JourneyApplicationError::存储失败("无法校验任务锚点。".into()))?; - if let Some((branch, task)) = membership { - if branch != branch_id || task.as_deref() != Some(task_id) { - return Err(JourneyApplicationError::校验失败( - "精确消息锚点不属于当前任务。".into(), - )); - } + let belongs_to_task = matches!( + membership.as_ref(), + Some((branch, task)) if branch == branch_id && task.as_deref() == Some(task_id) + ); + if !belongs_to_task { + return Err(JourneyApplicationError::校验失败( + "精确消息锚点不属于当前任务。".into(), + )); } Ok(()) } @@ -788,6 +863,46 @@ impl SessionJourneyApplicationService { Self::validate_task_anchor(conn, session_id, anchor, &journey.active_branch_id, task_id) } + fn validate_review_evidence( + conn: &Connection, + session_id: &str, + journey: &SessionJourney, + review_id: &str, + start: &MessageAnchor, + end: &MessageAnchor, + ) -> JourneyApplicationResult<()> { + let review = journey.reviews.get(review_id).ok_or_else(|| { + JourneyApplicationError::校验失败(format!("未知审阅项:{review_id}。")) + })?; + for anchor in [start, end] { + let membership: Option<(String, u64)> = conn + .query_row( + "SELECT branch_id, sequence FROM session_journey_memberships + WHERE session_id = ?1 AND message_id = ?2", + params![session_id, anchor.message_id], + |row| Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)), + ) + .optional() + .map_err(|_| { + JourneyApplicationError::存储失败("无法校验事实证据锚点。".into()) + })?; + let belongs_to_review = matches!( + membership.as_ref(), + Some((branch, sequence)) + if branch == &review.fork_id + && *sequence == anchor.sequence + && *sequence >= review.source_start_sequence + && *sequence <= review.source_end_sequence + ); + if !belongs_to_review { + return Err(JourneyApplicationError::校验失败( + "事实证据必须是审阅分叉范围内的精确成员消息。".into(), + )); + } + } + Ok(()) + } + fn domain_error(error: JourneyError) -> JourneyApplicationError { match error { JourneyError::RevisionConflict { expected, actual } => { @@ -852,9 +967,25 @@ mod tests { .unwrap(); conn.execute("INSERT INTO agent_messages (id, session_id, role, sequence) VALUES ('u1', 's', 'user', 4)", []).unwrap(); conn.execute("INSERT INTO agent_messages (id, session_id, role, sequence) VALUES ('a1', 's', 'assistant', 5)", []).unwrap(); + SqliteJourneyRepository::ensure_schema(&conn).unwrap(); + conn.execute( + "INSERT INTO session_journey_memberships (session_id, message_id, sequence, branch_id, task_id) + VALUES ('s', 'u1', 4, 'main', NULL), ('s', 'a1', 5, 'main', NULL)", + [], + ) + .unwrap(); conn } + fn assign_anchor(conn: &Connection, branch_id: &str, task_id: &str) { + conn.execute( + "UPDATE session_journey_memberships SET branch_id = ?1, task_id = ?2 + WHERE session_id = 's' AND message_id = 'a1'", + params![branch_id, task_id], + ) + .unwrap(); + } + fn fork_request(revision: u64) -> CreateForkRequest { CreateForkRequest { session_id: "s".into(), @@ -929,6 +1060,7 @@ mod tests { .on_user_message_persisted(current.revision, 6) .unwrap(); SqliteJourneyRepository::compare_and_store(&mut conn, ¤t, 1).unwrap(); + assign_anchor(&conn, "main", "next"); SessionJourneyApplicationService::create_checkpoint( &mut conn, CreateCheckpointRequest { @@ -949,6 +1081,7 @@ mod tests { fn fork_creates_active_task_and_close_only_queues_review() { let mut conn = conn(); SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + assign_anchor(&conn, "f1", "t1"); let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s").unwrap(); assert_eq!(snapshot.snapshot.active_task_id.as_deref(), Some("t1")); SessionJourneyApplicationService::request_fork_close( @@ -972,10 +1105,62 @@ mod tests { assert!(snapshot.snapshot.reviews["r1"].annotation.is_none()); } + #[test] + fn close_and_enqueue_roll_back_together_when_queue_rejects_provenance() { + let mut conn = conn(); + SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + assign_anchor(&conn, "f1", "t1"); + + let error = SessionJourneyApplicationService::request_fork_close_and_enqueue( + &mut conn, + RequestForkCloseRequest { + session_id: "s".into(), + expected_revision: 1, + fork_id: "f1".into(), + review_id: "r1".into(), + outcome: TaskOutcome::Completed, + message_id: "a1".into(), + }, + "job-r1".into(), + RuntimeProvenance { + model_id: "".into(), + account_id: "account".into(), + protocol: "openai".into(), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("provenance")); + + let reopened = SessionJourneyApplicationService::snapshot(&conn, "s").unwrap(); + assert_eq!(reopened.revision, 1); + assert_eq!(reopened.snapshot.active_task_id.as_deref(), Some("t1")); + assert!(reopened.snapshot.reviews.is_empty()); + assert!( + SessionJourneyApplicationService::get_review_job(&conn, "r1") + .unwrap() + .is_none() + ); + } + + #[test] + fn fork_anchor_without_durable_membership_fails_closed() { + let mut conn = conn(); + conn.execute( + "DELETE FROM session_journey_memberships WHERE session_id = 's' AND message_id = 'u1'", + [], + ) + .unwrap(); + assert!(matches!( + SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)), + Err(JourneyApplicationError::校验失败(_)) + )); + } + #[test] fn discard_returns_parent_anchor_and_promotion_requires_confirmable_review() { let mut conn = conn(); SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + assign_anchor(&conn, "f1", "t1"); SessionJourneyApplicationService::request_fork_close( &mut conn, RequestForkCloseRequest { @@ -1015,9 +1200,10 @@ mod tests { assert_eq!( ( discard.parent_branch_id.as_str(), + discard.parent_anchor_message_id.as_str(), discard.parent_anchor_sequence ), - ("main", 4) + ("main", "u1", 4) ); } @@ -1025,6 +1211,7 @@ mod tests { fn ready_review_can_be_explicitly_promoted_after_confirmation() { let mut conn = conn(); SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + assign_anchor(&conn, "f1", "t1"); SessionJourneyApplicationService::request_fork_close( &mut conn, RequestForkCloseRequest { @@ -1080,6 +1267,7 @@ mod tests { fn reviewed_capsule_preserves_provenance_and_returns_to_exact_parent_with_cas() { let mut conn = conn(); SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + assign_anchor(&conn, "f1", "t1"); SessionJourneyApplicationService::request_fork_close( &mut conn, RequestForkCloseRequest { diff --git a/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs b/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs index ad79b4b338..bcdd9a58e6 100644 --- a/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs +++ b/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs @@ -1320,8 +1320,9 @@ impl JourneyApplicationService { ) .map(|response| { format!( - "分叉已丢弃,父分叉 {} 的精确锚点为 sequence={}(修订 {})。", + "分叉已丢弃,父分叉 {} 的精确锚点为消息 {}(sequence={},修订 {})。", response.parent_branch_id, + response.parent_anchor_message_id, response.parent_anchor_sequence, response.revision ) diff --git a/src/api/tauri/sessionJourney/index.ts b/src/api/tauri/sessionJourney/index.ts index fa2bb45165..1267100af1 100644 --- a/src/api/tauri/sessionJourney/index.ts +++ b/src/api/tauri/sessionJourney/index.ts @@ -140,6 +140,7 @@ export const sessionJourneyApi = { invoke<{ revision: number; parent_branch_id: string; + parent_anchor_message_id: string; parent_anchor_sequence: number; }>("journey_discard", { request }), returnToParent: (request: ReturnToParentRequest) => diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx index 5bdda364ff..075b2f6e4d 100644 --- a/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx @@ -14,7 +14,7 @@ export const SessionJourneyPage: React.FC<{
diff --git a/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx index 3c8558822b..208122d2c6 100644 --- a/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx +++ b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx @@ -10,6 +10,7 @@ import { import React, { useCallback, useEffect, useMemo, useState } from "react"; import { + type JourneyReview, type JourneySnapshot, type TaskOutcome, sessionJourneyApi, @@ -419,7 +420,7 @@ const ReviewPanel: React.FC<{
{!reviews.length && ( -

当前没有待处理审核。

+

当前没有审核记录。

)} {reviews.map((review) => { const fork = snapshot?.branches[review.fork_id]; @@ -429,13 +430,7 @@ const ReviewPanel: React.FC<{ key={review.id} className="mb-2 border-t border-border-2 pt-2" > -
- {review.state === "ready" - ? "可审核" - : review.state === "failed" - ? "审核失败" - : "等待审核"} -
+
{reviewStateLabel(review.state)}

{capsule?.conclusion ?? review.annotation ?? @@ -534,6 +529,37 @@ const ReviewPanel: React.FC<{ ))} ))} + {Object.values(snapshot?.checkpoints ?? {}).map((checkpoint) => ( +

+ 检查点:{checkpoint.name} + +
+ ))} ); }; + +function reviewStateLabel(state: JourneyReview["state"]) { + switch (state) { + case "queued": + return "等待审核"; + case "ready": + return "可审核"; + case "confirmed": + return "已确认"; + case "discarded": + return "已丢弃"; + case "failed": + return "审核失败"; + } +} diff --git a/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts index 65741c7900..4383a09f01 100644 --- a/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts +++ b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts @@ -128,10 +128,11 @@ describe("session Journey UI model", () => { expect(activeTask(finished)?.outcome).toBe("partially_completed"); }); - it("shows queued, ready, and failed reviews without exposing discarded rows", () => { + it("keeps every review state visible as durable Journey history", () => { expect(visibleReviews(snapshot()).map((review) => review.id)).toEqual([ "queued", "ready", + "done", ]); }); diff --git a/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts index 3cde7eb526..ced2ed0a96 100644 --- a/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts +++ b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts @@ -15,12 +15,9 @@ export function activeTask(snapshot: JourneySnapshot | null) { export function visibleReviews( snapshot: JourneySnapshot | null ): JourneyReview[] { - return Object.values(snapshot?.reviews ?? {}).filter( - (review) => - review.state === "queued" || - review.state === "ready" || - review.state === "failed" - ); + // The Journey is an audit trail: confirmed and discarded reviews remain + // inspectable rather than disappearing from the Desktop surface. + return Object.values(snapshot?.reviews ?? {}); } export function isRevisionConflict(error: unknown): boolean { From c3b08cb5ac9b97b8f28cb4ef0c648b8cd8a0edbe Mon Sep 17 00:00:00 2001 From: chsimonpan Date: Sat, 8 Aug 2026 11:27:29 +0800 Subject: [PATCH 17/32] fix(journey): complete direct session hierarchy (cherry picked from commit bbf3af20e59c8fc78d10ec729c2c20d08f60a68a) --- src-tauri/crates/orgtrack-graph/src/audit.rs | 4 +- .../crates/orgtrack-graph/src/journey.rs | 37 +----- .../orgtrack-graph/src/journey_tests.rs | 16 +++ .../src/agent_sessions/journey_commands.rs | 28 ++++- src-tauri/src/commands/handler_list.inc | 1 + src/api/tauri/sessionJourney/index.test.ts | 25 ++++ src/api/tauri/sessionJourney/index.ts | 31 +++++ .../JourneyGraph/JourneyContainer.tsx | 15 ++- .../JourneyGraph/SessionJourneySnapshot.tsx | 29 ++++- .../ProjectJourney/ProjectJourneyPage.tsx | 5 +- .../ProjectJourney/ProjectTreePage.tsx | 18 +-- .../ProjectJourney/model/buildTree.test.ts | 33 +++++ .../ProjectJourney/model/buildTree.ts | 90 +++++--------- .../ProjectJourney/model/demoFixture.ts | 2 +- .../ProjectJourney/model/types.ts | 3 +- .../Chat/Journey/SessionJourneyControls.tsx | 115 ++++++++++++++++-- 16 files changed, 314 insertions(+), 138 deletions(-) create mode 100644 src/modules/ProjectManager/ProjectJourney/model/buildTree.test.ts diff --git a/src-tauri/crates/orgtrack-graph/src/audit.rs b/src-tauri/crates/orgtrack-graph/src/audit.rs index dd3ab2aa6d..a8c3f51761 100644 --- a/src-tauri/crates/orgtrack-graph/src/audit.rs +++ b/src-tauri/crates/orgtrack-graph/src/audit.rs @@ -22,7 +22,9 @@ pub fn audit_canonical_journey( .projects .iter() .map(|x| x.source_ref.as_str()) - .chain(input.work_items.iter().map(|x| x.source_ref.as_str())) + // Work items are independent metadata, rather than Journey containment + // units. Their source is validated by the projector but does not require + // a graph node or edge for Journey coverage. .chain(input.sessions.iter().map(|x| x.source_ref.as_str())) .chain(input.turns.iter().map(|x| x.source_ref.as_str())) .chain(input.artifacts.iter().map(|x| x.source_ref.as_str())) diff --git a/src-tauri/crates/orgtrack-graph/src/journey.rs b/src-tauri/crates/orgtrack-graph/src/journey.rs index 6d30bc5b75..90a7387fc6 100644 --- a/src-tauri/crates/orgtrack-graph/src/journey.rs +++ b/src-tauri/crates/orgtrack-graph/src/journey.rs @@ -229,30 +229,14 @@ pub fn project_canonical_journey(input: &CanonicalJourneyInput) -> Result = input.sessions.iter().map(|s| s.id.as_str()).collect(); for s in &input.sessions { @@ -260,11 +244,6 @@ pub fn project_canonical_journey(input: &CanonicalJourneyInput) -> Result Result Result { + tokio::task::spawn_blocking(move || { + let mut conn = open_connection()?; + SessionJourneyApplicationService::retry_review(&mut conn, request).map_err(service_error) + }) + .await + .map_err(|error| format!("会话旅程审核重试异常:{error}"))? +} + #[tauri::command] pub async fn journey_review_list( session_id: String, @@ -165,8 +179,8 @@ mod tests { use super::service_error; use agent_core::session::journey_application_service::{ CreateCheckpointRequest, CreateForkRequest, CreateTaskRequest, DiscardForkRequest, - FinishTaskRequest, PromoteFactRequest, RequestForkCloseRequest, ReturnToParentRequest, - TaskStartPosition, + FinishTaskRequest, PromoteFactRequest, RequestForkCloseRequest, RetryReviewRequest, + ReturnToParentRequest, TaskStartPosition, }; #[test] @@ -255,6 +269,13 @@ mod tests { review_id: "r".into(), }) .unwrap(), + serde_json::to_value(RetryReviewRequest { + session_id: "s".into(), + expected_revision: 1, + review_id: "r".into(), + job_id: "job-r".into(), + }) + .unwrap(), ]; for payload in payloads { let serialized = payload.to_string(); @@ -275,6 +296,7 @@ mod tests { "journey_task_finish", "journey_fork_start", "journey_fork_close", + "journey_review_retry", "journey_review_list", "journey_ready_draft", "journey_confirm", diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index b72f1dbe48..61f1a89f64 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -329,6 +329,7 @@ agent_sessions::journey_commands::journey_checkpoint, agent_sessions::journey_commands::journey_task_finish, agent_sessions::journey_commands::journey_fork_start, agent_sessions::journey_commands::journey_fork_close, +agent_sessions::journey_commands::journey_review_retry, agent_sessions::journey_commands::journey_review_list, agent_sessions::journey_commands::journey_ready_draft, agent_sessions::journey_commands::journey_confirm, diff --git a/src/api/tauri/sessionJourney/index.test.ts b/src/api/tauri/sessionJourney/index.test.ts index 04515e443d..29e896c5d7 100644 --- a/src/api/tauri/sessionJourney/index.test.ts +++ b/src/api/tauri/sessionJourney/index.test.ts @@ -53,4 +53,29 @@ describe("session Journey typed desktop API", () => { "journey_return_parent", ]); }); + + it("exposes typed fork close and review retry commands", async () => { + await sessionJourneyApi.closeFork( + { + sessionId: "s", + expectedRevision: 3, + forkId: "f", + reviewId: "r", + outcome: "completed", + messageId: "m", + }, + "job-r", + { modelId: "model", accountId: "account", protocol: "openai" } + ); + await sessionJourneyApi.retryReview({ + sessionId: "s", + expectedRevision: 4, + reviewId: "r", + jobId: "job-r", + }); + expect(invoke.mock.calls.map(([command]) => command)).toEqual([ + "journey_fork_close", + "journey_review_retry", + ]); + }); }); diff --git a/src/api/tauri/sessionJourney/index.ts b/src/api/tauri/sessionJourney/index.ts index 1267100af1..a86aded0ea 100644 --- a/src/api/tauri/sessionJourney/index.ts +++ b/src/api/tauri/sessionJourney/index.ts @@ -117,6 +117,25 @@ export interface ReturnToParentRequest { expectedRevision: number; reviewId: string; } +export interface ForkCloseRequest { + sessionId: string; + expectedRevision: number; + forkId: string; + reviewId: string; + outcome: TaskOutcome; + messageId: string; +} +export interface RuntimeProvenance { + modelId: string; + accountId: string; + protocol: string; +} +export interface RetryReviewRequest { + sessionId: string; + expectedRevision: number; + reviewId: string; + jobId: string; +} /** Typed desktop boundary. UI code must not invoke Journey command strings. */ export const sessionJourneyApi = { @@ -130,6 +149,18 @@ export const sessionJourneyApi = { invoke("journey_task_finish", { request }), startFork: (request: CreateForkRequest) => invoke("journey_fork_start", { request }), + closeFork: ( + request: ForkCloseRequest, + jobId: string, + provenance: RuntimeProvenance + ) => + invoke<{ job_id: string; state: string }>("journey_fork_close", { + request, + jobId, + provenance, + }), + retryReview: (request: RetryReviewRequest) => + invoke("journey_review_retry", { request }), reviews: (sessionId: string) => invoke("journey_review_list", { sessionId }), readyDraft: (sessionId: string, reviewId: string) => diff --git a/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx b/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx index 70996a322e..b7e7da56c4 100644 --- a/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx +++ b/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx @@ -33,10 +33,10 @@ interface JourneyResult { } const journeyTabs = [ - { key: "storyline", label: "Storyline" }, - { key: "branches", label: "Branches" }, - { key: "files", label: "File Lineage" }, - { key: "coverage", label: "Coverage" }, + { key: "storyline", label: "时间线" }, + { key: "branches", label: "分叉" }, + { key: "files", label: "文件血缘" }, + { key: "coverage", label: "覆盖情况" }, ]; function JourneyContent({ @@ -94,7 +94,6 @@ export const JourneyContainer: React.FC = ({ const currentResult = result?.scope === scope ? result : null; const graph = currentResult?.graph ?? null; const error = currentResult?.error ?? null; - return (
= ({
{error && (
- Journey unavailable: {error} + 旅程不可用:{error}
)} {!error && !graph && ( -
Loading Journey...
+
正在加载旅程...
)} {graph && (
diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx index 4f108d1c51..c73a6aa041 100644 --- a/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx @@ -7,6 +7,27 @@ import { } from "@src/api/tauri/sessionJourney"; import { requestJourneyMessageJump } from "@src/modules/WorkStation/Chat/Journey/journeyMessageJump"; +const taskStateLabel = (state: string) => + ({ active: "进行中", finished: "已结束", pending: "待开始" })[state] ?? state; +const outcomeLabel = (outcome: string) => + ({ + completed: "完成", + partially_completed: "部分完成", + paused: "暂停", + abandoned: "放弃", + redirected: "转向", + })[outcome] ?? outcome; +const forkStateLabel = (state: string) => + ({ active: "进行中", closing: "审核中", closed: "已关闭" })[state] ?? state; +const reviewStateLabel = (state: string) => + ({ + queued: "等待审核", + ready: "可审核", + confirmed: "已确认", + discarded: "已丢弃", + failed: "审核失败", + })[state] ?? state; + export const SessionJourneySnapshot: React.FC<{ sessionId: string }> = ({ sessionId, }) => { @@ -46,8 +67,8 @@ export const SessionJourneySnapshot: React.FC<{ sessionId: string }> = ({ {task.name}

- {task.state} - {task.outcome ? ` · ${task.outcome}` : ""} + {taskStateLabel(task.state)} + {task.outcome ? ` · ${outcomeLabel(task.outcome)}` : ""}

))} @@ -58,7 +79,7 @@ export const SessionJourneySnapshot: React.FC<{ sessionId: string }> = ({ {fork.id}

- 锚点 {fork.anchor_sequence} · {fork.state} + 锚点 {fork.anchor_sequence} · {forkStateLabel(fork.state)}

))} @@ -81,7 +102,7 @@ export const SessionJourneySnapshot: React.FC<{ sessionId: string }> = ({ 审核

- {review.state} · {review.fork_id} + {reviewStateLabel(review.state)} · {review.fork_id}

))} diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx index 03f708a368..99874c3e97 100644 --- a/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx +++ b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx @@ -20,14 +20,13 @@ const ProjectJourneyPage: React.FC = ({ if (!identity) return (
- Journey unavailable: Project identity is required; refusing to guess a - Journey graph. + 项目旅程不可用:缺少项目标识,拒绝猜测旅程图。
); return ( ); }; diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx index 4135aa59e9..36fcc61ad7 100644 --- a/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx +++ b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx @@ -145,10 +145,10 @@ const ProjectTreePage: React.FC = ({
-
Project Tree
+
项目树
- Workspace → Project → Work Item → Session / Task - {usedDemo ? " · demo data" : ""} + 工作区 → 项目 → 会话 → 旅程 + {usedDemo ? " · 演示数据" : ""}
@@ -171,7 +171,7 @@ const ProjectTreePage: React.FC = ({ setFilter(e.target.value)} - placeholder="Filter nodes…" + placeholder="筛选节点…" className="w-full rounded-md border border-border-2 bg-fill-1 px-2 py-1.5 text-xs text-text-1 outline-none focus:border-primary-6" data-testid="project-tree-filter" /> @@ -179,13 +179,13 @@ const ProjectTreePage: React.FC = ({ {error && (
- Load warning: {error} (showing available/demo data) + 加载提示:{error}(显示可用数据)
)}
{loading && ( -
Loading tree…
+
正在加载项目树…
)} {!loading && rows.map(({ node, depth }) => { @@ -236,7 +236,7 @@ const ProjectTreePage: React.FC = ({ ) } > - Journey + 旅程 )} {node.kind === "work_item" && onOpenWorkItem && ( diff --git a/src/modules/ProjectManager/ProjectJourney/model/buildTree.test.ts b/src/modules/ProjectManager/ProjectJourney/model/buildTree.test.ts new file mode 100644 index 0000000000..e771b6fb12 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/buildTree.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { buildWorkspaceProjectTree } from "./buildTree"; + +describe("Project Journey tree", () => { + it("contains sessions directly under projects and retains work item only as metadata", () => { + const tree = buildWorkspaceProjectTree({ + projects: [{ id: "p", name: "项目" }], + workItemsByProject: { + p: [ + { + session_id: "w", + name: "工作项 A", + linkedSessions: [{ session_id: "s", agent_role: "实现" }], + }, + ], + }, + }); + + const [project] = tree.children; + expect(project.kind).toBe("project"); + expect(project.children).toHaveLength(1); + expect(project.children[0]).toMatchObject({ + kind: "session", + sessionId: "s", + workItemId: "w", + meta: { workItemName: "工作项 A" }, + }); + expect(project.children.some((node) => node.kind === "work_item")).toBe( + false + ); + }); +}); diff --git a/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts b/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts index cc26d87eb4..355254ea4f 100644 --- a/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts +++ b/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts @@ -1,5 +1,6 @@ /** - * Build Workspace → Project → WorkItem → Session/Todo tree. + * Build Workspace → Project → Session tree. Work items annotate sessions but + * never become containment parents. */ import type { ProjectLike, ProjectTreeNode, WorkItemLike } from "./types"; @@ -17,67 +18,33 @@ function sessionStatus(raw?: string): string | undefined { return raw?.toLowerCase(); } -function mapWorkItemNode( +function mapSessionNode( wi: WorkItemLike, projectId?: string, - projectSlug?: string, - includeTodos = true -): ProjectTreeNode { - const children: ProjectTreeNode[] = []; - - if (includeTodos) { - for (const todo of wi.todos ?? []) { - children.push({ - id: `todo:${wi.session_id}:${todo.id}`, - kind: "todo", - title: todo.content || todo.id, - status: todo.status, - projectId, - projectSlug, - workItemId: wi.session_id, - children: [], - meta: { todoId: todo.id }, - }); - } - } - - for (const ls of wi.linkedSessions ?? []) { - children.push({ - id: `session:${ls.session_id}`, - kind: "session", - title: - ls.sub_agent_name || - ls.agent_role || - ls.session_id.slice(0, 8) || - "session", - status: sessionStatus(ls.status), - projectId, - projectSlug, - workItemId: wi.session_id, - sessionId: ls.session_id, - children: [], - meta: { - parentSessionId: ls.parent_session_id ?? null, - sessionType: ls.session_type, - agentRole: ls.agent_role, - }, - }); - } - - return { - id: `work_item:${wi.session_id}`, - kind: "work_item", - title: wi.name || wi.session_id, - status: wi.workItemStatus ?? wi.status, + projectSlug?: string +): ProjectTreeNode[] { + return (wi.linkedSessions ?? []).map((ls) => ({ + id: `session:${ls.session_id}`, + kind: "session", + title: + ls.sub_agent_name || + ls.agent_role || + ls.session_id.slice(0, 8) || + "session", + status: sessionStatus(ls.status), projectId, projectSlug, workItemId: wi.session_id, - children, + sessionId: ls.session_id, + children: [], meta: { - linkedSessionCount: wi.linkedSessions?.length ?? 0, - todoCount: wi.todos?.length ?? 0, + parentSessionId: ls.parent_session_id ?? null, + sessionType: ls.session_type, + agentRole: ls.agent_role, + // This is metadata only. The parent in the tree is always Project. + workItemName: wi.name || wi.session_id, }, - }; + })); } function resolveWorkItems( @@ -96,9 +63,11 @@ function resolveWorkItems( export function buildWorkspaceProjectTree( input: BuildTreeInput ): ProjectTreeNode { - const includeTodos = input.includeTodos !== false; const projectNodes: ProjectTreeNode[] = input.projects.map((project) => { const items = resolveWorkItems(project, input.workItemsByProject); + const sessions = items.flatMap((wi) => + mapSessionNode(wi, project.id, project.slug) + ); return { id: `project:${project.id}`, kind: "project" as const, @@ -106,11 +75,10 @@ export function buildWorkspaceProjectTree( status: project.status, projectId: project.id, projectSlug: project.slug, - children: items.map((wi) => - mapWorkItemNode(wi, project.id, project.slug, includeTodos) - ), + children: sessions, meta: { workItemCount: items.length, + sessionCount: sessions.length, slug: project.slug, }, }; @@ -122,9 +90,7 @@ export function buildWorkspaceProjectTree( id: "bucket:unassigned", kind: "unassigned", title: "Unassigned", - children: standalone.map((wi) => - mapWorkItemNode(wi, undefined, undefined, includeTodos) - ), + children: standalone.flatMap((wi) => mapSessionNode(wi)), meta: { workItemCount: standalone.length }, }); } diff --git a/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts b/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts index a7834a24bb..f35c30f4b7 100644 --- a/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts +++ b/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts @@ -17,7 +17,7 @@ export const DEMO_WORK_ITEMS: WorkItemLike[] = [ name: "四级树可感知化", status: "completed", workItemStatus: "completed", - spec: "Workspace→Project→WorkItem→Session 树只读聚合", + spec: "工作区→项目→会话→旅程树只读聚合(工作项仅元数据)", created_time: "2026-07-28T10:00:00.000Z", updated_time: "2026-07-28T18:00:00.000Z", todos: [ diff --git a/src/modules/ProjectManager/ProjectJourney/model/types.ts b/src/modules/ProjectManager/ProjectJourney/model/types.ts index 922640c096..a801a81032 100644 --- a/src/modules/ProjectManager/ProjectJourney/model/types.ts +++ b/src/modules/ProjectManager/ProjectJourney/model/types.ts @@ -2,7 +2,8 @@ * Project Journey / Tree — shared types * * Narrative layer only. Does NOT own project/session membership truth. - * Truth remains: Project → WorkItem → linkedSessions (+ todos as fine tasks). + * Truth remains: Project → Session → Journey. Work items are optional + * metadata associated with sessions, never a containment parent. */ export type TreeNodeKind = diff --git a/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx index 208122d2c6..898e6b4649 100644 --- a/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx +++ b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx @@ -41,12 +41,18 @@ const makeId = (prefix: string) => `${prefix}-${crypto.randomUUID()}`; export const SessionJourneyControls: React.FC<{ sessionId: string | null; messageId?: string | null; + /** Runtime identity is required to close a fork and enqueue its review. */ + forkCloseProvenance?: { + modelId: string; + accountId: string; + protocol: string; + } | null; onJumpToMessage?: (messageId: string) => void; -}> = ({ sessionId, messageId, onJumpToMessage }) => { +}> = ({ sessionId, messageId, forkCloseProvenance, onJumpToMessage }) => { const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const [dialog, setDialog] = useState< - "task" | "checkpoint" | "finish" | "fork" | null + "task" | "checkpoint" | "finish" | "fork" | "closeFork" | null >(null); const [name, setName] = useState(""); const [position, setPosition] = useState<"最近用户消息" | "下一条用户消息">( @@ -134,6 +140,20 @@ export const SessionJourneyControls: React.FC<{ outcome, messageId, }); + if (dialog === "closeFork" && messageId && forkCloseProvenance) + return () => + sessionJourneyApi.closeFork( + { + sessionId, + expectedRevision: revision, + forkId: snapshot?.active_branch_id ?? "", + reviewId: makeId("review"), + outcome, + messageId, + }, + makeId("review-job"), + forkCloseProvenance + ); if (dialog === "fork" && messageId) return () => sessionJourneyApi.startFork({ @@ -145,7 +165,17 @@ export const SessionJourneyControls: React.FC<{ anchorMessageId: messageId, }); return null; - }, [dialog, messageId, name, outcome, position, revision, sessionId]); + }, [ + dialog, + forkCloseProvenance, + messageId, + name, + outcome, + position, + revision, + sessionId, + snapshot?.active_branch_id, + ]); if (!sessionId) return null; return ( <> @@ -200,6 +230,22 @@ export const SessionJourneyControls: React.FC<{ > 结束 + {snapshot?.active_branch_id !== "main" && ( + + )} )}