diff --git a/docs/architecture-audit-2026-08-17/SessionJourneyRecovery.md b/docs/architecture-audit-2026-08-17/SessionJourneyRecovery.md new file mode 100644 index 0000000000..5c3c3b41ad --- /dev/null +++ b/docs/architecture-audit-2026-08-17/SessionJourneyRecovery.md @@ -0,0 +1,49 @@ +# Architecture audit — PR 770 Journey recovery + +**Scope:** recovery of the contributor Journey feature onto current `develop`, covering canonical Project/Session projection, the Session Journey lifecycle, review handoff, persistence membership, and exact transcript navigation. + +## Acceptance criteria + +- Projects contain canonical sessions directly; Work Items are optional metadata and never own sessions. +- Journey tasks, forks, checkpoints, reviews, and message memberships use explicit persisted identifiers and sequence anchors rather than titles, timestamps, or UI inference. +- Lifecycle mutations are CAS-protected, fail closed on missing provenance or anchors, and keep review publication separate from parent return. +- Provider history is filtered at the persistence boundary before reconstruction and receives only the active branch plus exact ancestor prefixes and confirmed handoff capsules. +- Existing sessions without Journey membership retain their legacy transcript, while partially Journey-tagged sessions deny unknown or mismatched rows. +- Current tab, history, persistence, and Agent Org inbox architecture remains authoritative; obsolete PR 770 shell/routes and unrelated provider/key-vault/search changes remain excluded. + +## Entry-point and ownership trace + +| Boundary | Owner | Contract | +| ------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Project tree | session aggregate + explicit project metadata | Project → Session is canonical; Work Item association annotates but does not re-parent a session | +| Journey state | `SessionJourney` + `SqliteJourneyRepository` | One revisioned aggregate per session; every mutation supplies the expected revision | +| Message membership | session persistence transaction | Durable message, sequence, branch, and task membership are committed atomically | +| Review execution | durable review queue/outbox | Frozen source range and runtime provenance are claimed by one worker; failures remain retryable | +| Provider prompt | Journey visibility projector | Rows are filtered before provider reconstruction; confirmed capsules are append-only context | +| UI navigation | workstation tab target + ChatHistory exact target | Task/fork/checkpoint routes carry durable message IDs through page selection and scroll/highlight | + +## Ten-layer review + +| Layer | Coverage | Verdict | +| -------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation and boundaries | Rust crates, Tauri commands, TypeScript API and component props | Pass. `pnpm typecheck`, focused ESLint, `cargo check -p org2`, and package tests compile the recovered cross-layer contract. | +| 2. Types, dead code, and deduplication | Journey aggregate, request/response DTOs, current history/tab architecture | Pass. One canonical lifecycle/application-service path and one Tauri API surface remain. The unreachable pre-application-service implementation and its private formatting/lookup helpers were removed; obsolete Journey Station routes, duplicated gateway code, and mixed contributor commits were not recovered. | +| 3. Dependency direction | orgtrack projection, agent-core persistence/application service, Tauri, React | Pass. Projection depends on canonical session/project inputs; UI invokes typed Tauri commands and does not own lifecycle invariants. Persistence filters provider history before reconstruction. | +| 4. Terminology and semantic overload | Project, Session, Work Item, Task, Fork, Checkpoint, Review | Pass. Work Item is metadata only; a Journey Task is not overloaded as a Work Item. Exact anchors are durable message IDs plus sequences, never display labels. | +| 5. Defaults and fail-closed behavior | legacy history, direct fork, strict lifecycle anchors, unavailable Journey state | Pass. Legacy sessions with no memberships retain history. Once memberships exist, unknown/mismatched rows are denied. Direct Fork may resolve only the latest durable user row on the active branch; checkpoint, finish, and close require an explicit durable anchor. | +| 6. Variant leakage | provider/runtime variants and UI render variants | Pass with scope note. Runtime provenance is stored as typed metadata; provider-wide review output-limit changes from the contributor branch were excluded, so the existing 1,024-token side-query budget remains. | +| 7. Control flow and FSM | task/fork/review transitions, CAS, queue recovery | Pass. State transitions are explicit, revisioned, and tested for conflicts, failure recovery, single-consumer claims, close/review/confirm/discard/return ordering, and exact parent anchors. | +| 8. Wire and persistence protocol | SQLite tables, serialized Journey snapshots, Tauri payloads | Pass. Tables are additive; message membership is written in the owning transaction; snake_case Rust DTOs are mapped by the typed frontend adapter. Invalid JSON and missing provenance fail closed. | +| 9. Initialization parity | normal startup, existing database, read-only load, queue reconciliation | Pass. Persistence initialization installs the additive Journey schema, Tauri handlers are registered in the current application shell, read-only loads do not create schema, and orphaned running review jobs are reconciled on startup. | +| 10. Resolver symmetry | project/session ownership, branch visibility, exact navigation | Pass. Project ownership uses explicit persisted project fields and linked workspaces; branch visibility uses membership and lineage anchors; navigation carries the same durable target through tree, tab, pagination, scroll, and highlight. No timestamp/string inference path was retained. | + +## Recovery integrity + +- Contributor commits were cherry-picked with provenance footers and resolved onto current `develop`; commits whose Journey changes were already represented were skipped rather than replayed as duplicates. +- Unrelated provider, key-vault, search, release, and ProgressMindMap changes were excluded. +- Current idempotent Agent Org inbox materialization, modular ChatHistory components, session header actions, and workstation tab architecture were preserved. +- The persistence regression test now stores the published capsule at revision 3 before returning to the parent at revision 4, matching production CAS sequencing. + +## Result + +Architecture verdict: **pass for draft review**. The recovered feature has one explicit ownership path from persisted domain state to UI navigation. Remaining release gates are rendered Tauri coverage and real-process performance measurements, recorded separately in the performance report and PR risks. diff --git a/docs/frontend-ui-audit-2026-08-17/ProjectTreeAndSessionJourney.md b/docs/frontend-ui-audit-2026-08-17/ProjectTreeAndSessionJourney.md new file mode 100644 index 0000000000..1e247847b0 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-17/ProjectTreeAndSessionJourney.md @@ -0,0 +1,24 @@ +# Project Tree and Session Journey UI audit + +The routed `frontend-ui-audit` skill was unavailable at both documented locations, so this report records the equivalent manual review over the recovered UI surfaces. + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------------------------- | ---------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `ProjectTreePage.tsx:53` | Tree node icon vocabulary | keep with reason | Lucide icons use the repository theme tokens and consistently distinguish project, session, task, fork, checkpoint, and unavailable ownership states. | None. | +| `ProjectTreePage.tsx:263` | Project tree header and actions | keep with reason | The compact bordered header matches existing file/tree surfaces; its native actions have explicit button semantics and preserve the dense 24px tree-toolbar geometry. | Consider migrating toolbar actions in a future tree-control sweep if the shared Button gains a matching compact tree variant. | +| `ProjectTreePage.tsx:288` | Tree filter | keep with reason | The input has an explicit placeholder, controlled value, focus indication, theme-token colors, and full-width tree-panel geometry. | None for this recovery; include it in any future shared tree-filter abstraction. | +| `ProjectTreePage.tsx:304` | Loading, partial error, and unavailable states | keep with reason | Loading is inline and non-blocking; partial failures retain canonical sessions and visibly label Journey enrichment as unavailable rather than hiding rows. | None. | +| `ProjectTreePage.tsx:313` | Expandable tree row | keep with reason | Native disclosure buttons retain semantic controls, accessible labels, truncation, exact indentation, and theme-token hover states without introducing a second generic tree primitive. | None. | +| `SessionJourneyControls.tsx:286` | Session lifecycle toolbar | keep with reason | Reuses the shared Button component with canonical `small`/`ghost` appearance, disables strict actions without a durable anchor, and exposes recovery/review actions in the current session header. | None. | +| `SessionJourneyControls.tsx:364` | Operation error and recovery modal | keep with reason | Errors use `role="alert"`; the existing Modal and Button system is reused; both recovery choices are explicit and persist only session-scoped acknowledgement. | None. | +| `SessionJourneySnapshot.tsx:95` | Task/fork/checkpoint/review snapshot cards | keep with reason | Cards use repository semantic color tokens, selected-state contrast, responsive columns, and buttons only where an exact navigation action exists. | None. | +| `SessionJourneySnapshot.tsx:142` | Exact checkpoint navigation | keep with reason | Checkpoints are keyboard-operable buttons whose copy names the exact message sequence, avoiding ambiguous card-level click behavior. | None. | +| `ChatHistoryList.tsx` / `PinnedTurnHeader.tsx` | Exact transcript target | keep with reason | The current paginated history architecture changes page before scrolling and applies a visible target treatment without replacing global message identity. | None. | + +## Verdict totals + +- Fix: 0 +- Keep with reason: 10 +- Abstract: 0 + +No multi-file design-system sweep candidate is required for this recovery. The raw compact tree controls should be considered together with the repository's other file/tree surfaces if a shared tree-toolbar primitive is introduced later. diff --git a/docs/org2-performance-guard-2026-08-17/SessionJourneyRecovery.md b/docs/org2-performance-guard-2026-08-17/SessionJourneyRecovery.md new file mode 100644 index 0000000000..b37b5e354c --- /dev/null +++ b/docs/org2-performance-guard-2026-08-17/SessionJourneyRecovery.md @@ -0,0 +1,23 @@ +# Session Journey recovery — performance guard + +| Area | Verdict | Evidence | Change or reason kept | Verification | +| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Session review polling | fix | The contributor implementation used a fixed 5-second interval while the review panel was open, including hidden documents, and permitted overlapping requests | Replaced it with one visibility-aware recursive timeout. Hidden documents cancel the timer; visibility return triggers one refresh; a running request must settle before the next timer is scheduled; cleanup removes listener/timer | Scoped ESLint and TypeScript pass; rendered control contracts pass 8/8 | +| Stale async writes | fix | Session changes/unmounts could allow old snapshot/comparison promises to commit into a newer surface | Added request-generation invalidation to Session Journey controls and snapshot; cleanup invalidates outstanding work, and results commit only for the current generation | Snapshot contract proves stale session responses cannot overwrite the current session; Journey controls compile and pass rendered contracts | +| Project tree enrichment | fix | Per-session Journey results rebuilt the complete visible tree once per resolved request | Canonical project/session tree renders first; Journey loads use concurrency 6; resolved results update a Map and coalesce full tree rebuilds into one 16ms timer; generation cleanup prevents updates after reload/unmount | Loader tests prove maximum observed concurrency is 6 and streaming delivery; project-tree rendered contract and build-tree tests pass | +| Retained frontend state | keep | Component-local Maps contain at most one Journey state per loaded canonical session and are discarded with the Project Tree component | No app-lifetime cache or unbounded event history was added; refs are replaced on reload and the only scheduled render timer is cleared on cleanup | Source inspection plus unmount/generation guards; no heap measurement claimed | +| Review queue worker | keep | Durable jobs are claimed transactionally by one consumer; notifications coalesce; provider failure does not kill the executor; shutdown stops the task; startup recovers stale running jobs | Keep the durable queue/outbox design because it prevents parallel claims and survives restart without frontend retries | Agent-core Journey suite passes queue idempotency, concurrent claim, coalescing, failure recovery, startup reconciliation, and shutdown tests | +| Provider history projection | keep | Visibility filtering runs at SQLite read/projection time using exact branch memberships and ancestor cutoffs, before provider reconstruction | Keep source-boundary filtering to prevent loading sibling/parent-future transcript into provider context | Journey and persistence suites cover legacy history, mismatched membership denial, nested prefixes, sibling exclusion, and confirmed capsules | + +## Lifecycle matrix + +| Lifecycle state | Session review panel | Project tree enrichment | Snapshot surface | Review queue | +| ------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------ | +| Visible/active | Single-flight refresh every 5 seconds while panel is open | Canonical tree first; six loaders maximum; 16ms render coalescing | One generation-guarded request per session selection | One executor; transactional single-consumer claim | +| Visible/idle | Same bounded cadence; no overlap | Stops after each session resolves; no recurring timer | No recurring work | Sleeps until notification/recovery wake | +| Hidden | Timer cancelled; no polling | No recurring work after initial load | No recurring work | Durable background work may continue only for queued reviews | +| Re-open / session switch | One immediate visibility refresh; old generations ignored | New generation replaces Map; old callbacks ignored | Old response ignored | Existing durable state is reloaded/reconciled | +| Unmount / shutdown | Listener and timeout removed; requests invalidated | Coalescing timer cancelled; generation invalidated | Request generation invalidated | Executor shutdown test proves task exits | +| Repeated open/close | At most one listener and timer per mounted controls instance | Component-local state is recreated and released | No retained cache | Queue remains idempotent by job/review identity | + +Performance verdict: **blocked for release measurement, acceptable for draft review**. Code-level lifecycle invariants and focused tests pass, but real Tauri process CPU/RSS, a hidden-window idle observation, and a high-session-count rendered profile were not measured. No runtime performance improvement is claimed from typecheck or code shape alone. 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-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/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..9800ebe742 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_application_service.rs @@ -0,0 +1,1646 @@ +//! 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, TransactionBehavior}; +use serde::{Deserialize, Serialize}; + +use crate::core::journey_lifecycle::{ + FactPromotion, 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, + /// A strict durable user-message id. `None` is allowed only for the + /// desktop direct-Fork command, whose server-side semantic is to resolve + /// the active branch's latest durable user message. + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor_message_id: Option, +} + +#[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, +} + +#[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, +} + +/// Provider-neutral fork comparison. Every adapter receives the same durable +/// fields; presentation layers only translate/render this response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ForkCompareResponse { + pub groups: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ForkCompareGroup { + pub parent_branch_id: String, + pub parent_anchor_message_id: Option, + pub anchor_sequence: u64, + pub forks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ForkCompareItem { + pub branch_id: String, + pub branch_name: String, + pub state: crate::core::journey_lifecycle::ForkState, + /// Every task on this branch, retained in durable creation order by id. + /// Consumers must not infer task names/outcomes from a rendered transcript. + pub tasks: Vec, + /// Compatibility summary for compact clients. The complete source is + /// `tasks`; this is the final task outcome when one exists. + pub task_outcome: Option, + pub conclusion: Option, + pub unresolved: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ForkCompareTask { + pub task_id: String, + pub name: String, + pub state: crate::core::journey_lifecycle::TaskState, + pub outcome: Option, +} + +#[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_message_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 { + Self::ensure_session(conn, &request.session_id)?; + SqliteJourneyRepository::ensure_schema(conn) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + 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, + ) + .map_err(Self::domain_error)?; + let job = ReviewJobRepository::get(&tx, &request.review_id) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))? + .filter(|job| job.session_id == request.session_id) + .ok_or_else(|| JourneyApplicationError::校验失败("审核任务不存在。".into()))?; + ReviewJobRepository::retry(&tx, &job.job_id) + .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(JourneyWriteResponse { + revision: snapshot.revision, + }) + } + 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 { + Self::ensure_session(conn, &request.session_id)?; + // Direct Fork must resolve the latest durable user row and validate its + // active-branch membership under one write transaction. This is not a + // generic fallback path for other lifecycle operations. + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + let mut journey = Self::load_snapshot(&tx, &request.session_id)?; + if journey.revision != request.expected_revision { + return Err(JourneyApplicationError::修订冲突 { + expected: request.expected_revision, + actual: journey.revision, + }); + } + let anchor = match request.anchor_message_id.as_deref() { + Some(message_id) => { + // An explicitly supplied id stays exact; an empty/malformed + // value is not allowed to silently become a direct Fork. + Self::message_anchor(&tx, &request.session_id, message_id)? + } + // This is intentionally a dedicated direct-Fork command semantic, + // not a generic anchor fallback. The query and active-branch + // membership check run in this one transaction, so an unrelated or + // stale row cannot anchor a fork. + None => Self::latest_active_branch_user_anchor( + &tx, + &request.session_id, + &journey.active_branch_id, + )?, + }; + Self::validate_branch_anchor(&tx, &request.session_id, &anchor, &journey.active_branch_id)?; + journey + .start_fork( + journey.revision, + request.fork_id, + request.task_id, + request.task_name, + anchor.message_id.clone(), + anchor.sequence, + ) + .map_err(Self::domain_error)?; + SqliteJourneyRepository::compare_and_store_in_transaction( + &tx, + &journey, + request.expected_revision, + ) + .map_err(Self::domain_error)?; + tx.commit() + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + Ok(JourneyWriteResponse { + revision: journey.revision, + }) + } + + 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 { + Self::ensure_session(conn, &request.session_id)?; + SqliteJourneyRepository::ensure_schema(conn) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + 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(&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) + } + + pub fn review_queue( + conn: &Connection, + session_id: &str, + ) -> JourneyApplicationResult> { + let snapshot = Self::snapshot(conn, session_id)?; + Ok(snapshot.snapshot.reviews.into_values().collect()) + } + + pub fn fork_compare( + conn: &Connection, + session_id: &str, + ) -> JourneyApplicationResult { + let snapshot = Self::snapshot(conn, session_id)?.snapshot; + let mut grouped: std::collections::BTreeMap< + (String, Option, u64), + Vec, + > = std::collections::BTreeMap::new(); + for fork in snapshot + .branches + .values() + .filter(|fork| fork.id != fork.parent_branch_id) + { + let tasks: Vec = snapshot + .tasks + .values() + .filter(|task| task.branch_id == fork.id) + .map(|task| ForkCompareTask { + task_id: task.id.clone(), + name: task.name.clone(), + state: task.state.clone(), + outcome: task.outcome.clone(), + }) + .collect(); + let task_outcome = tasks.iter().rev().find_map(|task| task.outcome.clone()); + let (conclusion, unresolved, evidence) = fork + .handoff_capsule + .as_ref() + .map(|capsule| { + ( + Some(capsule.conclusion.clone()), + capsule.open_questions.clone(), + capsule.evidence_references.clone(), + ) + }) + .unwrap_or_else(|| (None, Vec::new(), Vec::new())); + grouped + .entry(( + fork.parent_branch_id.clone(), + fork.parent_anchor_message_id.clone(), + fork.anchor_sequence, + )) + .or_default() + .push(ForkCompareItem { + branch_id: fork.id.clone(), + branch_name: fork.id.clone(), + state: fork.state.clone(), + tasks, + task_outcome, + conclusion, + unresolved, + evidence, + }); + } + Ok(ForkCompareResponse { + groups: grouped + .into_iter() + .filter_map( + |((parent_branch_id, parent_anchor_message_id, anchor_sequence), forks)| { + (forks.len() > 1).then_some(ForkCompareGroup { + parent_branch_id, + parent_anchor_message_id, + anchor_sequence, + forks, + }) + }, + ) + .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)?; + Self::ensure_session(conn, &request.session_id)?; + SqliteJourneyRepository::ensure_schema(conn) + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + 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, + &snapshot, + &request.review_id, + &start, + &end, + )?; + snapshot + .promote_fact( + snapshot.revision, + &request.review_id, + FactPromotion { + fact_id: request.fact_id.clone(), + text: request.text.clone(), + evidence_start_message_id: start.message_id.clone(), + evidence_start_sequence: start.sequence, + evidence_end_message_id: end.message_id.clone(), + evidence_end_sequence: 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( + &tx, + &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}")) + })?; + tx.commit() + .map_err(|error| JourneyApplicationError::存储失败(error.to_string()))?; + Ok(JourneyWriteResponse { + revision: snapshot.revision, + }) + } + + pub fn discard_fork( + conn: &mut Connection, + request: DiscardForkRequest, + ) -> JourneyApplicationResult { + Self::ensure_session(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, + 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_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_in_transaction( + &tx, + &snapshot, + request.expected_revision, + ) + .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, + }) + } + + 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 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, + }); + } + 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_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, + 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::存储失败("无法初始化会话旅程存储。".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())) + } + + /// Resolve the direct-Fork anchor server-side. It deliberately selects only + /// user transcript rows that already belong to the currently active + /// Journey branch. This never fabricates a message and is not used by + /// checkpoints, task finish, or fork close. + fn latest_active_branch_user_anchor( + conn: &Connection, + session_id: &str, + branch_id: &str, + ) -> JourneyApplicationResult { + let anchor = conn + .query_row( + "SELECT message.id, message.sequence + FROM agent_messages AS message + INNER JOIN session_journey_memberships AS membership + ON membership.session_id = message.session_id + AND membership.message_id = message.id + WHERE message.session_id = ?1 + AND message.role = 'user' + AND membership.branch_id = ?2 + AND message.sequence >= 0 + ORDER BY message.sequence DESC + LIMIT 1", + params![session_id, branch_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 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() != Some(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()))?; + 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(()) + } + + 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 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 } => { + 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(); + 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(), + expected_revision: revision, + fork_id: "f1".into(), + task_id: "t1".into(), + task_name: "调查".into(), + anchor_message_id: Some("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 finishing_a_task_explicitly_stays_in_the_current_fork() { + let mut conn = conn(); + SessionJourneyApplicationService::create_fork(&mut conn, fork_request(0)).unwrap(); + assign_anchor(&conn, "f1", "t1"); + SessionJourneyApplicationService::finish_task( + &mut conn, + FinishTaskRequest { + session_id: "s".into(), + expected_revision: 1, + outcome: TaskOutcome::Completed, + message_id: "a1".into(), + }, + ) + .unwrap(); + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s") + .unwrap() + .snapshot; + assert_eq!(snapshot.active_branch_id, "f1"); + assert!(snapshot.active_task_id.is_none()); + assert_eq!(snapshot.tasks["t1"].outcome, Some(TaskOutcome::Completed)); + assert_eq!( + snapshot.branches["f1"].state, + crate::core::journey_lifecycle::ForkState::Active + ); + assert!(snapshot.reviews.is_empty()); + } + + #[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(); + assign_anchor(&conn, "main", "next"); + 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 direct_fork_uses_the_latest_durable_user_anchor_in_the_active_branch() { + let mut conn = conn(); + conn.execute( + "INSERT INTO agent_messages (id, session_id, role, sequence) VALUES ('u2', 's', 'user', 6)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO session_journey_memberships (session_id, message_id, sequence, branch_id, task_id) VALUES ('s', 'u2', 6, 'main', NULL)", + [], + ) + .unwrap(); + + let mut request = fork_request(0); + request.anchor_message_id = None; + SessionJourneyApplicationService::create_fork(&mut conn, request).unwrap(); + + let snapshot = SessionJourneyApplicationService::snapshot(&conn, "s") + .unwrap() + .snapshot; + assert_eq!( + snapshot.branches["f1"].parent_anchor_message_id.as_deref(), + Some("u2") + ); + assert_eq!(snapshot.branches["f1"].anchor_sequence, 6); + } + + #[test] + fn direct_fork_never_uses_a_user_message_from_another_branch() { + let mut conn = conn(); + conn.execute( + "UPDATE session_journey_memberships SET branch_id = 'other' WHERE session_id = 's' AND message_id = 'u1'", + [], + ) + .unwrap(); + let mut request = fork_request(0); + request.anchor_message_id = None; + let error = SessionJourneyApplicationService::create_fork(&mut conn, request) + .unwrap_err() + .to_string(); + assert_eq!(error, "当前分叉没有可用的已持久化用户消息,无法创建分叉。"); + } + + #[test] + fn explicit_fork_anchor_remains_strict_and_does_not_fall_back() { + let mut conn = conn(); + let mut request = fork_request(0); + request.anchor_message_id = Some("missing-user-anchor".into()); + let error = SessionJourneyApplicationService::create_fork(&mut conn, request) + .unwrap_err() + .to_string(); + assert_eq!(error, "未找到该会话中的精确消息锚点。"); + } + + #[test] + 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( + &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 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 { + 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_message_id.as_str(), + discard.parent_anchor_sequence + ), + ("main", "u1", 4) + ); + } + + #[test] + 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 { + 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(); + assign_anchor(&conn, "f1", "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 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..0035fae081 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_context_visibility.rs @@ -0,0 +1,365 @@ +//! 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. +/// Provider prompt visibility is intentionally stricter than user browsing. +/// The parent branch keeps only its own transcript prefix; closed/discarded +/// child transcripts are represented exclusively by reviewed capsules. +pub fn project_prompt_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 ancestor_cutoffs = BTreeMap::new(); + let mut branch_id = viewer_branch_id; + loop { + let branch = journey + .branches + .get(branch_id) + .ok_or_else(|| JourneyError::UnknownBranch(branch_id.into()))?; + if branch.parent_branch_id == branch.id { + break; + } + ancestor_cutoffs.insert(branch.parent_branch_id.as_str(), branch.anchor_sequence); + branch_id = branch.parent_branch_id.as_str(); + } + + 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; + } + let allowed = membership.branch_id == viewer_branch_id + || ancestor_cutoffs + .get(membership.branch_id.as_str()) + .is_some_and(|cutoff| membership.sequence <= *cutoff); + if allowed { + visible.insert(message.message_id.clone()); + } + } + Ok(visible) +} + +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 provider_prompt_excludes_child_transcript_from_parent_and_preserves_child_prefix() { + let journey = journey(); + let messages = [ + message("main-prefix", 9), + message("parent-anchor", 10), + message("parent-future", 11), + message("child", 12), + message("sibling", 12), + ]; + let memberships = [ + membership("main-prefix", 9, "main"), + membership("parent-anchor", 10, "main"), + membership("parent-future", 11, "main"), + membership("child", 12, "fork-a"), + membership("sibling", 12, "fork-b"), + ]; + + let main = project_prompt_message_ids(&journey, "main", &messages, &memberships) + .expect("main prompt projection"); + assert_eq!( + main, + BTreeSet::from([ + "main-prefix".into(), + "parent-anchor".into(), + "parent-future".into(), + ]) + ); + + let child = project_prompt_message_ids(&journey, "fork-a", &messages, &memberships) + .expect("child prompt projection"); + assert_eq!( + child, + BTreeSet::from(["main-prefix".into(), "parent-anchor".into(), "child".into(),]) + ); + } + + #[test] + fn nested_fork_prompt_preserves_each_ancestor_prefix_at_its_exact_cutoff() { + let mut journey = SessionJourney::new("s", "main"); + journey + .start_fork( + 0, + "parent".into(), + "parent-task".into(), + "父分叉".into(), + "main-anchor".into(), + 10, + ) + .unwrap(); + journey.active_task_id = None; + journey + .start_fork( + 1, + "child".into(), + "child-task".into(), + "子分叉".into(), + "parent-anchor".into(), + 20, + ) + .unwrap(); + let messages = [ + message("main-prefix", 9), + message("main-after-parent-anchor", 11), + message("parent-prefix", 19), + message("parent-after-child-anchor", 21), + message("child-message", 22), + ]; + let memberships = [ + membership("main-prefix", 9, "main"), + membership("main-after-parent-anchor", 11, "main"), + membership("parent-prefix", 19, "parent"), + membership("parent-after-child-anchor", 21, "parent"), + membership("child-message", 22, "child"), + ]; + + let visible = project_prompt_message_ids(&journey, "child", &messages, &memberships) + .expect("nested prompt projection"); + assert_eq!( + visible, + BTreeSet::from([ + "main-prefix".into(), + "parent-prefix".into(), + "child-message".into(), + ]) + ); + } + + #[test] + fn provider_prompt_keeps_legacy_transcript_and_denies_unknown_or_mismatched_rows() { + let journey = journey(); + let messages = [message("legacy", 1), message("bad", 2)]; + let legacy = project_prompt_message_ids(&journey, "fork-a", &messages, &[]).unwrap(); + assert_eq!(legacy, BTreeSet::from(["bad".into(), "legacy".into()])); + + let denied = project_prompt_message_ids( + &journey, + "fork-a", + &messages, + &[membership("bad", 3, "fork-a")], + ) + .unwrap(); + assert!(denied.is_empty()); + assert!(matches!( + project_prompt_message_ids( + &journey, + "missing", + &messages, + &[membership("bad", 2, "main")] + ), + Err(JourneyError::UnknownBranch(_)) + )); + } + + #[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..37f388d782 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_lifecycle.rs @@ -0,0 +1,2100 @@ +//! 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, +} + +impl ForkState { + pub fn chinese_label(&self) -> &'static str { + match self { + Self::Active => "进行中", + Self::Closing => "关闭中", + Self::CloseFailed => "关闭失败", + Self::Closed => "已关闭", + Self::Discarded => "已丢弃", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewState { + Queued, + Ready, + Confirmed, + Discarded, + 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)] +pub struct FactPromotion { + pub fact_id: String, + pub text: String, + pub evidence_start_message_id: String, + pub evidence_start_sequence: u64, + pub evidence_end_message_id: String, + pub evidence_end_sequence: u64, +} + +#[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(&task_id) + .ok_or_else(|| JourneyError::UnknownTask(task_id.clone()))?; + if task.state != TaskState::Active { + return Err(JourneyError::InvalidState("任务尚未激活")); + } + let close_work_id = if disposition == FinishDisposition::CloseFork { + let work = close_work_id.ok_or(JourneyError::InvalidState("缺少关闭工作 ID"))?; + provenance + .as_ref() + .ok_or(JourneyError::MissingRuntimeProvenance)? + .validate()?; + let branch = s + .branches + .get(&branch_id) + .ok_or_else(|| JourneyError::UnknownBranch(branch_id.clone()))?; + if branch.parent_branch_id == branch.id { + return Err(JourneyError::InvalidState("主分支不可关闭")); + } + Some(work) + } else { + None + }; + + let task = s + .tasks + .get_mut(&task_id) + .ok_or_else(|| JourneyError::UnknownTask(task_id.clone()))?; + task.state = TaskState::Finished; + task.outcome = Some(outcome); + task.finish_sequence = Some(finish_sequence); + s.active_task_id = None; + if let Some(work) = close_work_id { + if let Some(branch) = s.branches.get_mut(&branch_id) { + 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, + promotion: FactPromotion, + ) -> Result<(), JourneyError> { + self.mutate(expected_revision, |s| { + if promotion.evidence_start_sequence > promotion.evidence_end_sequence + || promotion.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 promotion.evidence_start_sequence < review.source_start_sequence + || promotion.evidence_end_sequence > review.source_end_sequence + { + return Err(JourneyError::AccessDenied); + } + if s.facts.contains_key(&promotion.fact_id) { + return Err(JourneyError::DuplicateId(promotion.fact_id)); + } + review.promoted_fact_ids.push(promotion.fact_id.clone()); + review.state = ReviewState::Confirmed; + s.facts.insert( + promotion.fact_id.clone(), + ConfirmedFact { + id: promotion.fact_id, + review_id: review_id.into(), + evidence_start_message_id: promotion.evidence_start_message_id, + evidence_start_sequence: promotion.evidence_start_sequence, + evidence_end_message_id: promotion.evidence_end_message_id, + evidence_end_sequence: promotion.evidence_end_sequence, + text: promotion.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; + let discarded_branch_id = branch.id.clone(); + let parent_branch_id = branch.parent_branch_id.clone(); + branch.state = ForkState::Discarded; + review.state = ReviewState::Discarded; + for fact_id in &review.promoted_fact_ids { + s.facts.remove(fact_id); + } + if s.active_branch_id == discarded_branch_id { + s.active_branch_id = parent_branch_id; + s.active_task_id = None; + } + 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 { + /// 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: Some(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::fork_compare(conn, session_id) + .map(|response| fork_compare_response_text(&response)) + } + 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_message_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 fork_compare_response_text( + comparison: &crate::core::session::journey_application_service::ForkCompareResponse, +) -> String { + if comparison.groups.is_empty() { + return "当前没有可比较的同锚点分叉。".to_string(); + } + comparison + .groups + .iter() + .map(|group| { + let forks = group + .forks + .iter() + .map(|fork| { + format!( + "{}(状态:{};结果:{};结论:{};未决:{};证据:{})", + fork.branch_name, + fork.state.chinese_label(), + fork.task_outcome + .as_ref() + .map(TaskOutcome::chinese_label) + .unwrap_or("未结束"), + fork.conclusion.as_deref().unwrap_or("尚无结构化结论"), + if fork.unresolved.is_empty() { + "无".to_string() + } else { + fork.unresolved.join("、") + }, + if fork.evidence.is_empty() { + "无".to_string() + } else { + fork.evidence.join("、") + } + ) + }) + .collect::>() + .join("\n"); + format!("锚点 {}:\n{}", group.anchor_sequence, forks) + }) + .collect::>() + .join("\n") +} + +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!( + "会话旅程:修订 {};活动分支 `{}`;活动任务 `{task}`;审阅项 {} 个。", + journey.revision, + journey.active_branch_id, + journey.reviews.len() + ) +} + +/// 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("无法初始化会话旅程存储"))?; + 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("无法读取会话旅程存储"))?; + 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("无法读取会话旅程存储"))?; + json.map(|value| { + serde_json::from_str(&value) + .map_err(|_| JourneyError::InvalidState("会话旅程存储已损坏")) + }) + .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("无法初始化会话旅程存储"))?; + 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("无法开启会话旅程事务"))?; + 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("无法读取会话旅程修订"))?; + 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("无法序列化会话旅程"))?; + 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("无法写入会话旅程"))?; + tx.commit() + .map_err(|_| JourneyError::InvalidState("无法提交会话旅程"))?; + 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("无法读取会话旅程修订"))?; + 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("无法序列化会话旅程"))?; + 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("无法写入会话旅程"))?; + 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 task_finish_stays_in_fork_but_close_requires_provenance_before_mutating() { + let mut stay = forked(); + stay.finish_task( + 1, + TaskOutcome::Completed, + 15, + FinishDisposition::StayInFork, + None, + None, + ) + .unwrap(); + assert_eq!(stay.active_branch_id, "f1"); + assert_eq!(stay.branches["f1"].state, ForkState::Active); + assert_eq!(stay.tasks["t1"].state, TaskState::Finished); + + let mut close = forked(); + assert_eq!( + close.finish_task( + 1, + TaskOutcome::Completed, + 15, + FinishDisposition::CloseFork, + Some("work".into()), + None, + ), + Err(JourneyError::MissingRuntimeProvenance) + ); + assert_eq!(close.revision, 1); + assert_eq!(close.active_task_id.as_deref(), Some("t1")); + assert_eq!(close.tasks["t1"].state, TaskState::Active); + assert_eq!(close.branches["f1"].state, ForkState::Active); + + let mut main = SessionJourney::new("s", "main"); + main.start_task(0, "t1".into(), "主任务".into(), false, Some(1)) + .unwrap(); + assert_eq!( + main.finish_task( + 1, + TaskOutcome::Completed, + 2, + FinishDisposition::CloseFork, + Some("work".into()), + Some(provenance()), + ), + Err(JourneyError::InvalidState("主分支不可关闭")) + ); + assert_eq!(main.revision, 1); + assert_eq!(main.tasks["t1"].state, TaskState::Active); + } + #[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", + FactPromotion { + fact_id: "fact".into(), + text: "确认".into(), + evidence_start_message_id: "m11".into(), + evidence_start_sequence: 11, + evidence_end_message_id: "m15".into(), + evidence_end_sequence: 15, + }, + ) + .unwrap(); + assert_eq!(j.discard_fork(5, "r").unwrap(), 10); + assert!(j.facts.is_empty()); + } + #[test] + fn discarding_inactive_sibling_preserves_current_branch_and_task() { + 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.active_branch_id = "main".into(); + j.active_task_id = None; + j.start_fork( + 3, + "f2".into(), + "t2".into(), + "继续调查".into(), + "m20".into(), + 20, + ) + .unwrap(); + + assert_eq!(j.discard_fork(4, "r").unwrap(), 10); + assert_eq!(j.active_branch_id, "f2"); + assert_eq!(j.active_task_id.as_deref(), Some("t2")); + assert_eq!(j.branches["f1"].state, ForkState::Discarded); + assert_eq!(j.branches["f2"].state, ForkState::Active); + } + + #[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("会话旅程存储已损坏")) + )); + } + + #[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(); + SqliteJourneyRepository::ensure_schema(&conn).unwrap(); + conn.execute( + "INSERT INTO session_journey_memberships (session_id, message_id, sequence, branch_id, task_id) VALUES ('s', 'anchor', 9, 'main', NULL)", + [], + ) + .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(); + conn.execute( + "INSERT INTO session_journey_memberships (session_id, message_id, sequence, branch_id, task_id) VALUES ('s', 'fork-end', 10, 'fork-a', 'task-a')", + [], + ) + .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(); + SqliteJourneyRepository::ensure_schema(&conn).unwrap(); + conn.execute( + "INSERT INTO session_journey_memberships (session_id, message_id, sequence, branch_id, task_id) VALUES ('s', 'anchor', 9, 'main', NULL)", + [], + ) + .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)。"); + conn.execute( + "INSERT INTO session_journey_memberships (session_id, message_id, sequence, branch_id, task_id) VALUES ('s', 'fork-end', 10, 'fork-a', 'task-a')", + [], + ) + .unwrap(); + 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..a370b61321 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/journey_review_queue.rs @@ -0,0 +1,1355 @@ +//! 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::{ + HandoffCapsule, RuntimeProvenance, SessionJourney, SqliteJourneyRepository, +}; +use crate::core::session::journey_application_service::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 and release work claimed by the previous + /// process before this host 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)?; + ReviewJobRepository::requeue_orphaned_running_jobs(&conn)?; + Ok(()) + }) { + 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)) + } + + /// Startup recovery releases jobs that were claimed by a process which + /// exited before it could commit a terminal state. Call this only while + /// bringing up the single host executor, before it begins processing. + pub fn requeue_orphaned_running_jobs(conn: &Connection) -> Result { + Self::ensure_schema(conn)?; + conn.execute( + "UPDATE session_journey_review_jobs + SET state='queued', started_at=NULL, error=NULL, finished_at=NULL + WHERE state='running'", + [], + ) + .map_err(|e| QueueError::Storage(format!("无法恢复遗留审核任务:{e}"))) + } + + 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 annotation = draft.conclusion.trim().to_string(); + if annotation.is_empty() { + return Err(QueueError::Invalid("审核结论不能为空。".into())); + } + let current = SessionJourneyApplicationService::snapshot(conn, &job.session_id) + .map_err(|error| QueueError::Conflict(error.to_string()))?; + let branch = current + .snapshot + .branches + .get(&job.fork_id) + .ok_or_else(|| QueueError::Invalid("审核分叉不存在。".into()))?; + let parent_branch_id = branch.parent_branch_id.clone(); + let capsule = HandoffCapsule { + fork_id: job.fork_id.clone(), + review_id: job.review_id.clone(), + parent_branch_id, + parent_anchor_message_id: job.exact_anchor_message_id.clone(), + source_start_sequence: job.frozen_start_sequence, + source_end_sequence: job.frozen_end_sequence, + objective: draft.objective, + conclusion: draft.conclusion, + open_questions: draft.open_questions, + confirmed_items: draft.confirmation_items, + evidence_references: draft.evidence_message_ids, + generated_at: Some(now()), + provenance: draft.provenance.clone(), + }; + + // Review readiness, the immutable handoff capsule, its embedding outbox + // row, and queue completion are one durable publication boundary. A crash + // can therefore leave either the prior running job or the complete ready + // result, never a ready review without a capsule. + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| QueueError::Storage(format!("无法开启审核发布事务:{error}")))?; + let mut journey = SqliteJourneyRepository::load(&tx, &job.session_id) + .map_err(|error| QueueError::Conflict(error.to_string()))? + .ok_or_else(|| QueueError::Conflict("审核对应的会话旅程不存在。".into()))?; + if journey.revision != current.revision { + return Err(QueueError::Conflict("审核发布前会话旅程已变化。".into())); + } + journey + .mark_review_ready( + current.revision, + &job.review_id, + draft.provenance, + annotation.clone(), + ) + .map_err(|error| QueueError::Conflict(error.to_string()))?; + journey + .publish_handoff_capsule(journey.revision, &job.fork_id, capsule) + .map_err(|error| QueueError::Conflict(error.to_string()))?; + SqliteJourneyRepository::compare_and_store_in_transaction(&tx, &journey, current.revision) + .map_err(|error| QueueError::Conflict(error.to_string()))?; + crate::core::session::journey_embedding::enqueue( + &tx, + &format!("review:{}", job.review_id), + crate::core::session::journey_embedding::JourneyEmbeddingKind::ReviewAnnotation, + &job.session_id, + &job.fork_id, + &job.review_id, + &annotation, + ) + .map_err(|error| QueueError::Storage(format!("无法排队审核 embedding:{error}")))?; + ReviewJobRepository::complete(&tx, &job.job_id)?; + tx.commit() + .map_err(|error| QueueError::Storage(format!("无法提交审核发布事务:{error}"))) +} + +fn commit_failed_review( + conn: &mut Connection, + job_id: &str, + session_id: &str, + review_id: &str, +) -> Result<(), QueueError> { + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| QueueError::Storage(format!("无法开启审核失败事务:{error}")))?; + let job = ReviewJobRepository::get(&tx, review_id)? + .filter(|job| job.job_id == job_id && job.session_id == session_id) + .ok_or_else(|| QueueError::Conflict("审核失败任务不存在。".into()))?; + let mut journey = SqliteJourneyRepository::load(&tx, session_id) + .map_err(|error| QueueError::Conflict(error.to_string()))? + .ok_or_else(|| QueueError::Conflict("审核对应的会话旅程不存在。".into()))?; + let previous_revision = journey.revision; + journey + .fail_review(previous_revision, review_id) + .map_err(|error| QueueError::Conflict(error.to_string()))?; + SqliteJourneyRepository::compare_and_store_in_transaction(&tx, &journey, previous_revision) + .map_err(|error| QueueError::Conflict(error.to_string()))?; + ReviewJobRepository::fail(&tx, &job.job_id, "审核模型调用失败,请显式重试。")?; + tx.commit() + .map_err(|error| QueueError::Storage(format!("无法提交审核失败事务:{error}"))) +} + +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) => { + commit_ready_review(conn, &job, draft.clone())?; + Ok(Some(draft)) + } + Err(error) => { + commit_failed_review(conn, &job.job_id, &job.session_id, &job.review_id)?; + 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(()) +} + +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("批判")?, + }) +} + +#[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_eq!( + snapshot.reviews["r"].annotation.as_deref(), + Some("可以继续主分支") + ); + let capsule = snapshot.branches["f"] + .handoff_capsule + .as_ref() + .expect("ready review publishes a handoff capsule"); + assert_eq!(capsule.parent_branch_id, "main"); + assert_eq!(capsule.parent_anchor_message_id, "anchor"); + assert_eq!(capsule.source_start_sequence, 3); + assert_eq!(capsule.source_end_sequence, 5); + assert_eq!(capsule.conclusion, "可以继续主分支"); + assert_eq!(capsule.evidence_references, vec!["f3", "f5"]); + assert_eq!(capsule.provenance, provenance()); + 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()); + } + + #[test] + fn startup_reconciliation_requeues_orphaned_running_jobs_immediately() { + let mut conn = worker_conn(); + let job = enqueue(&conn); + ReviewJobRepository::claim(&mut conn, "worker-before-restart", 60) + .unwrap() + .unwrap(); + + assert_eq!( + ReviewJobRepository::requeue_orphaned_running_jobs(&conn).unwrap(), + 1 + ); + let requeued = ReviewJobRepository::get(&conn, "r").unwrap().unwrap(); + assert_eq!(requeued.state, ReviewJobState::Queued); + assert_eq!(requeued.started_at, None); + + let reclaimed = ReviewJobRepository::claim(&mut conn, "worker-after-restart", 60) + .unwrap() + .unwrap(); + assert_eq!(reclaimed.job_id, job.job_id); + assert_eq!(reclaimed.attempt, 2); + } + + #[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); + } +} 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/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/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/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index 7cad86184d..5ba81095fe 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -1,7 +1,9 @@ //! Message persistence — insertion, loading, truncation, history building. use chrono::Utc; -use rusqlite::{params, OptionalExtension, Result as SqliteResult}; +use rusqlite::{ + params, OptionalExtension, Result as SqliteResult, Transaction, TransactionBehavior, +}; use uuid::Uuid; use crate::persistence::db_helpers as shared; @@ -209,6 +211,18 @@ pub fn materialize_agent_org_inbox_transcript( true }; + if inserted { + let sequence = tx + .query_row( + "SELECT sequence FROM agent_messages WHERE id=?1 AND session_id=?2", + params![message_id, session_id], + |row| row.get(0), + ) + .map_err(|err| err.to_string())?; + assign_user_message_membership(&tx, session_id, message_id, sequence) + .map_err(|err| err.to_string())?; + } + let materialized_at = Utc::now().to_rfc3339(); { let mut stmt = tx @@ -250,6 +264,50 @@ pub fn save_user_msg( shared::save_user_msg(SESSION_TABLE_PREFIX, session_id, content, images) } +/// Persist one user message and its Journey membership in one transaction. +/// Sessions without a Journey deliberately receive no inferred membership. +pub fn save_user_msg_and_assign_journey( + session_id: &str, + content: &str, + images: Option<&[String]>, +) -> SqliteResult { + with_sessions_writer(|| { + let mut conn = get_connection()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let message_id = Uuid::new_v4().to_string(); + let images_json = images.filter(|value| !value.is_empty()).map(|value| { + let paths = crate::persistence::images::persist_images(value); + serde_json::to_string(if paths.is_empty() { value } else { &paths }) + .expect("image paths serialize") + }); + let sequence: i64 = tx.query_row( + "SELECT COALESCE(MAX(sequence), -1) + 1 FROM agent_messages WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + tx.execute( + "INSERT INTO agent_messages + (id, session_id, role, content, sequence, created_at, images) + VALUES (?1, ?2, 'user', ?3, ?4, ?5, ?6)", + params![ + &message_id, + session_id, + content, + sequence, + Utc::now().to_rfc3339(), + images_json + ], + )?; + assign_user_message_membership(&tx, session_id, &message_id, sequence)?; + tx.execute( + "UPDATE agent_sessions SET updated_at = ?2 WHERE session_id = ?1", + params![session_id, Utc::now().to_rfc3339()], + )?; + tx.commit()?; + Ok(message_id) + }) +} + /// Persist an at-least-once user input under a stable id. Replays return the /// same id without inserting a second transcript row. pub fn save_user_msg_with_id( @@ -262,7 +320,18 @@ pub fn save_user_msg_with_id( /// Save an assistant message. pub fn save_assistant_msg(session_id: &str, content: &str, model: &str) -> SqliteResult { - shared::save_assistant_msg(SESSION_TABLE_PREFIX, session_id, content, model) + save_message_and_assign_journey( + session_id, + JourneyMessageWrite { + role: "assistant", + content, + tool_name: None, + tool_call_id: None, + tool_input: None, + tool_output: None, + model: Some(model), + }, + ) } /// Save a persisted compact summary boundary. @@ -282,12 +351,17 @@ pub fn save_tool_call_msg( tool_name: &str, arguments: &str, ) -> SqliteResult { - shared::save_tool_call_msg( - SESSION_TABLE_PREFIX, + save_message_and_assign_journey( session_id, - tool_call_id, - tool_name, - arguments, + JourneyMessageWrite { + role: "tool_call", + content: &format!("Tool call: {tool_name}"), + tool_name: Some(tool_name), + tool_call_id: Some(tool_call_id), + tool_input: Some(arguments), + tool_output: None, + model: None, + }, ) } @@ -298,15 +372,239 @@ pub fn save_tool_result_msg( tool_name: &str, result: &str, ) -> SqliteResult { - shared::save_tool_result_msg( - SESSION_TABLE_PREFIX, + save_message_and_assign_journey( session_id, - tool_call_id, - tool_name, - result, + JourneyMessageWrite { + role: "tool_result", + content: &crate::utils::safe_truncate_chars_to_string(result, 2000), + tool_name: Some(tool_name), + tool_call_id: Some(tool_call_id), + tool_input: None, + tool_output: Some(result), + model: None, + }, ) } +struct JourneyMessageWrite<'a> { + role: &'a str, + content: &'a str, + tool_name: Option<&'a str>, + tool_call_id: Option<&'a str>, + tool_input: Option<&'a str>, + tool_output: Option<&'a str>, + model: Option<&'a str>, +} + +fn save_message_and_assign_journey( + session_id: &str, + message: JourneyMessageWrite<'_>, +) -> SqliteResult { + with_sessions_writer(|| { + let mut conn = get_connection()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let message_id = Uuid::new_v4().to_string(); + let sequence = insert_journey_message( + &tx, + session_id, + &message_id, + message.role, + message.content, + message.tool_name, + message.tool_call_id, + message.tool_input, + message.tool_output, + message.model, + )?; + assign_message_membership(&tx, session_id, &message_id, sequence)?; + tx.execute( + "UPDATE agent_sessions SET updated_at = ?2 WHERE session_id = ?1", + params![session_id, Utc::now().to_rfc3339()], + )?; + tx.commit()?; + Ok(message_id) + }) +} + +#[allow(clippy::too_many_arguments)] +fn insert_journey_message( + tx: &Transaction<'_>, + session_id: &str, + message_id: &str, + role: &str, + content: &str, + tool_name: Option<&str>, + tool_call_id: Option<&str>, + tool_input: Option<&str>, + tool_output: Option<&str>, + model: Option<&str>, +) -> SqliteResult { + let sequence: i64 = tx.query_row( + "SELECT COALESCE(MAX(sequence), -1) + 1 FROM agent_messages WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + tx.execute( + "INSERT INTO agent_messages + (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL)", + params![ + message_id, + session_id, + role, + content, + tool_name, + tool_call_id, + tool_input, + tool_output, + model, + sequence, + Utc::now().to_rfc3339(), + ], + )?; + Ok(sequence) +} + +fn assign_user_message_membership( + tx: &Transaction<'_>, + session_id: &str, + message_id: &str, + sequence: i64, +) -> SqliteResult<()> { + let Some(mut journey) = + crate::core::journey_lifecycle::SqliteJourneyRepository::load(tx, session_id) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + else { + return Ok(()); + }; + let previous_revision = journey.revision; + journey + .on_user_message_persisted(previous_revision, sequence as u64) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + if journey.revision != previous_revision { + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store_in_transaction( + tx, + &journey, + previous_revision, + ) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + } + ensure_active_branch_accepts_messages(&journey)?; + tx.execute( + "INSERT INTO session_journey_memberships + (session_id, message_id, sequence, branch_id, task_id) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + session_id, + message_id, + sequence, + journey.active_branch_id, + journey.active_task_id, + ], + )?; + Ok(()) +} + +fn assign_message_membership( + tx: &Transaction<'_>, + session_id: &str, + message_id: &str, + sequence: i64, +) -> SqliteResult<()> { + let Some(journey) = + crate::core::journey_lifecycle::SqliteJourneyRepository::load(tx, session_id) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + else { + return Ok(()); + }; + ensure_active_branch_accepts_messages(&journey)?; + tx.execute( + "INSERT INTO session_journey_memberships + (session_id, message_id, sequence, branch_id, task_id) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + session_id, + message_id, + sequence, + journey.active_branch_id, + journey.active_task_id, + ], + )?; + Ok(()) +} + +fn ensure_active_branch_accepts_messages( + journey: &crate::core::journey_lifecycle::SessionJourney, +) -> SqliteResult<()> { + use crate::core::journey_lifecycle::ForkState; + + let branch = journey + .branches + .get(&journey.active_branch_id) + .ok_or_else(|| { + rusqlite::Error::ToSqlConversionFailure("Journey active fork does not exist.".into()) + })?; + if branch.state != ForkState::Active { + return Err(rusqlite::Error::ToSqlConversionFailure( + format!("Journey active fork is not writable: {:?}.", branch.state).into(), + )); + } + Ok(()) +} + +pub fn save_completed_turn_and_assign_journey(session_id: &str, turn_id: &str) -> SqliteResult<()> { + with_sessions_writer(|| { + let mut conn = get_connection()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let Some(journey) = + crate::core::journey_lifecycle::SqliteJourneyRepository::load(&tx, session_id) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + else { + tx.commit()?; + return Ok(()); + }; + ensure_active_branch_accepts_messages(&journey)?; + let sequence: i64 = tx.query_row( + "SELECT COALESCE(MAX(sequence), -1) FROM agent_messages WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + let member: Option<(String, Option)> = tx + .query_row( + "SELECT branch_id, task_id FROM session_journey_memberships + WHERE session_id = ?1 AND sequence = ?2 + ORDER BY message_id DESC LIMIT 1", + params![session_id, sequence], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if member.as_ref() + != Some(&( + journey.active_branch_id.clone(), + journey.active_task_id.clone(), + )) + { + return Err(rusqlite::Error::ToSqlConversionFailure( + "Journey completed turn lacks exact active-fork message membership.".into(), + )); + } + tx.execute( + "INSERT OR IGNORE INTO session_journey_turn_memberships + (session_id, turn_id, completed_sequence, branch_id, task_id) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + session_id, + turn_id, + sequence, + journey.active_branch_id, + journey.active_task_id + ], + )?; + tx.commit()?; + Ok(()) + }) +} + /// Load messages for a session. pub fn load_messages(session_id: &str) -> SqliteResult> { shared::load_messages(SESSION_TABLE_PREFIX, session_id) @@ -405,6 +703,81 @@ pub fn load_llm_history_start_sequences(session_id: &str) -> SqliteResult, +) -> Vec { + prompt.extend( + journey + .parent_handoff_capsules(&journey.active_branch_id) + .into_iter() + .map(crate::core::journey_lifecycle::HandoffCapsule::synthetic_prompt_message), + ); + prompt +} + +/// Load provider history through the Journey visibility boundary when this is +/// a Journey session. Legacy sessions, including ones with no memberships, +/// retain the existing history semantics exactly. +pub fn load_llm_history_for_active_journey( + session_id: &str, +) -> SqliteResult> { + let conn = get_connection()?; + let Some(journey) = + crate::core::journey_lifecycle::SqliteJourneyRepository::load(&conn, session_id) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + else { + return load_llm_history(session_id); + }; + let mut statement = conn.prepare( + "SELECT message_id, sequence, branch_id, task_id + FROM session_journey_memberships WHERE session_id = ?1", + )?; + let memberships = statement + .query_map([session_id], |row| { + Ok( + crate::session::journey_context_visibility::JourneyMessageMembership { + message_id: row.get(0)?, + sequence: row.get::<_, i64>(1)? as u64, + branch_id: row.get(2)?, + task_id: row.get(3)?, + }, + ) + })? + .collect::>>()?; + if memberships.is_empty() { + return load_llm_history(session_id); + } + + let messages = shared::visible_rows(&shared::load_messages(SESSION_TABLE_PREFIX, session_id)?); + let persisted = messages + .iter() + .filter(|message| message.sequence >= 0) + .map( + |message| crate::session::journey_context_visibility::PersistedContextMessage { + message_id: message.id.clone(), + sequence: message.sequence as u64, + }, + ) + .collect::>(); + let visible_ids = crate::session::journey_context_visibility::project_prompt_message_ids( + &journey, + &journey.active_branch_id, + &persisted, + &memberships, + ) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let visible = messages + .into_iter() + .filter(|message| visible_ids.contains(&message.id)) + .collect::>(); + let prompt = shared::reconstruct(&visible); + // This is the sole parent prompt assembly boundary. Capsules are appended + // after reconstruction so every persisted parent message retains its exact + // serialized order and bytes; fork transcript rows never enter `visible`. + Ok(append_parent_handoff_capsules(&journey, prompt)) +} + /// Map "keep the last `tail_len` LLM messages visible" onto a durable /// sequence cutoff for [`append_compact_boundary`]. pub fn compact_cutoff_sequence(session_id: &str, tail_len: usize) -> SqliteResult { @@ -976,6 +1349,375 @@ mod tests { .expect("seed session row"); } + #[test] + fn assistant_tool_and_completed_turn_keep_exact_journey_membership() { + let _sandbox = test_env::sandbox(); + let session_id = "journey-message-membership"; + seed_session_for_message_tests(session_id); + let mut conn = get_connection().expect("get connection"); + let mut journey = crate::core::journey_lifecycle::SessionJourney::new(session_id, "main"); + journey + .start_task(0, "task".into(), "精确归属".into(), false, Some(0)) + .expect("start task"); + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 0, + ) + .expect("store journey"); + drop(conn); + + let assistant_id = save_assistant_msg(session_id, "回答", "model").expect("assistant"); + let call_id = save_tool_call_msg(session_id, "call-1", "工具", "{}").expect("tool call"); + let result_id = + save_tool_result_msg(session_id, "call-1", "工具", "结果").expect("tool result"); + save_completed_turn_and_assign_journey(session_id, "turn-1").expect("turn"); + + let conn = get_connection().expect("get connection"); + let memberships: Vec<(String, String, Option)> = conn + .prepare( + "SELECT message_id, branch_id, task_id FROM session_journey_memberships + WHERE session_id = ?1 ORDER BY sequence", + ) + .expect("prepare memberships") + .query_map([session_id], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + }) + .expect("query memberships") + .collect::>() + .expect("collect memberships"); + assert_eq!( + memberships, + vec![ + (assistant_id, "main".into(), Some("task".into())), + (call_id, "main".into(), Some("task".into())), + (result_id, "main".into(), Some("task".into())), + ] + ); + let turn: (i64, String, Option) = conn + .query_row( + "SELECT completed_sequence, branch_id, task_id + FROM session_journey_turn_memberships + WHERE session_id = ?1 AND turn_id = 'turn-1'", + [session_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("turn membership"); + assert_eq!(turn, (2, "main".into(), Some("task".into()))); + } + + #[test] + fn fork_user_message_is_persisted_and_reaches_provider_history_without_parent_continuation() { + let _sandbox = test_env::sandbox(); + let session_id = "journey-fork-user-provider-history"; + seed_session_for_message_tests(session_id); + let mut conn = get_connection().expect("get connection"); + crate::core::journey_lifecycle::SqliteJourneyRepository::ensure_schema(&conn) + .expect("ensure journey schema"); + + for (id, role, content, sequence) in [ + ("parent-prefix", "user", "parent prefix", 0_i64), + ("parent-anchor", "assistant", "parent anchor", 1_i64), + ( + "parent-after-anchor", + "assistant", + "parent after anchor", + 2_i64, + ), + ] { + conn.execute( + "INSERT INTO agent_messages (id, session_id, role, content, sequence, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))", + params![id, session_id, role, content, sequence], + ) + .expect("seed message"); + } + let mut journey = crate::core::journey_lifecycle::SessionJourney::new(session_id, "main"); + journey + .start_fork( + 0, + "fork-a".into(), + "task-a".into(), + "fork task".into(), + "parent-anchor".into(), + 1, + ) + .expect("start fork"); + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 0, + ) + .expect("store journey"); + for (id, sequence) in [("parent-prefix", 0_i64), ("parent-anchor", 1_i64)] { + conn.execute( + "INSERT INTO session_journey_memberships + (session_id, message_id, sequence, branch_id, task_id) + VALUES (?1, ?2, ?3, 'main', NULL)", + params![session_id, id, sequence], + ) + .expect("seed parent membership"); + } + conn.execute( + "INSERT INTO session_journey_memberships + (session_id, message_id, sequence, branch_id, task_id) + VALUES (?1, 'parent-after-anchor', 2, 'main', NULL)", + [session_id], + ) + .expect("seed hidden parent continuation"); + drop(conn); + + let fork_user_id = save_user_msg_and_assign_journey(session_id, "fork user request", None) + .expect("persist fork user message"); + let conn = get_connection().expect("get connection"); + let membership: (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, fork_user_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("fork user membership"); + assert_eq!(membership, ("fork-a".into(), Some("task-a".into()))); + drop(conn); + + let history = load_llm_history_for_active_journey(session_id) + .expect("provider history for active fork"); + let contents = history + .iter() + .filter_map(|message| message.get("content").and_then(|content| content.as_str())) + .collect::>(); + assert!(contents.contains(&"parent prefix")); + assert!(contents.contains(&"parent anchor")); + assert!(contents.contains(&"fork user request")); + assert!( + !contents.contains(&"parent after anchor"), + "fork provider history must exclude parent continuation after its exact anchor" + ); + } + + #[test] + fn journey_history_loader_filters_before_provider_reconstruction() { + let _sandbox = test_env::sandbox(); + let session_id = "journey-provider-visibility"; + seed_session_for_message_tests(session_id); + let mut conn = get_connection().expect("get connection"); + let mut journey = crate::core::journey_lifecycle::SessionJourney::new(session_id, "main"); + journey + .start_fork( + 0, + "fork-a".into(), + "task-a".into(), + "分叉 A".into(), + "anchor".into(), + 10, + ) + .expect("start fork a"); + journey.active_branch_id = "main".into(); + journey.active_task_id = None; + journey + .start_fork( + 1, + "fork-b".into(), + "task-b".into(), + "分叉 B".into(), + "anchor".into(), + 10, + ) + .expect("start fork b"); + journey.active_branch_id = "main".into(); + journey.active_task_id = None; + journey + .start_fork( + 2, + "fork-c".into(), + "task-c".into(), + "分叉 C".into(), + "future".into(), + 11, + ) + .expect("start fork c"); + journey.active_branch_id = "fork-a".into(); + journey.active_task_id = Some("task-a".into()); + journey.revision = 1; + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 0, + ) + .expect("store journey"); + + for (id, sequence, content, branch) in [ + ("anchor", 10, "parent anchor", "main"), + ("future", 11, "parent future", "main"), + ("a", 12, "fork a", "fork-a"), + ("b", 12, "fork b", "fork-b"), + ("c", 12, "fork c", "fork-c"), + ] { + conn.execute( + "INSERT INTO agent_messages (id, session_id, role, content, sequence, created_at) + VALUES (?1, ?2, 'assistant', ?3, ?4, datetime('now'))", + params![id, session_id, content, sequence], + ) + .expect("seed message"); + conn.execute( + "INSERT INTO session_journey_memberships + (session_id, message_id, sequence, branch_id, task_id) + VALUES (?1, ?2, ?3, ?4, NULL)", + params![session_id, id, sequence, branch], + ) + .expect("seed membership"); + } + drop(conn); + + let history = load_llm_history_for_active_journey(session_id).expect("project history"); + let contents = history + .iter() + .filter_map(|message| message.get("content").and_then(|content| content.as_str())) + .collect::>(); + assert!(contents.contains(&"parent anchor")); + assert!(contents.contains(&"fork a")); + assert!( + !contents.contains(&"fork b"), + "provider prompt must not inherit sibling fork transcript" + ); + assert!(!contents.contains(&"parent future")); + assert!(!contents.contains(&"fork c")); + } + + #[test] + fn parent_handoff_capsule_is_chinese_append_only_and_excludes_fork_rows() { + let _sandbox = test_env::sandbox(); + let session_id = "journey-parent-handoff"; + seed_session_for_message_tests(session_id); + let mut conn = get_connection().expect("get connection"); + let mut journey = crate::core::journey_lifecycle::SessionJourney::new(session_id, "main"); + journey + .start_fork( + 0, + "fork-a".into(), + "task-a".into(), + "核对分叉".into(), + "anchor".into(), + 10, + ) + .expect("start fork"); + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 0, + ) + .expect("store fork"); + journey + .request_fork_close( + 1, + "fork-a", + "review-a".into(), + crate::core::journey_lifecycle::TaskOutcome::Completed, + 12, + ) + .expect("close request"); + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 1, + ) + .expect("store close request"); + let provenance = crate::core::journey_lifecycle::RuntimeProvenance { + model_id: "模型一".into(), + account_id: "账户一".into(), + protocol: "测试协议".into(), + }; + journey + .mark_review_ready(2, "review-a", provenance.clone(), "审核通过".into()) + .expect("ready review"); + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 2, + ) + .expect("store ready review"); + let capsule = crate::core::journey_lifecycle::HandoffCapsule { + fork_id: "fork-a".into(), + review_id: "review-a".into(), + parent_branch_id: "main".into(), + parent_anchor_message_id: "anchor".into(), + source_start_sequence: 11, + source_end_sequence: 12, + objective: "核对主干方案".into(), + conclusion: "可以继续主干实施".into(), + open_questions: vec!["补充一次回归".into()], + confirmed_items: vec!["父主干前缀保持".into()], + evidence_references: vec!["检查点 anchor".into()], + generated_at: Some("元数据,不参与定位".into()), + provenance: provenance.clone(), + }; + journey + .publish_handoff_capsule(3, "fork-a", capsule) + .expect("publish capsule"); + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 3, + ) + .expect("store published capsule"); + journey + .return_to_parent(4, "review-a") + .expect("return parent"); + crate::core::journey_lifecycle::SqliteJourneyRepository::compare_and_store( + &mut conn, &journey, 4, + ) + .expect("store parent return"); + for (id, sequence, content, branch) in [ + ("anchor", 10, "主干锚点", "main"), + ("parent-next", 11, "主干后续", "main"), + ("fork-secret", 12, "FORK_SECRET_TRANSCRIPT", "fork-a"), + ] { + conn.execute( + "INSERT INTO agent_messages (id, session_id, role, content, sequence, created_at) + VALUES (?1, ?2, 'assistant', ?3, ?4, datetime('now'))", + params![id, session_id, content, sequence], + ) + .expect("seed message"); + conn.execute( + "INSERT INTO session_journey_memberships + (session_id, message_id, sequence, branch_id, task_id) + VALUES (?1, ?2, ?3, ?4, NULL)", + params![session_id, id, sequence, branch], + ) + .expect("seed membership"); + } + drop(conn); + + let history = load_llm_history_for_active_journey(session_id).expect("project history"); + let capsule_message = history.last().expect("capsule item"); + let capsule_text = capsule_message["content"].as_str().expect("capsule text"); + assert!(capsule_text.contains("【分叉交接】")); + assert!(capsule_text.contains("分叉ID:fork-a")); + assert!(capsule_text.contains("审阅ID:review-a")); + assert!(capsule_text.contains("源锚点:anchor")); + assert!(capsule_text.contains("模型:模型一")); + assert!(capsule_text.contains("账户:账户一")); + assert!(capsule_text.contains("协议:测试协议")); + assert!(capsule_text.contains("可以继续主干实施")); + assert!(!history + .iter() + .any(|message| message.to_string().contains("FORK_SECRET_TRANSCRIPT"))); + + let before = [ + serde_json::json!({ "role": "assistant", "content": "主干锚点" }), + serde_json::json!({ "role": "assistant", "content": "主干后续" }), + ]; + let before_bytes = before + .iter() + .map(serde_json::to_vec) + .collect::, _>>() + .expect("serialize prefix"); + let after_bytes = history[..before.len()] + .iter() + .map(serde_json::to_vec) + .collect::, _>>() + .expect("serialize projected prefix"); + assert_eq!(before_bytes, after_bytes, "父主干 prefix bytes 不可改变"); + let persisted_rows = + shared::load_messages(SESSION_TABLE_PREFIX, session_id).expect("load transcript"); + assert_eq!(persisted_rows.len(), 3, "capsule 不得写入 transcript"); + assert_eq!( + journey.branches["fork-a"] + .handoff_capsule + .as_ref() + .unwrap() + .provenance, + provenance + ); + } + #[test] fn compact_boundary_hides_old_rows_but_keeps_them_in_table() { 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..e0e921d358 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; @@ -31,12 +32,15 @@ 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, }; +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, @@ -46,13 +50,14 @@ pub use messages::{ anchor_at_or_after_created_at, append_compact_boundary, clear_messages, clear_session_memory_state, compact_cutoff_sequence, load_agent_org_inbox_transcript_materializations, load_llm_history, - load_llm_history_start_sequences, load_llm_history_text_only, - load_llm_history_text_only_bounded, load_messages, load_session_memory_state, - mark_turn_cancelled, materialize_agent_org_inbox_transcript, message_anchor, - message_created_at, save_assistant_msg, save_compact_summary_msg, save_session_memory_state, - save_snapshot, save_subagent_transcript, save_tool_call_msg, save_tool_result_msg, - save_user_msg, save_user_msg_with_id, seed_session_with_messages, take_turn_cancelled, - truncate_messages_from_sequence, update_compact_boundary_token_delta, + load_llm_history_for_active_journey, load_llm_history_start_sequences, + load_llm_history_text_only, load_llm_history_text_only_bounded, load_messages, + load_session_memory_state, mark_turn_cancelled, materialize_agent_org_inbox_transcript, + message_anchor, message_created_at, save_assistant_msg, save_compact_summary_msg, + save_completed_turn_and_assign_journey, save_session_memory_state, save_snapshot, + save_subagent_transcript, save_tool_call_msg, save_tool_result_msg, save_user_msg, + save_user_msg_and_assign_journey, save_user_msg_with_id, seed_session_with_messages, + take_turn_cancelled, truncate_messages_from_sequence, update_compact_boundary_token_delta, AgentOrgInboxTranscriptMaterialization, MessageAnchor, }; @@ -64,5 +69,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/agent-core/src/core/session/turn/event_handler/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs index fc7b33cb98..7a44a5faf6 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs @@ -861,7 +861,7 @@ impl TurnEventHandler for UnifiedEventHandler { // Persist the user message so the durable transcript (and the next // turn's reloaded history) contains it as a plain user row — the // in-memory wrapper is a per-turn presentation. - let message_id = match crate::session::persistence::save_user_msg( + let message_id = match crate::session::persistence::save_user_msg_and_assign_journey( session_id, &injection.content, None, diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index 84a51e6a01..ab000611e2 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -63,7 +63,6 @@ fn scoped_system_message(text: String, scope: RenderedSystemBlockScope) -> Value }], }) } - fn reconcile_inbox_transcript_replay( messages: &mut Vec, message_count_before_inbox: usize, diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs index e0e983d24c..4fd3684de3 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs @@ -474,7 +474,7 @@ pub fn compact_cutoff_sequence( /// `sequence >= compact_from_sequence`. Without a boundary, all rows pass /// through unchanged. Boundary rows other than the latest are always /// skipped. The DB row keeps its `system` role — only the LLM view remaps. -fn visible_rows(messages: &[AgentMessageRow]) -> Vec { +pub(crate) fn visible_rows(messages: &[AgentMessageRow]) -> Vec { let latest_boundary = messages .iter() .filter(|m| m.compact_from_sequence.is_some()) @@ -505,7 +505,7 @@ fn visible_rows(messages: &[AgentMessageRow]) -> Vec { /// into the OpenAI-compatible JSON array. Lifted out of /// `load_llm_history` so the unit tests below can exercise it without a /// SQLite round-trip. -fn reconstruct(messages: &[AgentMessageRow]) -> Vec { +pub(crate) fn reconstruct(messages: &[AgentMessageRow]) -> Vec { let mut result: Vec = Vec::with_capacity(messages.len()); // Collect consecutive tool_calls into batches diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs index b01e7f383f..af3f2911f3 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs @@ -31,3 +31,4 @@ pub use load_llm::{ compact_cutoff_sequence, load_llm_history, load_llm_history_start_sequences, load_llm_history_text_only, load_llm_history_text_only_bounded, turns_since_last_tool_call, }; +pub(crate) use load_llm::{reconstruct, visible_rows}; diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index fe6aa23cd1..ea16241107 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -124,7 +124,6 @@ pub(super) async fn handle_command( push_debug_outbound(state, &reply).await; Ok(None) } - async fn handle_model_command( state: &AgentAppState, _msg: &InboundMessage, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs b/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs index 4c36a6db70..1b1840f31a 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs @@ -497,7 +497,11 @@ pub(crate) async fn plan_approval_response_impl( treat their next message as feedback for a new plan.", snapshot.plan_title ); - if let Err(err) = session_persistence::save_user_msg(&session_id, &rejection_note, None) { + if let Err(err) = session_persistence::save_user_msg_and_assign_journey( + &session_id, + &rejection_note, + None, + ) { tracing::warn!( "[plan_approval] Failed to persist plan-rejection note for {}: {}", session_id, 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/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/edit_extraction_tests.rs b/src-tauri/crates/orgtrack-core/src/edit_extraction_tests.rs index 710930a9f5..74bfede996 100644 --- a/src-tauri/crates/orgtrack-core/src/edit_extraction_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/edit_extraction_tests.rs @@ -10,6 +10,7 @@ fn context(sequence_index: i64) -> EditArtifactContext { session_id: "session-1".to_string(), source_event_id: Some(format!("event-{sequence_index}")), turn_id: None, + execution_turn_id: None, sequence_index, timestamp: None, workspace_path: Some("/repo".to_string()), 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-core/src/store/sqlite/tests.rs b/src-tauri/crates/orgtrack-core/src/store/sqlite/tests.rs index ca73f2371a..e34ec4dce5 100644 --- a/src-tauri/crates/orgtrack-core/src/store/sqlite/tests.rs +++ b/src-tauri/crates/orgtrack-core/src/store/sqlite/tests.rs @@ -2,9 +2,10 @@ use rusqlite::{params, Connection, OptionalExtension}; use super::SqliteRecordStore; use crate::canonical::{ - AgentMetadata, ArtifactQuality, AttributionPrecision, FileResourceRecord, ResourceAction, - ResourceInteractionCaptureMethod, ResourceInteractionOutcome, ResourceInteractionRecord, - SessionEditArtifactRecord, SessionEditKind, SessionRecord, RESOURCE_INTERACTION_SCHEMA_VERSION, + AgentMetadata, ArtifactQuality, AttributionPrecision, FileResourceRecord, JourneyMetadata, + ResourceAction, ResourceInteractionCaptureMethod, ResourceInteractionOutcome, + ResourceInteractionRecord, SessionEditArtifactRecord, SessionEditKind, SessionRecord, + RESOURCE_INTERACTION_SCHEMA_VERSION, }; use crate::privacy::ORGTRACK_SCHEMA_VERSION; use crate::store::RecordStore; @@ -108,6 +109,7 @@ fn init_tables_backfills_parent_identity_before_creating_parent_index() { org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: JourneyMetadata::default(), }; let child_payload = serde_json::to_string(&child).expect("serialize child"); conn.execute( @@ -197,6 +199,7 @@ fn edit_artifacts_are_upserted_listed_and_deleted_by_session() { session_id: "session-1".to_string(), source_event_id: Some("event-1".to_string()), turn_id: Some("turn-1".to_string()), + execution_turn_id: None, sequence_index: 1, timestamp: Some("2026-06-15T00:00:00Z".to_string()), workspace_path: Some("/repo".to_string()), @@ -360,6 +363,7 @@ fn file_resource_interaction_pages_keep_root_and_child_sessions_together() { org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: JourneyMetadata::default(), }) .expect("upsert paged session"); } @@ -507,6 +511,7 @@ fn recent_hook_signals_return_newest_hook_facts_with_paths() { org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: JourneyMetadata::default(), }; // A placeholder title (equal to the raw source id) is suppressed so a diff --git a/src-tauri/crates/orgtrack-core/src/sync_export.rs b/src-tauri/crates/orgtrack-core/src/sync_export.rs index 21218884ad..95a86f06bd 100644 --- a/src-tauri/crates/orgtrack-core/src/sync_export.rs +++ b/src-tauri/crates/orgtrack-core/src/sync_export.rs @@ -202,7 +202,7 @@ fn agent_kind(session: &SessionRecord) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{AgentMetadata, ArtifactQuality}; + use crate::{AgentMetadata, ArtifactQuality, JourneyMetadata}; #[test] fn exports_session_evidence_with_net_final_diff_totals() { @@ -222,6 +222,7 @@ mod tests { org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: JourneyMetadata::default(), }; let final_diffs = vec![SessionFinalDiffRecord { schema_version: 1, 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..a8c3f51761 --- /dev/null +++ b/src-tauri/crates/orgtrack-graph/src/audit.rs @@ -0,0 +1,49 @@ +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()) + // 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())) + .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..90a7387fc6 --- /dev/null +++ b/src-tauri/crates/orgtrack-graph/src/journey.rs @@ -0,0 +1,438 @@ +//! 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, + Branch, + Task, + Checkpoint, + Review, + Annotation, + ConfirmedFact, + MessageAnchor, + 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, + AnchoredAt, + Reviews, + PromotedFrom, + EvidenceAt, +} + +#[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, + ), + )?; + } + // Work items remain optional, independent metadata. They must never + // become a containment hop between a project and its sessions. + for w in &input.work_items { + require_id(&w.id, "work item id")?; + if !ids.contains(&format!("project/{}", w.project_id)) { + return Err(format!("work item {} has unknown project", w.id)); + } + require_id(&w.source_ref, "work item source reference")?; + } + 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(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].into_iter().flatten() { + 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(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..fedf62256e --- /dev/null +++ b/src-tauri/crates/orgtrack-graph/src/journey_tests.rs @@ -0,0 +1,166 @@ +use crate::{ + audit_canonical_journey, project_canonical_journey, ArtifactRelation, CanonicalArtifact, + CanonicalJourneyInput, CanonicalProject, CanonicalSession, CanonicalTurn, CanonicalWorkItem, + CoverageStatus, JourneyEdgeKind, JourneyScope, 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")); +} + +#[test] +fn projector_contains_sessions_directly_under_projects_and_never_under_work_items() { + let graph = project_canonical_journey(&input()).unwrap(); + assert!(graph.edges.iter().any(|edge| { + edge.from == "project/p" + && edge.to == "session/parent" + && edge.kind == JourneyEdgeKind::Contains + })); + assert!(graph.edges.iter().all(|edge| { + !(edge.kind == JourneyEdgeKind::Contains + && (edge.from.starts_with("work_item/") || edge.to.starts_with("work_item/"))) + })); +} +#[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())); +} + +#[test] +fn required_project_and_strict_turn_sequence_fail_closed() { + let mut canonical = input(); + canonical.sessions[0].project_id.clear(); + + assert!(project_canonical_journey(&canonical).is_err()); + + let mut duplicate_sequence = input(); + duplicate_sequence.turns[1].sequence = duplicate_sequence.turns[0].sequence; + assert!(project_canonical_journey(&duplicate_sequence).is_err()); +} + +#[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/lib.rs b/src-tauri/crates/orgtrack-graph/src/lib.rs index 0d73bc83fd..511f5cb91a 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 query::{GraphDirection, GraphNeighbor, JourneyScope}; pub use store::GraphStore; + +#[cfg(test)] +mod journey_tests; 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/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)] diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/mod.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/mod.rs index fa7678d47d..96d5fe77be 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/mod.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/mod.rs @@ -216,6 +216,7 @@ mod runtime_artifact_tests { session_id: "sdeagent-1".to_string(), source_event_id: Some("tool-call-1".to_string()), turn_id: Some("turn-1".to_string()), + execution_turn_id: None, sequence_index: 7, timestamp: Some("2026-06-17T00:00:00Z".to_string()), workspace_path: Some("/tmp/repo".to_string()), 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. Desktop +/// resolves provenance from the durable session/key records so UI callers +/// cannot omit or forge the runtime identity. +#[tauri::command] +pub async fn journey_fork_close( + request: RequestForkCloseRequest, + job_id: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let session = agent_core::session::persistence::get_session(&request.session_id) + .map_err(|error| format!("无法读取会话运行来源:{error}"))? + .ok_or_else(|| "无法读取会话运行来源:会话不存在。".to_string())?; + let model_id = session + .model + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "无法关闭分叉:会话缺少模型来源。".to_string())?; + let account_id = session + .account_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "无法关闭分叉:会话缺少账户来源。".to_string())?; + let protocol = key_vault::key_store::KEY_SERVICE + .get_key_by_id(&account_id) + .and_then(|key| key.protocol) + .map(|protocol| protocol.as_str().to_string()) + .ok_or_else(|| "无法关闭分叉:账户缺少协议来源。".to_string())?; + let provenance = RuntimeProvenance { + model_id, + account_id, + protocol, + }; + 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}"))? +} + +/// Re-queues a failed review using the same durable application service and +/// compare-and-swap revision contract as every other Journey mutation. +#[tauri::command] +pub async fn journey_review_retry( + request: RetryReviewRequest, +) -> 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, +) -> 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_fork_compare(session_id: String) -> Result { + tokio::task::spawn_blocking(move || { + let conn = open_connection()?; + SessionJourneyApplicationService::fork_compare(&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, ForkCompareGroup, ForkCompareItem, ForkCompareResponse, ForkCompareTask, + PromoteFactRequest, RequestForkCloseRequest, RetryReviewRequest, 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: Some("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(), + serde_json::to_value(RetryReviewRequest { + 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}"); + } + let direct_fork = 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: None, + }) + .unwrap(); + assert!( + !direct_fork + .as_object() + .unwrap() + .contains_key("anchorMessageId"), + "omitting the desktop direct-Fork anchor must serialize as an absent optional field" + ); + } + + #[test] + fn fork_compare_dto_uses_the_same_snake_case_fields_as_the_typed_client() { + let payload = serde_json::to_value(ForkCompareResponse { + groups: vec![ForkCompareGroup { + parent_branch_id: "main".into(), + parent_anchor_message_id: Some("message-1".into()), + anchor_sequence: 1, + forks: vec![ForkCompareItem { + branch_id: "fork-a".into(), + branch_name: "fork-a".into(), + state: agent_core::core::journey_lifecycle::ForkState::Active, + tasks: vec![ForkCompareTask { + task_id: "task-a".into(), + name: "核对".into(), + state: agent_core::core::journey_lifecycle::TaskState::Active, + outcome: None, + }], + task_outcome: None, + conclusion: None, + unresolved: vec![], + evidence: vec![], + }], + }], + }) + .unwrap(); + let group = &payload["groups"][0]; + let fork = &group["forks"][0]; + let task = &fork["tasks"][0]; + for key in [ + "parent_branch_id", + "parent_anchor_message_id", + "anchor_sequence", + ] { + assert!(group.get(key).is_some(), "missing group key {key}"); + } + for key in ["branch_id", "branch_name", "task_outcome"] { + assert!(fork.get(key).is_some(), "missing fork key {key}"); + } + assert!(task.get("task_id").is_some()); + assert!(task.get("taskId").is_none()); + } + + #[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_retry", + "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 1b5c8bc863..45d75659c5 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -321,6 +321,21 @@ 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, +// 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_retry, +agent_sessions::journey_commands::journey_review_list, +agent_sessions::journey_commands::journey_fork_compare, +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, @@ -1247,6 +1262,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-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.rs b/src-tauri/src/orgtrack/journey.rs new file mode 100644 index 0000000000..3406b98757 --- /dev/null +++ b/src-tauri/src/orgtrack/journey.rs @@ -0,0 +1,256 @@ +//! 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 database::db::get_projects_connection; +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; +use rusqlite::OptionalExtension; + +/// Build the canonical Journey graph for a scope. +/// +/// Scope semantics (no guessing, no synthesized data): +/// - `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( + 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) => { + // 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 { + 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()); + } + } + } + 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) +} + +/// 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}"), + JourneyScope::Session(id) => format!("session/{id}"), + } +} diff --git a/src-tauri/src/orgtrack/journey_canonical.rs b/src-tauri/src/orgtrack/journey_canonical.rs new file mode 100644 index 0000000000..5fd6474b1d --- /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..7c7e4b210d --- /dev/null +++ b/src-tauri/src/orgtrack/journey_lifecycle_graph.rs @@ -0,0 +1,465 @@ +//! 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_core::core::journey_lifecycle::SessionJourney; + + #[test] + fn lifecycle_nodes_attach_to_the_canonical_session_not_a_work_item() { + let conn = Connection::open_in_memory().unwrap(); + let mut graph = JourneyGraph { + nodes: vec![JourneyNode { + id: "session/s".into(), + kind: JourneyNodeKind::Session, + evidence_class: EvidenceClass::Canonical, + source_ref: "canonical-session:s".into(), + display_timestamp: None, + }], + ..JourneyGraph::default() + }; + + append(&conn, &mut graph, &SessionJourney::new("s", "main")).unwrap(); + + assert!(graph.edges.iter().any(|edge| { + edge.from == "session/s" + && edge.to == "journey/s/branch/main" + && edge.kind == JourneyEdgeKind::Contains + })); + assert!(graph.edges.iter().all(|edge| { + !(edge.kind == JourneyEdgeKind::Contains + && (edge.from.starts_with("work_item/") || edge.to.starts_with("work_item/"))) + })); + } +} diff --git a/src-tauri/src/orgtrack/mod.rs b/src-tauri/src/orgtrack/mod.rs index fb47d6c30d..488e411bfc 100644 --- a/src-tauri/src/orgtrack/mod.rs +++ b/src-tauri/src/orgtrack/mod.rs @@ -7,6 +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; @@ -36,6 +39,22 @@ use orgtrack_core::projectors::stats::{session_summaries, CoreSessionSummary}; use orgtrack_core::store::{sqlite::SqliteRecordStore, RecordStore}; use types::OrgtrackTier; +/// 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 { + 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, &conn, &parsed) + }) + .await + .map_err(|err| err.to_string())? +} + #[tauri::command] pub async fn orgtrack_initialize( repo_path: String, @@ -170,10 +189,11 @@ pub async fn orgtrack_get_extraction_memory_gate( mod tests { use super::{is_temporary_diff_path, project_file_session_history}; use orgtrack_core::canonical::{ - AgentMetadata, AttributionPrecision, CollaborationSessionOrigin, ResourceAction, - ResourceInteractionCaptureMethod, ResourceInteractionOutcome, ResourceInteractionRecord, - SessionActorRecord, SessionRecord, RESOURCE_INTERACTION_SCHEMA_VERSION, - SESSION_ACTOR_SCHEMA_VERSION, SESSION_PROVENANCE_HOOK_ORIGIN, + AgentMetadata, AttributionPrecision, CollaborationSessionOrigin, JourneyMetadata, + ResourceAction, ResourceInteractionCaptureMethod, ResourceInteractionOutcome, + ResourceInteractionRecord, SessionActorRecord, SessionRecord, + RESOURCE_INTERACTION_SCHEMA_VERSION, SESSION_ACTOR_SCHEMA_VERSION, + SESSION_PROVENANCE_HOOK_ORIGIN, }; use orgtrack_core::privacy::ORGTRACK_SCHEMA_VERSION; use orgtrack_core::store::{sqlite::SqliteRecordStore, RecordStore}; @@ -224,6 +244,7 @@ mod tests { origin: Some(SESSION_PROVENANCE_HOOK_ORIGIN.to_string()), ..AgentMetadata::default() }, + journey: JourneyMetadata::default(), }) .expect("upsert session"); @@ -309,6 +330,7 @@ mod tests { org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: JourneyMetadata::default(), }) .expect("upsert root session"); store @@ -389,6 +411,7 @@ mod tests { org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: JourneyMetadata::default(), }) .expect("upsert session"); } @@ -487,6 +510,7 @@ mod tests { org_member_id: None, collaboration_origin: None, metadata: AgentMetadata::default(), + journey: JourneyMetadata::default(), }) .expect("upsert session"); } diff --git a/src-tauri/src/orgtrack/session_provenance.rs b/src-tauri/src/orgtrack/session_provenance.rs index b9a1df4270..3315c39259 100644 --- a/src-tauri/src/orgtrack/session_provenance.rs +++ b/src-tauri/src/orgtrack/session_provenance.rs @@ -116,6 +116,7 @@ fn persist_actor_lifecycle( origin: Some(SESSION_PROVENANCE_HOOK_ORIGIN.to_string()), ..AgentMetadata::default() }, + journey: Default::default(), }, }; if root_transcript.is_some() { @@ -168,6 +169,7 @@ fn persist_actor_lifecycle( display_name: envelope.actor_type.clone(), ..AgentMetadata::default() }, + journey: Default::default(), }, }; store.upsert_session(&child)?; @@ -390,6 +392,7 @@ fn persist_envelope( origin: Some(SESSION_PROVENANCE_HOOK_ORIGIN.to_string()), ..AgentMetadata::default() }, + journey: Default::default(), })?; } diff --git a/src-tauri/src/orgtrack/session_provenance/collaboration_replay.rs b/src-tauri/src/orgtrack/session_provenance/collaboration_replay.rs index 8a22c50eda..903078ff31 100644 --- a/src-tauri/src/orgtrack/session_provenance/collaboration_replay.rs +++ b/src-tauri/src/orgtrack/session_provenance/collaboration_replay.rs @@ -108,6 +108,7 @@ pub(crate) fn index_collaboration_replay( display_name: Some(owner_display_name.to_string()), ..AgentMetadata::default() }, + journey: Default::default(), }; let preflight_current = { 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/journeyGraph/index.ts b/src/api/tauri/journeyGraph/index.ts new file mode 100644 index 0000000000..bc591e13b2 --- /dev/null +++ b/src/api/tauri/journeyGraph/index.ts @@ -0,0 +1,66 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type JourneyScope = `project/${string}` | `session/${string}`; +export type EvidenceClass = + | "canonical" + | "derivedRule" + | "aiAnnotation" + | "userOverlay"; +export interface JourneyNodeMetadata { + agentIdentity?: string | null; + agentBand?: string | null; + topicTags: string[]; +} +export interface JourneyGraphNode { + id: string; + kind: string; + evidenceClass: EvidenceClass; + sourceRef: string; + displayTimestamp?: string | null; + metadata?: JourneyNodeMetadata; +} +export interface JourneyGraphEdge { + from: string; + to: string; + kind: string; + evidenceClass: EvidenceClass; + sourceRef: string; +} +export type JourneyCoverageStatus = + | "represented" + | "uncovered" + | { mergedInto: { target: string } } + | { excluded: { reason: string } }; +export interface JourneyCoverage { + sourceRef: string; + status: JourneyCoverageStatus; +} +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/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/api/tauri/sessionJourney/index.test.ts b/src/api/tauri/sessionJourney/index.test.ts new file mode 100644 index 0000000000..aede3ba5cd --- /dev/null +++ b/src/api/tauri/sessionJourney/index.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { sessionJourneyApi } from "."; + +const { invoke } = vi.hoisted(() => ({ 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", + ]); + }); + + 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" + ); + await sessionJourneyApi.retryReview({ + sessionId: "s", + expectedRevision: 4, + reviewId: "r", + }); + expect(invoke.mock.calls.map(([command]) => command)).toEqual([ + "journey_fork_close", + "journey_review_retry", + ]); + }); + + it("uses the shared durable fork-compare command", async () => { + await sessionJourneyApi.forkCompare("s"); + expect(invoke).toHaveBeenCalledWith("journey_fork_compare", { + sessionId: "s", + }); + }); +}); diff --git a/src/api/tauri/sessionJourney/index.ts b/src/api/tauri/sessionJourney/index.ts new file mode 100644 index 0000000000..8a24ad30fc --- /dev/null +++ b/src/api/tauri/sessionJourney/index.ts @@ -0,0 +1,209 @@ +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 ForkCompareItem { + branch_id: string; + branch_name: string; + state: string; + tasks: Array<{ + task_id: string; + name: string; + state: string; + outcome: TaskOutcome | null; + }>; + task_outcome: TaskOutcome | null; + conclusion: string | null; + unresolved: string[]; + evidence: string[]; +} +export interface ForkCompareGroup { + parent_branch_id: string; + parent_anchor_message_id: string | null; + anchor_sequence: number; + forks: ForkCompareItem[]; +} +export interface ForkCompareResponse { + groups: ForkCompareGroup[]; +} + +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; + /** + * An explicit durable user-message anchor. Omit only for the dedicated + * direct-Fork flow: the backend then resolves the latest durable user + * message and verifies that it belongs to the active branch atomically. + */ + anchorMessageId?: string | null; +} +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; +} +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; +} + +/** 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 }), + closeFork: (request: ForkCloseRequest, jobId: string) => + invoke<{ job_id: string; state: string }>("journey_fork_close", { + request, + jobId, + }), + retryReview: (request: RetryReviewRequest) => + invoke("journey_review_retry", { request }), + reviews: (sessionId: string) => + invoke("journey_review_list", { sessionId }), + forkCompare: (sessionId: string) => + invoke("journey_fork_compare", { 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_message_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/engines/ChatPanel/ChatHistory/ChatHistory.types.ts b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts index a910eed918..fd099c77dc 100644 --- a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts +++ b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts @@ -45,6 +45,8 @@ export const EMPTY_BROWSER_ADD_TO_CONVERSATION_NAV: BrowserAddToConversationNavS }; export interface ChatHistoryProps { + /** Exact durable event/message target carried by the owning chat tab. */ + initialMessageId?: string; /** Opaque background class for sticky headers. Must match the container surface. */ surfaceBgClass?: string; /** Dock side of the containing chat panel, used by narrow side previews. */ diff --git a/src/engines/ChatPanel/ChatHistory/__tests__/exactTargetNavigation.test.ts b/src/engines/ChatPanel/ChatHistory/__tests__/exactTargetNavigation.test.ts new file mode 100644 index 0000000000..ab1d3c30b8 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/__tests__/exactTargetNavigation.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + resolveExactHistoryTarget, + resolveExactHistoryTargetDisplayIndex, + resolveExactHistoryTargetFlatIndex, +} from "../exactTargetNavigation"; + +const event = (id: string) => ({ id }) as SessionEvent; + +describe("exact history target navigation", () => { + const originalToFlatIndex = new Map([ + [0, 0], + [1, 3], + [2, 8], + ]); + + it("maps a plain projected event through the projection index", () => { + expect( + resolveExactHistoryTargetFlatIndex( + [{ chunk_id: "one", type: "activity", event: event("one") }], + new Map([[0, 4]]), + "one" + ) + ).toBe(4); + }); + + it("keeps a user header target distinct from the first body row", () => { + const header = { + chunk_id: "header", + type: "activity", + event: event("user-anchor"), + } as never; + const body = { + chunk_id: "body", + type: "activity", + event: event("body-event"), + } as never; + expect( + resolveExactHistoryTarget( + [header, body], + [header], + [1], + new Map([ + [0, 0], + [1, 0], + ]), + "user-anchor" + ) + ).toEqual({ kind: "header", groupIndex: 0, groupId: "user-anchor" }); + expect( + resolveExactHistoryTarget( + [header, body], + [header], + [1], + new Map([ + [0, 0], + [1, 0], + ]), + "body-event" + ) + ).toEqual({ kind: "body", flatIndex: 0, groupIndex: 0 }); + }); + + it("maps consolidated and grouped nested events to their owning display row", () => { + const items = [ + { + chunk_id: "consolidated", + type: "activity", + consolidatedParts: 2, + event: event("consolidated-tail"), + consolidatedEvents: [ + event("consolidated-first"), + event("consolidated-second"), + ], + }, + { + chunk_id: "summary", + type: "actionSummaryGroup", + actionSummaryItems: [ + { category: "read", event: event("nested-summary") }, + ], + }, + { + chunk_id: "stack", + type: "activityStackGroup", + activityStackGroup: { + category: "browser", + events: [event("nested-stack")], + }, + }, + ] as never; + + expect( + resolveExactHistoryTargetFlatIndex( + items, + originalToFlatIndex, + "consolidated-second" + ) + ).toBe(0); + expect( + resolveExactHistoryTargetFlatIndex( + items, + originalToFlatIndex, + "nested-summary" + ) + ).toBe(3); + expect( + resolveExactHistoryTargetFlatIndex( + items, + originalToFlatIndex, + "nested-stack" + ) + ).toBe(8); + }); + + it("does not invent a target when filtering removed the durable event", () => { + expect( + resolveExactHistoryTargetFlatIndex( + [{ chunk_id: "visible", type: "activity", event: event("visible") }], + new Map([[0, 0]]), + "filtered-out" + ) + ).toBeNull(); + }); + + it("converts a global target to the selected page-local offset", () => { + const pages = [ + { + startGroupIndex: 0, + endGroupIndex: 0, + flatStartIndex: 0, + flatEndIndex: 3, + cursorIdeSummary: null, + }, + { + startGroupIndex: 1, + endGroupIndex: 1, + flatStartIndex: 3, + flatEndIndex: 9, + cursorIdeSummary: null, + }, + ]; + + expect(resolveExactHistoryTargetDisplayIndex(8, pages, 1, true)).toBe(5); + expect(resolveExactHistoryTargetDisplayIndex(8, pages, 0, true)).toBeNull(); + expect(resolveExactHistoryTargetDisplayIndex(8, pages, 0, false)).toBe(8); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts index 3401710d08..5f76df2a6d 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts @@ -392,6 +392,7 @@ export function processChatItems( }, }, consolidatedParts: partialBuffer.length, + consolidatedEvents: bufferEvents, }); } else { result.push(...partialBuffer.map((entry) => entry.item)); diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/types.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/types.ts index 8764b43424..dcb0cd06c3 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/types.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/types.ts @@ -57,6 +57,10 @@ export interface OptimizedChatItem { }; /** For consolidated partial observations */ consolidatedParts?: number; + /** Source events represented by a consolidated partial-observation row. */ + consolidatedEvents?: SessionEvent[]; + /** Number of consecutive identical errors collapsed into this item (≥2 means repeats were folded) */ + repeatedErrorCount?: number; /** Internal layout-only row used to keep a collapsed turn measurable. */ structuralOnly?: boolean; /** Thread selector synthetic data */ diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index a14f471abc..ba08f1605f 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -93,6 +93,8 @@ const ChatHistoryList: React.FC = memo( virtualScrollerRef, staticScrollerRef, newEventDividerLabel = null, + exactTargetDisplayIndex = null, + exactTargetGroupIndex = null, }) => { // Planning indicator state in refs so polling ticks don't invalidate // renderGroupItem's useCallback (Root Cause 2 fix). @@ -344,25 +346,37 @@ const ChatHistoryList: React.FC = memo( } const rowMeta = rowGroupMetaRef.current[flatIndex] ?? EMPTY_ROW_GROUP_META; + const isExactTarget = flatIndex === exactTargetDisplayIndex; return ( - +
+ +
); }, [ @@ -374,6 +388,7 @@ const ChatHistoryList: React.FC = memo( onSkip, onEditUserMessage, newEventDividerLabel, + exactTargetDisplayIndex, ] ); @@ -414,7 +429,25 @@ const ChatHistoryList: React.FC = memo( className="relative" data-chat-group-index={groupIndex} > -
+
{renderGroupHeaderProp(groupIndex, "user")}
@@ -436,26 +469,43 @@ const ChatHistoryList: React.FC = memo( `static-chat-${itemFlatIndex}`; const rowMeta = rowGroupMeta[itemFlatIndex] ?? EMPTY_ROW_GROUP_META; + const isExactTarget = + itemFlatIndex === exactTargetDisplayIndex; return ( - + data-exact-history-target={ + isExactTarget ? "true" : undefined + } + aria-current={isExactTarget ? "true" : undefined} + aria-label={ + isExactTarget ? "Exact history target" : undefined + } + className={ + isExactTarget + ? "rounded border border-primary-6 bg-primary-1/30" + : undefined + } + > + +
); })}
@@ -504,7 +554,29 @@ const ChatHistoryList: React.FC = memo( transform: `translateY(${virtualItem.start}px)`, }} > -
+
{renderGroupHeaderProp(group.groupIndex, "user")}
diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts index 47b38bbf82..8a82e952e2 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts @@ -251,6 +251,14 @@ export function sameChatHistoryListProps( "newEventDividerLabel", previous.newEventDividerLabel === next.newEventDividerLabel, ], + [ + "exactTargetDisplayIndex", + previous.exactTargetDisplayIndex === next.exactTargetDisplayIndex, + ], + [ + "exactTargetGroupIndex", + previous.exactTargetGroupIndex === next.exactTargetGroupIndex, + ], ]; return checks.every(([, same]) => same); } diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts index 4c3258532d..ce2661be60 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts @@ -92,6 +92,10 @@ export interface ChatHistoryListProps { * keeps the divider off (default for the main chat panel). */ newEventDividerLabel?: string | null; + /** Current page-local row index for an exact durable history target. */ + exactTargetDisplayIndex?: number | null; + /** Current page-local group whose user header is the exact durable target. */ + exactTargetGroupIndex?: number | null; } export interface VirtualGroup { diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index b94db2a7ef..8480019076 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -56,6 +56,9 @@ interface ChatHistoryViewProps { chatPanelPosition: "left" | "right"; displayMode: ChatHistoryDisplayMode; emptyState: UseChatEmptyStateReturn; + exactTargetDisplayIndex: number | null; + exactTargetGroupIndex: number | null; + exactTargetSourceGroupIndex: number | null; groupChatEnabled: boolean; groupChatViewActive: boolean; groupChatViewAvailable: boolean; @@ -63,6 +66,7 @@ interface ChatHistoryViewProps { handleReloadSession: ReturnType; hideGroupUserMessage: boolean; historyState: UseChatHistoryStateReturn; + initialMessageId?: string; mutationActionsDisabled: boolean; navigation: NavigationModel; newEventDividerLabel: string | null; @@ -91,6 +95,9 @@ const ChatHistoryView: React.FC = ({ chatPanelPosition, displayMode, emptyState, + exactTargetDisplayIndex, + exactTargetGroupIndex, + exactTargetSourceGroupIndex, groupChatEnabled, groupChatViewActive, groupChatViewAvailable, @@ -98,6 +105,7 @@ const ChatHistoryView: React.FC = ({ handleReloadSession, hideGroupUserMessage, historyState, + initialMessageId, mutationActionsDisabled, navigation, newEventDividerLabel, @@ -306,6 +314,10 @@ const ChatHistoryView: React.FC = ({ onRestoreCheckpoint={ mutationActionsDisabled ? undefined : handleHeaderRestoreCheckpoint } + exactHistoryTarget={ + exactTargetSourceGroupIndex !== null && + exactTargetSourceGroupIndex === activePinnedSourceGroupIndex + } /> ); @@ -318,9 +330,21 @@ const ChatHistoryView: React.FC = ({ data-optimized-count={activeProjectionHistory.length} data-flat-count={displayTotalFlatItems} data-group-shape={projectionResult.groupShapeDigest} + data-exact-history-target={initialMessageId ?? ""} ref={chatContainerRef as React.RefObject} style={chatHistoryContainerStyle} > + {(exactTargetDisplayIndex !== null || + exactTargetGroupIndex !== null) && ( +
+ Exact history target +
+ )}
@@ -506,6 +530,8 @@ const ChatHistoryView: React.FC = ({ virtualScrollerRef={virtuosoScrollerRef} staticScrollerRef={staticScrollerRef} newEventDividerLabel={newEventDividerLabel} + exactTargetDisplayIndex={exactTargetDisplayIndex} + exactTargetGroupIndex={exactTargetGroupIndex} /> ) : ( diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatPinnedHeaderLayer.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatPinnedHeaderLayer.tsx index 686ddb1981..773f4f9758 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatPinnedHeaderLayer.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatPinnedHeaderLayer.tsx @@ -47,6 +47,7 @@ interface ChatPinnedHeaderLayerProps { turnCollapseInteractionAtRef: React.MutableRefObject; onEditSubmit: GroupHeaderRendererProps["onEditSubmit"]; onRestoreCheckpoint: GroupHeaderRendererProps["onRestoreCheckpoint"]; + exactHistoryTarget?: boolean; } const ChatPinnedHeaderLayer: React.FC = memo( @@ -89,6 +90,7 @@ const ChatPinnedHeaderLayer: React.FC = memo( turnCollapseInteractionAtRef, onEditSubmit, onRestoreCheckpoint, + exactHistoryTarget = false, }) => { if (!showTurnContextRow && !showPinnedTurnHeader) return null; @@ -139,6 +141,7 @@ const ChatPinnedHeaderLayer: React.FC = memo( turnCollapseInteractionAtRef={turnCollapseInteractionAtRef} onEditSubmit={onEditSubmit} onRestoreCheckpoint={onRestoreCheckpoint} + exactHistoryTarget={exactHistoryTarget} />
); diff --git a/src/engines/ChatPanel/ChatHistory/components/PinnedTurnHeader.tsx b/src/engines/ChatPanel/ChatHistory/components/PinnedTurnHeader.tsx index 55423c7be6..f94ba711bc 100644 --- a/src/engines/ChatPanel/ChatHistory/components/PinnedTurnHeader.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/PinnedTurnHeader.tsx @@ -20,6 +20,8 @@ interface PinnedTurnHeaderProps { turnCollapseInteractionAtRef: React.MutableRefObject; onEditSubmit: GroupHeaderRendererProps["onEditSubmit"]; onRestoreCheckpoint: GroupHeaderRendererProps["onRestoreCheckpoint"]; + /** Marks the actual pinned user-message header as the durable target. */ + exactHistoryTarget?: boolean; } function samePinnedHeader( @@ -70,6 +72,7 @@ function samePinnedTurnHeaderProps( next.turnCollapseInteractionAtRef && previous.onEditSubmit === next.onEditSubmit && previous.onRestoreCheckpoint === next.onRestoreCheckpoint && + previous.exactHistoryTarget === next.exactHistoryTarget && samePinnedHeader(previous.header, next.header) && samePinnedMeta(previous.meta, next.meta) ); @@ -88,11 +91,21 @@ const PinnedTurnHeaderComponent: React.FC = ({ turnCollapseInteractionAtRef, onEditSubmit, onRestoreCheckpoint, + exactHistoryTarget = false, }) => { if (!visible || !header) return null; return ( -
+
({ + GroupHeaderRenderer: () =>
, +})); + +describe("PinnedTurnHeader exact durable target", () => { + it("marks the actual pinned user header instead of the paginated list placeholder", () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('data-exact-history-target="true"'); + expect(markup).toContain('aria-current="true"'); + expect(markup).toContain('aria-label="Exact history target"'); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/exactTargetNavigation.ts b/src/engines/ChatPanel/ChatHistory/exactTargetNavigation.ts new file mode 100644 index 0000000000..ce2a6adacf --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/exactTargetNavigation.ts @@ -0,0 +1,106 @@ +import type { OptimizedChatItem } from "./chatItemPipeline/types"; +import type { ChatTurnPage } from "./hooks/useChatTurnPagination"; + +function itemContainsEventId( + item: OptimizedChatItem, + eventId: string +): boolean { + if (item.event?.id === eventId) return true; + if (item.consolidatedEvents?.some((event) => event.id === eventId)) { + return true; + } + if (item.readFileEvents?.some((event) => event.id === eventId)) return true; + if ( + item.actionSummaryEntries?.some((entry) => + entry.events.some((event) => event.id === eventId) + ) + ) { + return true; + } + if (item.actionSummaryItems?.some(({ event }) => event.id === eventId)) { + return true; + } + return ( + item.activityStackGroup?.events.some((event) => event.id === eventId) ?? + false + ); +} + +/** + * Resolves a durable event id to the global display flat index. Grouped pipeline + * items own their nested events, so their optimized-history index is the only + * valid input to the groups projection's original-to-flat mapping. + */ +export function resolveExactHistoryTargetFlatIndex( + optimizedChatHistory: readonly OptimizedChatItem[], + originalToFlatIndex: ReadonlyMap, + targetEventId: string | undefined +): number | null { + if (!targetEventId) return null; + const originalIndex = optimizedChatHistory.findIndex((item) => + itemContainsEventId(item, targetEventId) + ); + if (originalIndex < 0) return null; + return originalToFlatIndex.get(originalIndex) ?? null; +} + +export type ExactHistoryTarget = + | { kind: "header"; groupIndex: number; groupId: string | null } + | { kind: "body"; flatIndex: number; groupIndex: number }; + +/** + * A user turn header is intentionally absent from flatItems. Keep it distinct + * from body targets so a fork parent anchor cannot be painted on the first + * assistant/tool row in that turn. + */ +export function resolveExactHistoryTarget( + optimizedChatHistory: readonly OptimizedChatItem[], + groupHeaders: readonly (OptimizedChatItem | null)[], + groupCounts: readonly number[], + originalToFlatIndex: ReadonlyMap, + targetEventId: string | undefined +): ExactHistoryTarget | null { + if (!targetEventId) return null; + const headerGroupIndex = groupHeaders.findIndex( + (header) => header?.event?.id === targetEventId + ); + if (headerGroupIndex >= 0) { + return { + kind: "header", + groupIndex: headerGroupIndex, + groupId: groupHeaders[headerGroupIndex]?.event?.id ?? null, + }; + } + const flatIndex = resolveExactHistoryTargetFlatIndex( + optimizedChatHistory, + originalToFlatIndex, + targetEventId + ); + if (flatIndex === null) return null; + let remaining = flatIndex; + for (let groupIndex = 0; groupIndex < groupCounts.length; groupIndex++) { + remaining -= groupCounts[groupIndex] ?? 0; + if (remaining < 0) return { kind: "body", flatIndex, groupIndex }; + } + return null; +} + +/** Returns the currently rendered page-local row index for an exact target. */ +export function resolveExactHistoryTargetDisplayIndex( + globalFlatIndex: number | null, + pages: readonly ChatTurnPage[], + currentPageIndex: number, + paginationEnabled: boolean +): number | null { + if (globalFlatIndex === null) return null; + if (!paginationEnabled) return globalFlatIndex; + const page = pages[currentPageIndex]; + if ( + !page || + globalFlatIndex < page.flatStartIndex || + globalFlatIndex >= page.flatEndIndex + ) { + return null; + } + return globalFlatIndex - page.flatStartIndex; +} diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index 435ea29f57..135b7a6572 100644 --- a/src/engines/ChatPanel/ChatHistory/index.tsx +++ b/src/engines/ChatPanel/ChatHistory/index.tsx @@ -21,6 +21,10 @@ import { } from "./ChatHistory.types"; import { useGroupChatContext } from "./GroupChatView/GroupChatContext"; import ChatHistoryView from "./components/ChatHistoryView"; +import { + resolveExactHistoryTarget, + resolveExactHistoryTargetDisplayIndex, +} from "./exactTargetNavigation"; import { useChatEmptyState, useChatHistoryItemActions, @@ -59,6 +63,7 @@ function resolveSharedConversationSender(session: Session | undefined) { } const ChatHistory: React.FC = ({ + initialMessageId, surfaceBgClass = "bg-chat-pane", chatPanelPosition = "right", agentOrgCurrentMemberName = null, @@ -154,6 +159,98 @@ const ChatHistory: React.FC = ({ turnPaginationEnabled, virtualListRef: historyState.virtualListRef, }); + const exactTarget = useMemo( + () => + resolveExactHistoryTarget( + projection.activeProjectionHistory, + projection.groupHeaders, + projection.groupCounts, + projection.originalToFlatIndex, + initialMessageId + ), + [ + initialMessageId, + projection.activeProjectionHistory, + projection.groupCounts, + projection.groupHeaders, + projection.originalToFlatIndex, + ] + ); + const exactTargetPageIndex = useMemo(() => { + if (!exactTarget || !turnPaginationEnabled) return -1; + return projection.pages.findIndex((page) => + exactTarget.kind === "header" + ? exactTarget.groupIndex >= page.startGroupIndex && + exactTarget.groupIndex <= page.endGroupIndex + : exactTarget.flatIndex >= page.flatStartIndex && + exactTarget.flatIndex < page.flatEndIndex + ); + }, [exactTarget, projection.pages, turnPaginationEnabled]); + const exactTargetFlatIndex = + exactTarget?.kind === "body" ? exactTarget.flatIndex : null; + const exactTargetDisplayIndex = useMemo( + () => + resolveExactHistoryTargetDisplayIndex( + exactTargetFlatIndex, + projection.pages, + projection.currentPageIndex, + turnPaginationEnabled + ), + [ + exactTargetFlatIndex, + projection.currentPageIndex, + projection.pages, + turnPaginationEnabled, + ] + ); + const exactTargetGroupIndex = useMemo(() => { + if (!exactTarget) return null; + const displayIndex = projection.displaySourceGroupIndices.indexOf( + exactTarget.groupIndex + ); + return displayIndex >= 0 ? displayIndex : null; + }, [exactTarget, projection.displaySourceGroupIndices]); + const exactTargetCurrentPageIndex = projection.currentPageIndex; + const selectExactTargetPage = projection.selectTurnPage; + + useEffect(() => { + if (!exactTarget) return; + if ( + turnPaginationEnabled && + exactTargetPageIndex >= 0 && + exactTargetPageIndex !== exactTargetCurrentPageIndex + ) { + selectExactTargetPage(exactTargetPageIndex); + return; + } + const frame = window.requestAnimationFrame(() => { + if (exactTarget.kind === "header" && exactTargetGroupIndex !== null) { + historyState.virtualListRef.current?.scrollToGroup({ + groupIndex: exactTargetGroupIndex, + behavior: "auto", + }); + } else if ( + exactTarget.kind === "body" && + exactTargetDisplayIndex !== null + ) { + historyState.virtualListRef.current?.scrollToIndex({ + index: exactTargetDisplayIndex, + behavior: "auto", + align: "center", + }); + } + }); + return () => window.cancelAnimationFrame(frame); + }, [ + exactTarget, + exactTargetCurrentPageIndex, + exactTargetDisplayIndex, + exactTargetGroupIndex, + exactTargetPageIndex, + historyState.virtualListRef, + selectExactTargetPage, + turnPaginationEnabled, + ]); const emptyState = useChatEmptyState({ activeSessionId: activeId, sessionLoadStatus: historyState.sessionLoadStatus, @@ -214,6 +311,10 @@ const ChatHistory: React.FC = ({ bottomInset={bottomInset} chatPanelPosition={chatPanelPosition} displayMode={displayMode} + exactTargetDisplayIndex={exactTargetDisplayIndex} + exactTargetGroupIndex={exactTargetGroupIndex} + exactTargetSourceGroupIndex={exactTarget?.groupIndex ?? null} + initialMessageId={initialMessageId} emptyState={emptyState} groupChatEnabled={Boolean(groupChat?.enabled)} groupChatViewActive={groupChatViewActive} 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/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index c5e8f9eb01..ca6d323204 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -41,6 +41,7 @@ import { derivedSnapshotAtom } from "@src/engines/SessionCore/core/atoms/events" import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { derivePlanApprovalViewState } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { useTodoSync } from "@src/engines/SessionCore/hooks/session/useTodoSync"; +import { isBackendUserMessageEvent } from "@src/engines/SessionCore/sync/utils/activityIds"; import { useCloudSessionHasDownloadSurface } from "@src/features/Org2Cloud/useCloudSessionDownloadSurface"; import { ForkCancelledError } from "@src/features/TeamCollaboration/forkSession"; import { useFileReviewSync } from "@src/hooks/fileReview"; @@ -48,6 +49,7 @@ import { createLogger } from "@src/hooks/logger"; import { usePendingPlanApproval } from "@src/hooks/session/usePendingPlanApproval"; import { useSessionWorkspaceSync } from "@src/hooks/session/useSessionWorkspaceSync"; import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; +import { SessionJourneyControls } from "@src/modules/WorkStation/Chat/Journey/SessionJourneyControls"; import { loadSessions, sessionByIdAtom } from "@src/store/session"; import { isSessionActiveAtom, @@ -100,6 +102,7 @@ export type { ChatViewProps } from "./ChatViewTypes"; const ChatView: React.FC = memo( ({ sessionId, + initialMessageId, onRegisterSearchOpen, displayMode = "full", turnPaginationEnabled = true, @@ -306,6 +309,17 @@ const ChatView: React.FC = memo( const canvasPreviewPill = useChatViewCanvasPreview(sessionId, snapshot); const currentPlanApproval = usePendingPlanApproval(sessionId); const chatEvents = snapshot?.chatEvents ?? EMPTY_CHAT_EVENTS; + // Journey lifecycle commands require an exact durable `agent_messages.id`. + // Use the frontend-only synthetic marker, rather than `user-input-*`, to + // exclude optimistic bubbles: real backend rows may legitimately use that + // ID prefix (for example after CLI recovery). + const latestUserMessageId = useMemo(() => { + for (let index = chatEvents.length - 1; index >= 0; index -= 1) { + const event = chatEvents[index]; + if (event && isBackendUserMessageEvent(event)) return event.id; + } + return null; + }, [chatEvents]); const isAgentWorking = useAtomValue(isSessionActiveAtom); const gitArtifactStats = useMemo( @@ -470,9 +484,21 @@ const ChatView: React.FC = memo( } data-chat-pinned-header-portal-host /> + {!isReadOnlySurface && ( +
+ +
+ )}
void) | null) => void; displayMode?: ChatHistoryDisplayMode; turnPaginationEnabled?: boolean; 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.map((project) => ( + + ))} + {matches.length === 0 ? ( +

No matching projects.

+ ) : null} +
+
+

Create Project

+
+ + +
+
+
+
+
, + document.body + ); +} diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index a54f3097da..89a120accb 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -483,6 +483,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", @@ -914,7 +915,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 faccb540b2..58174f1437 100644 --- a/src/i18n/locales/zh/projects.json +++ b/src/i18n/locales/zh/projects.json @@ -484,6 +484,7 @@ "openSession": "打开会话", "viewLiveChat": "查看实时 Chat", "viewConversation": "查看对话记录", + "viewSessionJourney": "会话旅程", "noWorkflowRun": "尚未运行 Agent 工作流", "confirmStart": "在此工作项上启动 SDE Agent?", "sdePhase": "SDE 开发", @@ -915,7 +916,9 @@ "empty": "未找到 Workspace 项目", "issues": "Issues", "views": "Views", - "more": "More" + "more": "More", + "projectTree": "项目树", + "projectJourney": "项目旅程" }, "teams": { "title": "你的团队", diff --git a/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx b/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx new file mode 100644 index 0000000000..b7e7da56c4 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/JourneyContainer.tsx @@ -0,0 +1,140 @@ +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: "时间线" }, + { key: "branches", label: "分叉" }, + { key: "files", label: "文件血缘" }, + { key: "coverage", label: "覆盖情况" }, +]; + +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 && ( +
+ 旅程不可用:{error} +
+ )} + {!error && !graph && ( +
正在加载旅程...
+ )} + {graph && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx new file mode 100644 index 0000000000..fa10e57e86 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneyPage.tsx @@ -0,0 +1,35 @@ +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; + selectedTaskId?: string; + selectedForkId?: string; + selectedAnchorMessageId?: string; +}> = ({ + sessionId, + sessionName, + selectedTaskId, + selectedForkId, + selectedAnchorMessageId, +}) => ( +
+ +
+ +
+
+); diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.contract.test.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.contract.test.tsx new file mode 100644 index 0000000000..86e7a3803b --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.contract.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +import { SessionJourneySnapshot } from "./SessionJourneySnapshot"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ snapshot: vi.fn() })); +vi.mock("@src/api/tauri/sessionJourney", () => ({ + sessionJourneyApi: { snapshot: mocks.snapshot }, +})); + +describe("SessionJourneySnapshot", () => { + it("clears the old session immediately and ignores its late completion", async () => { + let resolveFirst!: (value: unknown) => void; + let resolveSecond!: (value: unknown) => void; + mocks.snapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ); + mocks.snapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }) + ); + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => + root.render() + ); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + await act(async () => + root.render() + ); + expect(container.textContent).toContain("正在加载会话旅程"); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + await act(async () => + resolveFirst({ + snapshot: { + revision: 1, + tasks: {}, + branches: {}, + checkpoints: {}, + reviews: {}, + }, + }) + ); + expect(container.textContent).toContain("正在加载会话旅程"); + await act(async () => + resolveSecond({ + snapshot: { + revision: 2, + tasks: {}, + branches: {}, + checkpoints: {}, + reviews: {}, + }, + }) + ); + expect(container.textContent).toContain("修订 2"); + await act(async () => root.unmount()); + }); +}); diff --git a/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx new file mode 100644 index 0000000000..be305a3c9b --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/SessionJourneySnapshot.tsx @@ -0,0 +1,169 @@ +import { Flag, GitFork, ListChecks, MapPin } from "lucide-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import { + type JourneySnapshot, + sessionJourneyApi, +} from "@src/api/tauri/sessionJourney"; +import { requestJourneyMessageJump } from "@src/modules/WorkStation/Chat/Journey/journeyMessageJump"; + +const taskStateLabel = (state: string) => + ({ + active: "进行中", + finished: "已结束", + pending: "待开始", + pending_next_user: "等待下一条用户消息", + })[state] ?? state; +const outcomeLabel = (outcome: string) => + ({ + completed: "完成", + partially_completed: "部分完成", + paused: "暂停", + abandoned: "放弃", + redirected: "转向", + })[outcome] ?? outcome; +const forkStateLabel = (state: string) => + ({ + active: "进行中", + closing: "审核中", + close_failed: "关闭失败", + closed: "已关闭", + discarded: "已丢弃", + })[state] ?? state; +const reviewStateLabel = (state: string) => + ({ + queued: "等待审核", + ready: "可审核", + confirmed: "已确认", + discarded: "已丢弃", + failed: "审核失败", + })[state] ?? state; + +export const SessionJourneySnapshot: React.FC<{ + sessionId: string; + selectedTaskId?: string; + selectedForkId?: string; + selectedAnchorMessageId?: string; +}> = ({ + sessionId, + selectedTaskId, + selectedForkId, + selectedAnchorMessageId, +}) => { + const [snapshot, setSnapshot] = useState<{ + sessionId: string; + value: JourneySnapshot; + } | null>(null); + const [error, setError] = useState<{ + sessionId: string; + value: string; + } | null>(null); + const requestGenerationRef = useRef(0); + const reload = useCallback(async () => { + const generation = ++requestGenerationRef.current; + try { + const response = await sessionJourneyApi.snapshot(sessionId); + if (generation !== requestGenerationRef.current) return; + setSnapshot({ sessionId, value: response.snapshot }); + setError(null); + } catch (reason) { + if (generation !== requestGenerationRef.current) return; + setError({ sessionId, value: String(reason) }); + } + }, [sessionId]); + useEffect(() => { + // Invalidate previous requests before scheduling the new session fetch. + requestGenerationRef.current += 1; + const timer = window.setTimeout(() => void reload(), 0); + return () => { + window.clearTimeout(timer); + requestGenerationRef.current += 1; + }; + }, [sessionId, reload]); + const currentSnapshot = + snapshot?.sessionId === sessionId ? snapshot.value : null; + const currentError = error?.sessionId === sessionId ? error.value : null; + if (currentError) + return ( +

+ 旅程快照不可用:{currentError} +

+ ); + if (!currentSnapshot) + return

正在加载会话旅程...

; + return ( +
+
+ 会话旅程 + + 修订 {currentSnapshot.revision} + +
+ {selectedAnchorMessageId && ( +

+ 精确回溯锚点:{selectedAnchorMessageId} +

+ )} +
+ {Object.values(currentSnapshot.tasks).map((task) => ( +
+ + {task.name} +

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

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

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

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

+ {reviewStateLabel(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 new file mode 100644 index 0000000000..efb14be3d1 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/components.p2.test.ts @@ -0,0 +1,129 @@ +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", + 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", + }, + ], + 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"); + 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__/journeyGraph.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts new file mode 100644 index 0000000000..76c6da8d7c --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/journeyGraph.test.ts @@ -0,0 +1,47 @@ +import { invoke } from "@tauri-apps/api/core"; +import { describe, expect, it, vi } from "vitest"; + +import { + type JourneyGraphPayload, + journeyGraphQuery, +} from "@src/api/tauri/journeyGraph"; + +import { graphToJourneyViewModel } from "../viewModel"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +const payload: JourneyGraphPayload = { + nodes: [ + { + id: "session/s", + kind: "session", + evidenceClass: "canonical", + sourceRef: "session:s", + }, + ], + edges: [], + coverage: [{ sourceRef: "session:s", status: "represented" }], +}; +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/__tests__/timelineLayout.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts new file mode 100644 index 0000000000..7adf20577f --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/timelineLayout.test.ts @@ -0,0 +1,206 @@ +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, + topicTags: [], + }; +} + +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/__tests__/viewModel.p2.test.ts b/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts new file mode 100644 index 0000000000..b4bb652c1a --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/__tests__/viewModel.p2.test.ts @@ -0,0 +1,265 @@ +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", + 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", + }, + ], + 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(["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" + ), + }; + expect(graphToBranchesViewModel(graph).links).toEqual([]); + 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 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"); + }); +}); 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..2a1be936a3 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/components/StorylineTimeline.tsx @@ -0,0 +1,380 @@ +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, +}) => { + 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, toX, toY }) => ( + + + + + ))} + + {/* 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(({ 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 ( +
+
+ + ● + {" "} + {milestone.kind}: {milestone.title} +
+
+ {milestone.displayTimestamp} + {milestone.sequence !== null + ? ` · turn ${milestone.sequence}` + : ""} +
+ {milestone.evidenceClass === "canonical" && + milestone.topicTags.length > 0 && ( +
+ {milestone.topicTags.join(" · ")} +
+ )} + +
+ ); + }) + )} +
+
+ + {/* Legend for the visual grammar. */} +
+ {["forkedFrom", "resumedFrom", "compactedTo"].map((kind) => ( + + + + + {kind} + + ))} + + + + + stale lane tail + + + + + + idle compression + +
+ + {/* 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/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/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`; +} diff --git a/src/modules/ProjectManager/JourneyGraph/viewModel.ts b/src/modules/ProjectManager/JourneyGraph/viewModel.ts new file mode 100644 index 0000000000..f754c24176 --- /dev/null +++ b/src/modules/ProjectManager/JourneyGraph/viewModel.ts @@ -0,0 +1,363 @@ +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; + agentIdentity?: string | null; + agentBand?: string | null; + topicTags: string[]; +} + +export interface JourneyViewModel { + 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, + agentIdentity: node.metadata?.agentIdentity ?? null, + agentBand: node.metadata?.agentBand ?? null, + topicTags: node.metadata?.topicTags ?? [], + }; +} + +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 agentLaneForNode( + node: JourneyGraphNode, + nodes: JourneyGraphNode[], + edges: JourneyGraphEdge[] +): string | null { + 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 + ); +} + +/** Pure presentation mapping. It cannot infer or repair graph facts. */ +export function graphToJourneyViewModel( + graph: JourneyGraphPayload +): JourneyViewModel { + 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: null }); + continue; + } + const laneId = + agentLaneForNode(node, graph.nodes, edges) ?? "unknown-agent"; + 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: null })); + 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 === "unknown-agent" ? "Unknown agent" : 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 { + 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/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx b/src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/OrgSidebarTreeContent.tsx index ab6661af21..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; @@ -57,6 +59,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..5dc010bccd 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 { + Box, + Layers, + ListChecks, + Map, + MoreHorizontal, + Network, +} 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,7 +50,14 @@ 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); @@ -72,6 +90,30 @@ export const WorkspaceOrgTreeContent: React.FC = kind: "workspace-projects", }, }, + { + 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 +159,27 @@ 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" && !!onOpenProjectTree) || + (node.kind === "workspace-project-journey" && + !!onOpenProjectJourney); 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, + onOpenProjects, + onOpenWorkItems, + onOpenProjectTree, + onOpenProjectJourney, + ] ); 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..99874c3e97 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/ProjectJourneyPage.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +import type { JourneyScope } from "@src/api/tauri/journeyGraph"; +import { JourneyContainer } from "@src/modules/ProjectManager/JourneyGraph"; + +export interface ProjectJourneyPageProps { + projectId?: string; + projectSlug?: string; + projectName?: string; + forceDemo?: boolean; +} + +/** Read-only project wrapper over the shared Journey graph container. */ +const ProjectJourneyPage: React.FC = ({ + projectId, + projectSlug, + projectName, +}) => { + const identity = projectId ?? projectSlug; + if (!identity) + return ( +
+ 项目旅程不可用:缺少项目标识,拒绝猜测旅程图。 +
+ ); + return ( + + ); +}; + +export default ProjectJourneyPage; diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.contract.test.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.contract.test.tsx new file mode 100644 index 0000000000..ed9560e742 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.contract.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ProjectTreePage from "./ProjectTreePage"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("./loadProjectTree", () => ({ + loadProjectTreeBundle: vi.fn().mockResolvedValue({ + tree: { + id: "workspace:root", + kind: "workspace", + title: "Workspace", + children: [ + { + id: "project:p", + kind: "project", + title: "项目 P", + projectId: "p", + projectSlug: "project-p", + children: [ + { + id: "session:s", + kind: "session", + title: "没有工作项的会话", + sessionId: "session-1", + projectSlug: "project-p", + children: [ + { + id: "task:session-1:task-1", + kind: "task", + title: "Exact task", + sessionId: "session-1", + taskId: "task-1", + children: [ + { + id: "checkpoint:session-1:checkpoint-1", + kind: "checkpoint", + title: "Durable checkpoint", + sessionId: "session-1", + taskId: "task-1", + checkpointId: "checkpoint-1", + anchorMessageId: "message-8", + children: [], + }, + ], + }, + { + id: "fork:session-1:fork-1", + kind: "fork", + title: "fork-1", + sessionId: "session-1", + forkId: "fork-1", + anchorMessageId: "message-7", + anchorSequence: 7, + children: [], + }, + ], + }, + ], + }, + ], + }, + projects: [], + workItemsByProject: {}, + sessions: [], + standaloneWorkItems: [], + journeysBySessionId: new Map(), + usedDemo: false, + }), + streamSessionJourneys: vi.fn().mockResolvedValue(undefined), + loadSessionJourney: vi.fn(), +})); + +describe("ProjectTreePage production session route", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("keeps project and session journeys visible while preserving chat opening", async () => { + const onOpenJourney = vi.fn(); + const onOpenSession = vi.fn(); + const onOpenSessionJourney = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + expect(container.textContent).toContain("工作区 → 项目 → 会话 → 旅程"); + expect( + container.querySelector('[data-testid="project-tree-row-work_item"]') + ).toBeNull(); + + const projectJourneyButton = container.querySelector( + '[data-testid="project-tree-open-project-journey-p"]' + ) as HTMLButtonElement; + expect(projectJourneyButton).not.toBeNull(); + expect(projectJourneyButton.className).not.toContain("hidden"); + await act(async () => projectJourneyButton.click()); + expect(onOpenJourney).toHaveBeenCalledWith("p", "project-p", "项目 P"); + + const chatButton = container.querySelector( + '[data-testid="project-tree-open-session-session-1"]' + ) as HTMLButtonElement; + expect(chatButton).not.toBeNull(); + expect(chatButton.textContent).toBe("打开会话"); + await act(async () => chatButton.click()); + expect(onOpenSession).toHaveBeenCalledWith( + "session-1", + "没有工作项的会话", + undefined, + "project-p" + ); + + const sessionJourneyButton = container.querySelector( + '[data-testid="project-tree-open-session-journey-session-1"]' + ) as HTMLButtonElement; + expect(sessionJourneyButton).not.toBeNull(); + expect(sessionJourneyButton.className).not.toContain("hidden"); + await act(async () => sessionJourneyButton.click()); + expect(onOpenSessionJourney).toHaveBeenCalledWith( + "session-1", + "没有工作项的会话" + ); + + const checkpointButton = container.querySelector( + '[data-testid="project-tree-open-checkpoint-history-checkpoint-1"]' + ) as HTMLButtonElement; + await act(async () => checkpointButton.click()); + expect(onOpenSession).toHaveBeenCalledWith( + "session-1", + "Durable checkpoint", + undefined, + undefined, + "message-8" + ); + + const taskJourneyButton = container.querySelector( + '[data-testid="project-tree-open-task-journey-task-1"]' + ) as HTMLButtonElement; + await act(async () => taskJourneyButton.click()); + expect(onOpenSessionJourney).toHaveBeenCalledWith( + "session-1", + "Exact task", + { taskId: "task-1" } + ); + + const forkJourneyButton = container.querySelector( + '[data-testid="project-tree-open-fork-journey-fork-1"]' + ) as HTMLButtonElement; + await act(async () => forkJourneyButton.click()); + expect(onOpenSession).toHaveBeenCalledWith( + "session-1", + "fork-1", + undefined, + undefined, + "message-7" + ); + }); +}); diff --git a/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx new file mode 100644 index 0000000000..93c6a782c3 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/ProjectTreePage.tsx @@ -0,0 +1,505 @@ +import { + Box, + ChevronDown, + ChevronRight, + CircleDot, + Flag, + FolderTree, + GitBranch, + ListTodo, + RefreshCw, +} from "lucide-react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { + loadProjectTreeBundle, + loadSessionJourney, + streamSessionJourneys, +} from "./loadProjectTree"; +import { + type ProjectSessionJourneyState, + type ProjectTreeNode, + buildWorkspaceProjectTree, +} from "./model"; + +export interface ProjectTreePageProps { + onOpenJourney?: ( + projectId: string, + projectSlug?: string, + projectName?: string + ) => void; + onOpenWorkItem?: (workItemId: string, projectSlug?: string) => void; + onOpenSession?: ( + sessionId: string, + sessionTitle: string, + workItemId?: string, + projectSlug?: string, + initialMessageId?: string + ) => void; + onOpenSessionJourney?: ( + sessionId: string, + sessionTitle: string, + target?: { taskId?: string; forkId?: string; anchorMessageId?: 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 "task": + return ; + case "fork": + return ; + case "checkpoint": + return ; + case "unassigned": + return ; + default: + return null; + } +} + +const ProjectTreePage: React.FC = ({ + onOpenJourney, + onOpenWorkItem, + onOpenSession, + onOpenSessionJourney, +}) => { + 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 generationRef = useRef(0); + const bundleRef = useRef + > | null>(null); + const journeyStatesRef = useRef( + new Map() + ); + const journeyRenderTimerRef = useRef(null); + const journeyRenderGenerationRef = useRef(0); + + const applyJourneyState = useCallback( + ( + generation: number, + sessionId: string, + state: ProjectSessionJourneyState + ) => { + if (generation !== generationRef.current || !bundleRef.current) return; + journeyStatesRef.current.set(sessionId, state); + if ( + journeyRenderTimerRef.current !== null && + journeyRenderGenerationRef.current !== generation + ) { + window.clearTimeout(journeyRenderTimerRef.current); + journeyRenderTimerRef.current = null; + } + journeyRenderGenerationRef.current = generation; + if (journeyRenderTimerRef.current !== null) return; + journeyRenderTimerRef.current = window.setTimeout(() => { + journeyRenderTimerRef.current = null; + const bundle = bundleRef.current; + if (generation !== generationRef.current || !bundle) return; + setRoot( + buildWorkspaceProjectTree({ + projects: bundle.projects, + workItemsByProject: bundle.workItemsByProject, + sessions: bundle.sessions, + standaloneWorkItems: bundle.standaloneWorkItems, + journeysBySessionId: journeyStatesRef.current, + }) + ); + }, 16); + }, + [] + ); + + const reload = useCallback( + async (forceDemo = false) => { + const generation = ++generationRef.current; + setLoading(true); + setError(null); + try { + const bundle = await loadProjectTreeBundle({ forceDemo }); + if (generation !== generationRef.current) return; + bundleRef.current = bundle; + journeyStatesRef.current = new Map(bundle.journeysBySessionId); + setRoot(bundle.tree); + setUsedDemo(bundle.usedDemo); + setError(bundle.error ?? null); + setExpanded((prev) => { + const next: Record = { + ...prev, + "workspace:root": true, + }; + const visit = (node: ProjectTreeNode) => { + if ( + !(node.id in next) && + (node.kind === "project" || + node.kind === "unassigned" || + ((node.kind === "session" || node.kind === "task") && + node.children.length > 0)) + ) + next[node.id] = true; + node.children.forEach(visit); + }; + bundle.tree.children.forEach(visit); + return next; + }); + void streamSessionJourneys(bundle.sessions, (sessionId, state) => + applyJourneyState(generation, sessionId, state) + ); + } catch (e) { + if (generation !== generationRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (generation === generationRef.current) setLoading(false); + } + }, + [applyJourneyState] + ); + + const retryJourney = useCallback( + async (sessionId: string) => { + const generation = generationRef.current; + const state = await loadSessionJourney(sessionId); + applyJourneyState(generation, sessionId, state); + }, + [applyJourneyState] + ); + + useEffect(() => { + void reload(false); + return () => { + generationRef.current += 1; + if (journeyRenderTimerRef.current !== null) { + window.clearTimeout(journeyRenderTimerRef.current); + journeyRenderTimerRef.current = null; + } + }; + }, [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 ( +
+
+ +
+
项目树
+
+ 工作区 → 项目 → 会话 → 旅程 + {usedDemo ? " · 演示数据" : ""} +
+
+ + +
+ +
+ setFilter(e.target.value)} + 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" + /> +
+ + {error && ( +
+ 加载提示:{error}(显示可用数据) +
+ )} + +
+ {loading && ( +
正在加载项目树…
+ )} + {!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.journeyUnavailable && ( + + 旅程不可用 + + )} + {node.kind === "project" && + node.projectSlug && + onOpenJourney && ( + + )} + {node.kind === "session" && node.sessionId && ( + <> + {onOpenSession && ( + + )} + {onOpenSessionJourney && ( + + )} + + )} + {node.kind === "session" && + node.sessionId && + node.journeyUnavailable && ( + + )} + {node.kind === "task" && + node.sessionId && + onOpenSessionJourney && ( + + )} + {node.kind === "task" && node.children.length === 0 && ( + + 精确历史不可用 + + )} + {node.kind === "fork" && + node.sessionId && + node.anchorMessageId && + onOpenSession && ( + + )} + {node.kind === "checkpoint" && + node.sessionId && + node.anchorMessageId && + onOpenSession && ( + + )} + {node.kind === "work_item" && onOpenWorkItem && ( + + )} +
+ ); + })} +
+
+ ); +}; + +export default ProjectTreePage; 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.test.ts b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.test.ts new file mode 100644 index 0000000000..010f238bd5 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + loadProjectTreeBundle, + loadSessionJourneys, + streamSessionJourneys, +} from "./loadProjectTree"; + +const mocks = vi.hoisted(() => ({ + readProjects: vi.fn(), + readWorkItemsEnriched: vi.fn(), + readStandaloneWorkItems: vi.fn(), + sessionAggregateList: vi.fn(), + toFrontendSession: vi.fn(), + snapshot: vi.fn(), +})); + +vi.mock("@src/api/http/project", () => ({ + projectApi: { + readProjects: mocks.readProjects, + readWorkItemsEnriched: mocks.readWorkItemsEnriched, + readStandaloneWorkItems: mocks.readStandaloneWorkItems, + }, + enrichedWorkItemToUI: vi.fn(), +})); + +vi.mock("@src/api/tauri/session", () => ({ + sessionAggregateList: mocks.sessionAggregateList, + toFrontendSession: mocks.toFrontendSession, +})); + +vi.mock("@src/api/tauri/sessionJourney", () => ({ + sessionJourneyApi: { snapshot: mocks.snapshot }, +})); + +describe("loadProjectTreeBundle", () => { + it("discovers a Project-owned session from the canonical aggregate without a Work Item", async () => { + mocks.readProjects.mockResolvedValue([ + { meta: { id: "p", name: "项目" }, slug: "project-p" }, + ]); + mocks.readWorkItemsEnriched.mockResolvedValue([]); + mocks.readStandaloneWorkItems.mockResolvedValue([]); + mocks.sessionAggregateList.mockResolvedValue({ + sessions: [{ sessionId: "s" }], + }); + mocks.toFrontendSession.mockReturnValue({ + session_id: "s", + name: "实时会话", + projectId: "p", + }); + const bundle = await loadProjectTreeBundle(); + + expect(mocks.sessionAggregateList).toHaveBeenCalledOnce(); + expect(bundle.tree.children[0]?.children).toMatchObject([ + { + kind: "session", + sessionId: "s", + workItemId: undefined, + children: [], + }, + ]); + expect(mocks.snapshot).not.toHaveBeenCalled(); + }); + + it("preserves snapshot failures as explicit unavailable state", async () => { + mocks.snapshot.mockRejectedValueOnce(new Error("not a Journey session")); + + await expect( + loadSessionJourneys([{ session_id: "missing" }]) + ).resolves.toEqual( + new Map([ + ["missing", { state: "unavailable", error: "not a Journey session" }], + ]) + ); + }); + + it("bounds mixed snapshot requests while retaining every canonical session result", async () => { + let active = 0; + let peak = 0; + mocks.snapshot.mockClear(); + mocks.snapshot.mockImplementation(async (sessionId: string) => { + active += 1; + peak = Math.max(peak, active); + await Promise.resolve(); + active -= 1; + if (sessionId === "s-7") throw new Error("snapshot failed"); + return { snapshot: { tasks: {}, checkpoints: {}, branches: {} } }; + }); + + const sessions = Array.from({ length: 8 }, (_, index) => ({ + session_id: `s-${index}`, + })); + const result = await loadSessionJourneys(sessions); + + expect(mocks.snapshot).toHaveBeenCalledTimes(8); + expect(peak).toBeLessThanOrEqual(6); + expect(result.size).toBe(8); + expect(result.get("s-7")).toMatchObject({ state: "unavailable" }); + }); + + it("streams each session result without delaying canonical tree loading", async () => { + mocks.snapshot.mockClear(); + const pending = new Map void>(); + mocks.snapshot.mockImplementation( + (sessionId: string) => + new Promise((resolve) => pending.set(sessionId, resolve)) + ); + const results: string[] = []; + const stream = streamSessionJourneys( + [{ session_id: "one" }, { session_id: "two" }], + (sessionId) => results.push(sessionId), + 1 + ); + expect(mocks.snapshot).toHaveBeenCalledWith("one"); + expect(mocks.snapshot).toHaveBeenCalledTimes(1); + pending.get("one")?.({ snapshot: { tasks: {}, branches: {} } }); + await Promise.resolve(); + expect(mocks.snapshot).toHaveBeenCalledWith("two"); + pending.get("two")?.({ snapshot: { tasks: {}, branches: {} } }); + await stream; + expect(results).toEqual(["one", "two"]); + }); +}); diff --git a/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts new file mode 100644 index 0000000000..3e89fbcea7 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/loadProjectTree.ts @@ -0,0 +1,256 @@ +/** + * Load real projects, canonical sessions, and optional work-item metadata. + */ +import { + type ProjectData, + enrichedWorkItemToUI, + projectApi, +} from "@src/api/http/project"; +import { + sessionAggregateList, + toFrontendSession, +} from "@src/api/tauri/session"; +import { sessionJourneyApi } from "@src/api/tauri/sessionJourney"; + +import { + DEMO_PROJECT, + DEMO_SESSIONS, + DEMO_WORK_ITEMS, + type ProjectLike, + type ProjectSessionJourneyState, + type ProjectSessionLike, + type ProjectTreeNode, + type WorkItemLike, + buildWorkspaceProjectTree, +} from "./model"; + +export interface ProjectTreeBundle { + tree: ProjectTreeNode; + projects: ProjectLike[]; + workItemsByProject: Record; + sessions: ProjectSessionLike[]; + standaloneWorkItems: WorkItemLike[]; + journeysBySessionId: ReadonlyMap; + usedDemo: boolean; + error?: string; +} + +export async function loadSessionJourneys( + sessions: readonly ProjectSessionLike[] +): Promise> { + const results = new Map(); + const concurrency = 6; + let cursor = 0; + const workers = Array.from( + { length: Math.min(concurrency, sessions.length) }, + async () => { + while (cursor < sessions.length) { + const session = sessions[cursor++]; + if (!session) continue; + try { + const response = await sessionJourneyApi.snapshot(session.session_id); + results.set(session.session_id, { + state: "ready", + snapshot: response.snapshot, + }); + } catch (error) { + results.set(session.session_id, { + state: "unavailable", + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + ); + await Promise.all(workers); + return results; +} + +/** + * Starts bounded Journey enrichment after the canonical tree is available. + * The callback is intentionally per-session so callers can paint useful + * project/session membership without waiting for unrelated snapshots. + */ +export function streamSessionJourneys( + sessions: readonly ProjectSessionLike[], + onResult: (sessionId: string, state: ProjectSessionJourneyState) => void, + concurrency = 6 +): Promise { + let cursor = 0; + const workers = Array.from( + { length: Math.min(Math.max(1, concurrency), sessions.length) }, + async () => { + while (cursor < sessions.length) { + const session = sessions[cursor++]; + if (!session) continue; + try { + const response = await sessionJourneyApi.snapshot(session.session_id); + onResult(session.session_id, { + state: "ready", + snapshot: response.snapshot, + }); + } catch (error) { + onResult(session.session_id, { + state: "unavailable", + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + ); + return Promise.all(workers).then(() => undefined); +} + +export async function loadSessionJourney( + sessionId: string +): Promise { + try { + const response = await sessionJourneyApi.snapshot(sessionId); + return { state: "ready", snapshot: response.snapshot }; + } catch (error) { + return { + state: "unavailable", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +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, + }; + const journeysBySessionId = new Map(); + return { + tree: buildWorkspaceProjectTree({ + workspaceName: options.workspaceName ?? "Workspace", + projects: [DEMO_PROJECT], + workItemsByProject, + sessions: DEMO_SESSIONS, + journeysBySessionId, + }), + projects: [DEMO_PROJECT], + workItemsByProject, + sessions: DEMO_SESSIONS, + standaloneWorkItems: [], + journeysBySessionId, + usedDemo: true, + }; + } + + try { + const [projectsRaw, sessionResponse] = await Promise.all([ + projectApi.readProjects(), + sessionAggregateList(), + ]); + const projects = (projectsRaw ?? []).map(projectDataToLike); + const sessions: ProjectSessionLike[] = (sessionResponse.sessions ?? []).map( + toFrontendSession + ); + const workItemsByProject: Record = {}; + const journeysBySessionId = new Map(); + + 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 = []; + } + + // 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, + workItemsByProject, + sessions, + journeysBySessionId, + standaloneWorkItems, + }), + projects, + workItemsByProject, + sessions, + standaloneWorkItems, + journeysBySessionId, + usedDemo: false, + }; + } catch (error) { + return { + tree: buildWorkspaceProjectTree({ + workspaceName: options?.workspaceName ?? "Workspace", + projects: [], + workItemsByProject: {}, + sessions: [], + journeysBySessionId: new Map(), + standaloneWorkItems: [], + }), + projects: [], + workItemsByProject: {}, + sessions: [], + standaloneWorkItems: [], + journeysBySessionId: new Map(), + usedDemo: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} 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..29645d576d --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/buildTree.test.ts @@ -0,0 +1,175 @@ +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: "项目" }], + sessions: [{ session_id: "s", name: "实现会话", projectId: "p" }], + 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 + ); + }); + + it("uses canonical project sessions without work items and deduplicates linked metadata", () => { + const tree = buildWorkspaceProjectTree({ + projects: [{ id: "p", name: "项目" }], + sessions: [ + { session_id: "direct", name: "直接会话", projectId: "p" }, + { session_id: "linked", name: "规范会话", projectId: "p" }, + ], + workItemsByProject: { + p: [ + { + session_id: "w", + name: "工作项 A", + linkedSessions: [{ session_id: "linked", agent_role: "实现" }], + }, + ], + }, + }); + + const [project] = tree.children; + expect(project.children).toHaveLength(2); + expect(project.children.map((node) => node.sessionId)).toEqual([ + "direct", + "linked", + ]); + expect(project.children[1]).toMatchObject({ + workItemId: "w", + meta: { workItemName: "工作项 A" }, + }); + expect(project.children.some((node) => node.kind === "work_item")).toBe( + false + ); + }); + + it("does not create a session node from a work item link absent from the canonical aggregate", () => { + const tree = buildWorkspaceProjectTree({ + projects: [{ id: "p", name: "项目" }], + sessions: [{ session_id: "canonical", name: "规范会话", projectId: "p" }], + workItemsByProject: { + p: [ + { + session_id: "w", + name: "工作项 A", + linkedSessions: [{ session_id: "missing-from-aggregate" }], + }, + ], + }, + }); + + expect(tree.children[0].children.map((node) => node.sessionId)).toEqual([ + "canonical", + ]); + }); + + it("projects only durable task and fork records, retaining the exact fork anchor", () => { + const tree = buildWorkspaceProjectTree({ + projects: [{ id: "p", name: "项目" }], + sessions: [{ session_id: "s", name: "规范会话", projectId: "p" }], + workItemsByProject: {}, + journeysBySessionId: new Map([ + [ + "s", + { + state: "ready", + snapshot: { + tasks: { + "task-1": { + id: "task-1", + name: "实现任务", + branch_id: "fork-1", + state: "active", + start_sequence: 8, + finish_sequence: null, + outcome: null, + }, + }, + branches: { + "fork-1": { + id: "fork-1", + parent_branch_id: "main", + parent_anchor_message_id: "message-7", + anchor_sequence: 7, + state: "active", + }, + }, + checkpoints: { + "checkpoint-1": { + id: "checkpoint-1", + task_id: "task-1", + message_id: "message-8", + sequence: 8, + name: "完成实现", + }, + }, + }, + }, + ], + ]), + }); + + const session = tree.children[0].children[0]; + expect(session.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "task", + taskId: "task-1", + sessionId: "s", + title: "实现任务", + }), + expect.objectContaining({ + kind: "fork", + forkId: "fork-1", + sessionId: "s", + anchorMessageId: "message-7", + anchorSequence: 7, + }), + ]) + ); + expect(session.children[0]?.children).toEqual([ + expect.objectContaining({ + kind: "checkpoint", + anchorMessageId: "message-8", + }), + ]); + }); + + it("retains project-less canonical sessions under the visible unassigned section", () => { + const tree = buildWorkspaceProjectTree({ + projects: [{ id: "p", name: "项目" }], + sessions: [{ session_id: "unassigned", name: "无项目会话" }], + workItemsByProject: {}, + }); + expect(tree.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "unassigned", + children: [expect.objectContaining({ sessionId: "unassigned" })], + }), + ]) + ); + }); +}); diff --git a/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts b/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts new file mode 100644 index 0000000000..9fb4c7e724 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/buildTree.ts @@ -0,0 +1,235 @@ +/** + * Build Workspace → Project → Session tree. Work items annotate sessions but + * never become containment parents. + */ +import type { + ProjectJourneyForkLike, + ProjectLike, + ProjectSessionJourneyState, + ProjectSessionLike, + ProjectTreeNode, + WorkItemLike, +} from "./types"; + +export interface BuildTreeInput { + workspaceName?: string; + projects: ProjectLike[]; + /** projectId or slug → work items */ + workItemsByProject: Record; + /** Canonical aggregate sessions. Project membership comes from this source. */ + sessions?: ProjectSessionLike[]; + /** Snapshot projection keyed by canonical session id. */ + journeysBySessionId?: ReadonlyMap; + /** Orphan work items (no project) */ + standaloneWorkItems?: WorkItemLike[]; + includeTodos?: boolean; +} + +function sessionStatus(raw?: string): string | undefined { + return raw?.toLowerCase(); +} + +function sessionNodeFromAggregate( + session: ProjectSessionLike, + project: ProjectLike, + linked?: { workItemId?: string; workItemName?: string }, + journeyState?: ProjectSessionJourneyState +): ProjectTreeNode { + const journey = + journeyState?.state === "ready" ? journeyState.snapshot : undefined; + const tasks: ProjectTreeNode[] = Object.values(journey?.tasks ?? {}).map( + (task) => { + const checkpoints = Object.values(journey?.checkpoints ?? {}) + .filter((checkpoint) => checkpoint.task_id === task.id) + .map((checkpoint) => ({ + id: `checkpoint:${session.session_id}:${checkpoint.id}`, + kind: "checkpoint" as const, + title: checkpoint.name, + status: "精确历史", + sessionId: session.session_id, + taskId: task.id, + checkpointId: checkpoint.id, + anchorMessageId: checkpoint.message_id, + children: [], + })); + return { + id: `task:${session.session_id}:${task.id}`, + kind: "task", + title: task.name, + status: task.outcome ?? task.state, + projectId: project.id, + projectSlug: project.slug, + workItemId: session.workItemId ?? linked?.workItemId, + sessionId: session.session_id, + taskId: task.id, + children: checkpoints, + meta: { + branchId: task.branch_id, + }, + }; + } + ); + const forks: ProjectTreeNode[] = Object.values(journey?.branches ?? {}) + .filter((fork) => fork.id !== fork.parent_branch_id) + .map((fork: ProjectJourneyForkLike) => ({ + id: `fork:${session.session_id}:${fork.id}`, + kind: "fork", + title: fork.id, + status: fork.state, + projectId: project.id, + projectSlug: project.slug, + workItemId: session.workItemId ?? linked?.workItemId, + sessionId: session.session_id, + forkId: fork.id, + anchorMessageId: fork.parent_anchor_message_id ?? undefined, + anchorSequence: fork.anchor_sequence, + children: [], + meta: { parentBranchId: fork.parent_branch_id }, + })); + return { + id: `session:${session.session_id}`, + kind: "session", + title: + session.displayLabel || + session.name || + session.agentRole || + session.session_id, + status: sessionStatus(session.status), + projectId: project.id, + projectSlug: project.slug, + workItemId: session.workItemId ?? linked?.workItemId, + sessionId: session.session_id, + journeyUnavailable: + journeyState?.state === "unavailable" ? journeyState.error : undefined, + children: [...tasks, ...forks], + meta: { + parentSessionId: session.parentSessionId ?? null, + agentRole: session.agentRole, + ...(linked?.workItemName ? { workItemName: linked.workItemName } : {}), + }, + }; +} + +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 projectNodes: ProjectTreeNode[] = input.projects.map((project) => { + const items = resolveWorkItems(project, input.workItemsByProject); + const linkedBySessionId = new Map< + string, + { workItemId: string; workItemName: string } + >(); + for (const item of items) { + for (const linked of item.linkedSessions ?? []) { + if (!linkedBySessionId.has(linked.session_id)) { + linkedBySessionId.set(linked.session_id, { + workItemId: item.session_id, + workItemName: item.name || item.session_id, + }); + } + } + } + const projectKeys = new Set([project.id, project.slug].filter(Boolean)); + const canonicalSessions = (input.sessions ?? []).filter( + (session) => + projectKeys.has(session.projectId) || + projectKeys.has(session.projectSlug) + ); + const sessionsById = new Map(); + for (const session of canonicalSessions) { + if (!sessionsById.has(session.session_id)) { + sessionsById.set( + session.session_id, + sessionNodeFromAggregate( + session, + project, + linkedBySessionId.get(session.session_id), + input.journeysBySessionId?.get(session.session_id) + ) + ); + } + } + const sessions = [...sessionsById.values()]; + return { + id: `project:${project.id}`, + kind: "project" as const, + title: project.name, + status: project.status, + projectId: project.id, + projectSlug: project.slug, + children: sessions, + meta: { + workItemCount: items.length, + sessionCount: sessions.length, + slug: project.slug, + }, + }; + }); + + const standalone = input.standaloneWorkItems ?? []; + const assignedSessionIds = new Set( + projectNodes.flatMap((project) => + project.children.map((session) => session.sessionId) + ) + ); + const unassignedSessions = (input.sessions ?? []) + .filter((session) => !assignedSessionIds.has(session.session_id)) + .map((session) => + sessionNodeFromAggregate( + session, + { id: "unassigned", name: "未分配会话" }, + undefined, + input.journeysBySessionId?.get(session.session_id) + ) + ); + if (unassignedSessions.length) { + projectNodes.push({ + id: "unassigned:sessions", + kind: "unassigned", + title: "未分配会话", + children: unassignedSessions, + }); + } + + return { + id: "workspace:root", + kind: "workspace", + title: input.workspaceName ?? "Workspace", + children: projectNodes, + meta: { + projectCount: input.projects.length, + standaloneWorkItemMetadataCount: 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..a1b41912fc --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/demoFixture.ts @@ -0,0 +1,203 @@ +/** + * Demo fixture so Journey/Tree are usable even when local DB has sparse links. + */ +import type { ProjectLike, ProjectSessionLike, WorkItemLike } from "./types"; + +export const DEMO_PROJECT: ProjectLike = { + 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", +}; + +// Demo Project → Session membership follows the same canonical aggregate +// contract as production; work-item links below only annotate these sessions. +export const DEMO_SESSIONS: ProjectSessionLike[] = [ + { + session_id: "sess-main-tree", + name: "四级树可感知化", + status: "completed", + projectId: DEMO_PROJECT.id, + projectSlug: DEMO_PROJECT.slug, + workItemId: "WI-DEMO-1", + agentRole: "implementer", + }, + { + session_id: "sess-main-journey", + name: "项目旅程思维导图", + status: "running", + projectId: DEMO_PROJECT.id, + projectSlug: DEMO_PROJECT.slug, + workItemId: "WI-DEMO-2", + agentRole: "implementer", + }, + { + session_id: "sess-fork-explore", + name: "explore-alt-layout", + status: "completed", + projectId: DEMO_PROJECT.id, + projectSlug: DEMO_PROJECT.slug, + workItemId: "WI-DEMO-2", + parentSessionId: "sess-main-journey", + agentRole: "explorer", + }, + { + session_id: "sess-fork-dead", + name: "dead-end-reactflow", + status: "cancelled", + projectId: DEMO_PROJECT.id, + projectSlug: DEMO_PROJECT.slug, + workItemId: "WI-DEMO-2", + parentSessionId: "sess-main-journey", + agentRole: "experiment", + }, +]; + +export const DEMO_WORK_ITEMS: WorkItemLike[] = [ + { + session_id: "WI-DEMO-1", + name: "四级树可感知化", + status: "completed", + workItemStatus: "completed", + spec: "工作区→项目→会话→旅程树只读聚合(工作项仅元数据)", + 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/project-journey/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/project-journey/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/project-journey/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/project-journey/journey-preview.png", + additions: 1, + deletions: 0, + }, + ], + }, + }, + }, +]; + +export function getDemoTreeBundle() { + return { + projects: [DEMO_PROJECT], + sessions: DEMO_SESSIONS, + 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..a61e8f1e41 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./fileCategory"; +export * from "./buildTree"; +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..e613d3dc73 --- /dev/null +++ b/src/modules/ProjectManager/ProjectJourney/model/types.ts @@ -0,0 +1,230 @@ +/** + * Project Journey / Tree — shared types + * + * Narrative layer only. Does NOT own project/session membership truth. + * Truth remains: Project → Session → Journey. Work items are optional + * metadata associated with sessions, never a containment parent. + */ + +export type TreeNodeKind = + | "workspace" + | "project" + | "work_item" + | "todo" + | "session" + | "task" + | "fork" + | "checkpoint" + | "unassigned"; + +/** Durable Journey fields projected into the tree; never inferred from text. */ +export interface ProjectJourneyTaskLike { + id: string; + name: string; + branch_id: string; + state: string; + start_sequence: number | null; + finish_sequence: number | null; + outcome: string | null; +} + +export interface ProjectJourneyCheckpointLike { + id: string; + task_id: string; + message_id: string; + sequence: number; + name: string; +} + +export interface ProjectJourneyForkLike { + id: string; + parent_branch_id: string; + parent_anchor_message_id: string | null; + anchor_sequence: number; + state: string; +} + +export interface ProjectSessionJourneyLike { + tasks: Record; + checkpoints?: Record; + branches: Record; +} + +export type ProjectSessionJourneyState = + | { state: "ready"; snapshot: ProjectSessionJourneyLike } + | { state: "unavailable"; error: string }; + +export interface ProjectTreeNode { + id: string; + kind: TreeNodeKind; + title: string; + status?: string; + projectId?: string; + projectSlug?: string; + workItemId?: string; + sessionId?: string; + taskId?: string; + forkId?: string; + checkpointId?: string; + /** Exact durable message anchor for a fork backtrace, when available. */ + anchorMessageId?: string; + anchorSequence?: number; + /** Explicit snapshot failure, distinct from a ready snapshot with no Journey. */ + journeyUnavailable?: 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; +} + +/** Canonical session aggregate projection used for Project -> Session membership. */ +export interface ProjectSessionLike { + session_id: string; + name?: string; + displayLabel?: string; + status?: string; + projectId?: string; + projectSlug?: string; + workItemId?: string; + parentSessionId?: string; + agentRole?: string; +} + +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.contract.test.tsx b/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.contract.test.tsx new file mode 100644 index 0000000000..c9f9bbe19c --- /dev/null +++ b/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.contract.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import type { WorkStationTab } from "@src/store/workstation/tabs/types"; + +import { renderActiveContent } from "./ProjectManagerContentRouter"; + +function tab(type: WorkStationTab["type"]): WorkStationTab { + return { + id: `${type}:test`, + type, + category: "project", + title: type, + icon: "GitFork", + data: type === "session-journey" ? { sessionId: "session-1" } : {}, + closable: true, + }; +} + +describe("ProjectManagerContentRouter Journey production route", () => { + for (const type of [ + "project-tree", + "project-journey", + "session-journey", + ] as const) { + it(`routes ${type} through the unified project host`, () => { + const node = renderActiveContent({ + repoPath: "/repo", + activeTab: tab(type), + hasNoTabs: false, + projectQuickActions: [], + }); + + expect(node).not.toBeNull(); + expect( + (node as { props?: { tab?: WorkStationTab } }).props?.tab?.type + ).toBe(type); + }); + } + + it("passes the keyed chat target through the production project host", () => { + const activeTab = { + ...tab("chat-session"), + data: { sessionId: "session-1", initialMessageId: "message-9" }, + }; + const node = renderActiveContent({ + repoPath: "/repo", + activeTab, + hasNoTabs: false, + projectQuickActions: [], + }) as { + props: { + children: { props: { children: { props: Record } } }; + }; + }; + + expect(node.props.children.props.children.props.initialMessageId).toBe( + "message-9" + ); + }); +}); diff --git a/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx b/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx index 1cf6fad5c4..386fa49818 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx +++ b/src/modules/ProjectManager/ProjectManagerLayout/components/ProjectManagerContentRouter.tsx @@ -98,7 +98,7 @@ interface RenderActiveContentOptions { projectQuickActions: ProjectManagerContentRouterProps["projectQuickActions"]; } -function renderActiveContent({ +export function renderActiveContent({ repoPath, activeTab, hasNoTabs, @@ -134,7 +134,13 @@ function renderActiveContent({ "linear-gradient(180deg, var(--color-bg-1) 0%, var(--color-fill-1) 100%)", }} > - +
); @@ -168,6 +174,9 @@ function renderActiveContent({ case "project-org": case "project-org-settings": case "project-settings": + case "project-tree": + case "project-journey": + case "session-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..6cc4e7e701 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,23 @@ export function useProjectTabActions({ ); }, [navigateWorkspaceTab]); + const handleOpenProjectTree = useCallback(() => { + navigateWorkspaceTab(createProjectTreeTab()); + }, [navigateWorkspaceTab]); + + const handleOpenProjectJourney = useCallback( + (projectId?: string, projectSlug?: string, projectName?: string) => { + navigateWorkspaceTab( + createProjectJourneyTab({ + projectId, + projectSlug, + projectName, + }) + ); + }, + [navigateWorkspaceTab] + ); + const handleOpenPersonalOrg = useCallback( (view: ProjectOrgSurfaceView = PROJECT_ORG_SURFACE_VIEW.WORK_ITEMS) => { navigateWorkspaceTab( @@ -448,6 +467,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/Chat/Communication/MessageViewer.tsx b/src/modules/WorkStation/Chat/Communication/MessageViewer.tsx index c40b373b3a..328528df9d 100644 --- a/src/modules/WorkStation/Chat/Communication/MessageViewer.tsx +++ b/src/modules/WorkStation/Chat/Communication/MessageViewer.tsx @@ -17,6 +17,7 @@ import { isPlanDisplayEvent, } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { usePendingPlanApproval } from "@src/hooks/session/usePendingPlanApproval"; +import { focusJourneyMessage } from "@src/modules/WorkStation/Chat/Journey/journeyMessageJump"; import type { SessionReplayPlaceholderMode } from "@src/modules/WorkStation/shared"; import { isEmailBubbleEvent } from "./EmailMessageBubble"; @@ -154,10 +155,22 @@ export const MessageViewer: React.FC = ({ messageWindow.key === replayWindowKey ? messageWindow.count : initialRenderedMessageCount; + const journeyTargetId = selectedMessage?.eventId ?? null; + const journeyTargetIndex = useMemo( + () => + journeyTargetId + ? messages.findIndex((message) => message.eventId === journeyTargetId) + : -1, + [journeyTargetId, messages] + ); + const effectiveRenderedMessageCount = Math.max( + renderedMessageCount, + journeyTargetIndex >= 0 ? messages.length - journeyTargetIndex : 0 + ); const lastMessageId = messages[messages.length - 1]?.eventId ?? null; const visibleMessages = useMemo( - () => messages.slice(-renderedMessageCount), - [messages, renderedMessageCount] + () => messages.slice(-effectiveRenderedMessageCount), + [effectiveRenderedMessageCount, messages] ); const hiddenMessageCount = Math.max( 0, @@ -175,6 +188,14 @@ export const MessageViewer: React.FC = ({ const liveContentLength = latestLiveDelta?.kind === "message" ? latestLiveDelta.content.length : 0; + useEffect(() => { + if (!journeyTargetId) return; + const frame = window.requestAnimationFrame(() => + focusJourneyMessage(journeyTargetId) + ); + return () => window.cancelAnimationFrame(frame); + }, [journeyTargetId, journeyTargetIndex]); + const handleLoadMoreMessages = useCallback(() => { const scrollContainer = scrollContainerRef.current; loadMoreScrollAnchorRef.current = scrollContainer @@ -353,18 +374,30 @@ export const MessageViewer: React.FC = ({ })} /> )} - + > + +
); })} diff --git a/src/modules/WorkStation/Chat/Communication/index.tsx b/src/modules/WorkStation/Chat/Communication/index.tsx index 212beb64a9..6bb2134a6a 100644 --- a/src/modules/WorkStation/Chat/Communication/index.tsx +++ b/src/modules/WorkStation/Chat/Communication/index.tsx @@ -1,5 +1,12 @@ import { useAtomValue, useSetAtom } from "jotai"; -import React, { memo, useCallback, useMemo, useRef } from "react"; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { EDITOR_TAB_CANVAS_BG_CLASS } from "@src/config/workstation/tokens"; @@ -14,6 +21,11 @@ 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 { + type JourneyMessageJump, + listenForJourneyMessageJump, +} from "@src/modules/WorkStation/Chat/Journey/journeyMessageJump"; import { TextSelectionDropdown, useTextSelectionDropdown, @@ -23,6 +35,7 @@ import { chatFontSizeAtom, chatLineHeightAtom, } from "@src/store/config/configAtom"; +import { openSessionAtom } from "@src/store/session"; import { addToAgentAtom } from "@src/store/ui/addToAgentAtom"; import { simulatorEffectiveDockAppAtom } from "@src/store/ui/simulatorAtom"; import type { BackendEvent } from "@src/types/session/steps"; @@ -33,6 +46,7 @@ import { SimulatorReplayChrome, WorkStationShell, buildPrimarySidebarConfig, + buildSecondaryPanelConfig, } from "../../shared"; import PlanApprovalActions from "./PlanApprovalActions"; import { CommunicationCanvas } from "./components/CommunicationCanvas"; @@ -67,12 +81,18 @@ const SimulatorMessagesComponent: React.FC = ({ const { t } = useTranslation("sessions"); const effectiveDockApp = useAtomValue(simulatorEffectiveDockAppAtom); const setAddToAgent = useSetAtom(addToAgentAtom); + const openSession = useSetAtom(openSessionAtom); const selectionContainerRef = useRef(null); + const [dockedJourneyReviewPanel, setDockedJourneyReviewPanel] = + useState(null); + const pendingJourneyJumpRef = useRef(null); + const atomSessionId = useAtomValue(sessionIdAtom); + // Prefer the explicitly passed sessionId (WorkStation Build context) over + // the global atom, which may lag while a Journey jump changes sessions. + const sessionId = propSessionId ?? atomSessionId; const chatFontSize = useAtomValue(chatFontSizeAtom); const chatCodeFontSize = useAtomValue(chatCodeFontSizeAtom); const chatLineHeight = useAtomValue(chatLineHeightAtom); - const atomSessionId = useAtomValue(sessionIdAtom); - const sessionId = propSessionId ?? atomSessionId; const { viewMode, @@ -184,6 +204,40 @@ const SimulatorMessagesComponent: React.FC = ({ }, [handleViewModeChange, jumpToMessage, messageViewModel.previewMessages] ); + const handleDockedJourneyReviewPanel = useCallback( + (panel: React.ReactNode | null) => setDockedJourneyReviewPanel(panel), + [] + ); + useEffect( + () => + listenForJourneyMessageJump( + ({ sessionId: targetSessionId, messageId }) => { + if (targetSessionId !== sessionId) { + openSession({ sessionId: targetSessionId }); + pendingJourneyJumpRef.current = { + sessionId: targetSessionId, + messageId, + }; + return; + } + handleMessageClick(messageId); + } + ), + [handleMessageClick, openSession, sessionId] + ); + useEffect(() => { + const pendingJourneyJump = pendingJourneyJumpRef.current; + if (!pendingJourneyJump || pendingJourneyJump.sessionId !== sessionId) + return; + // `jumpToMessage` stores the exact ID. Once the session switch makes its + // transcript available, MessageViewer expands the window and focuses that + // row rather than accepting a positional fallback. + pendingJourneyJumpRef.current = null; + handleMessageClick(pendingJourneyJump.messageId); + }, [handleMessageClick, sessionId]); + + // Entering edit forces the preview surface so the plan textarea is actually + // rendered (the plan doc only mounts in "preview" view). const handlePlanEditToggle = useCallback(() => { if (!isEditing) handleViewModeChange("preview"); handleEditToggle(); @@ -206,6 +260,11 @@ const SimulatorMessagesComponent: React.FC = ({ ) : null; + // Resolve org-run member info so simulator message bubbles can show the + // correct sender label (e.g. "Planner updated task" instead of the + // generic "Agent"). One hook instance per Communication panel — bubbles + // receive a stable lookup map rather than each calling the hook + // themselves (which would multiply the 2.5s polling timer). const { view: agentOrgRunView } = useAgentOrgRunView(sessionId); const orgMembers = useMemo( () => agentOrgRunView?.members ?? [], @@ -277,11 +336,34 @@ const SimulatorMessagesComponent: React.FC = ({ ? () => handlePreviewModeChange(!effectivePreviewMode) : undefined } - extraActions={planHeaderActions} + extraActions={ + <> + {planHeaderActions} + + + } />
setDockedJourneyReviewPanel(null), + }) + : undefined + } content={messageContent} statusBar={null} appClassName="session-replay-messages" diff --git a/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.contract.test.tsx b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.contract.test.tsx new file mode 100644 index 0000000000..f205486533 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.contract.test.tsx @@ -0,0 +1,323 @@ +// @vitest-environment jsdom +import React, { useState } from "react"; +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SessionJourneyControls, + resolveDurableJourneyMessageId, +} from "./SessionJourneyControls"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const api = vi.hoisted(() => ({ + snapshot: vi.fn(), + forkCompare: vi.fn(), + closeFork: vi.fn(), + retryReview: vi.fn(), + startTask: vi.fn(), + checkpoint: vi.fn(), + finishTask: vi.fn(), + startFork: vi.fn(), + confirm: vi.fn(), + discard: vi.fn(), + returnToParent: vi.fn(), + onJump: vi.fn(), +})); + +vi.mock("@src/api/tauri/sessionJourney", () => ({ sessionJourneyApi: api })); + +const snapshot = { + session_id: "session-a", + revision: 7, + active_branch_id: "fork-a", + active_task_id: "task-a", + tasks: { + "task-a": { + id: "task-a", + name: "验证关闭", + branch_id: "fork-a", + state: "active", + start_sequence: 3, + finish_sequence: null, + outcome: null, + }, + }, + checkpoints: {}, + branches: { + "fork-a": { + id: "fork-a", + parent_branch_id: "main", + parent_anchor_message_id: "anchor-1", + anchor_sequence: 3, + state: "active", + handoff_capsule: null, + }, + }, + reviews: { + "review-a": { + id: "review-a", + fork_id: "fork-a", + state: "failed", + annotation: "审核暂时失败", + source_start_sequence: 3, + source_end_sequence: 6, + promoted_fact_ids: [], + }, + }, +}; + +async function click(container: HTMLElement, text: string) { + const button = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.trim().startsWith(text) + ); + expect(button, `button ${text}`).toBeTruthy(); + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); +} + +function Harness({ messageId = "message-6" }: { messageId?: string | null }) { + const [panel, setPanel] = useState(null); + return ( +
+ +
+ {panel} +
+
+ ); +} + +function DirectForkHarness({ messageId }: { messageId?: string | null }) { + return ; +} + +describe("SessionJourneyControls rendered behavior", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + vi.useFakeTimers(); + localStorage.clear(); + sessionStorage.clear(); + Object.values(api).forEach((mock) => mock.mockReset()); + api.snapshot.mockResolvedValue({ snapshot }); + api.forkCompare.mockResolvedValue({ groups: [] }); + api.closeFork.mockResolvedValue({ job_id: "job", state: "queued" }); + api.retryReview.mockResolvedValue({ revision: 8 }); + api.discard.mockResolvedValue({ parent_anchor_message_id: "anchor-1" }); + api.returnToParent.mockResolvedValue({ + parent_anchor_message_id: "anchor-1", + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + await act(async () => { + // `reload` commits after both snapshot promises resolve. Flush that + // microtask before asserting real rendered controls. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + it("normalizes only the known live user-event prefix", () => { + expect(resolveDurableJourneyMessageId("user-message-durable-7")).toBe( + "durable-7" + ); + expect(resolveDurableJourneyMessageId("durable-7")).toBe("durable-7"); + expect(resolveDurableJourneyMessageId("assistant-message-7")).toBe( + "assistant-message-7" + ); + expect(resolveDurableJourneyMessageId("user-input-backend-7")).toBe( + "user-input-backend-7" + ); + expect(resolveDurableJourneyMessageId("user-message-")).toBeNull(); + }); + + it("keeps direct Fork available without a hydrated visual anchor", async () => { + await act(async () => { + root.unmount(); + root = createRoot(container); + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const fork = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.trim().startsWith("分叉") + ) as HTMLButtonElement | undefined; + expect(fork?.disabled).toBe(false); + await click(container, "分叉"); + expect(document.body.textContent).toContain("最近一条已持久化的用户消息"); + const input = document.body.querySelector("input") as HTMLInputElement; + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )?.set; + await act(async () => { + valueSetter?.call(input, "直接分叉"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click(document.body, "确认"); + await act(async () => {}); + expect(api.startFork).toHaveBeenCalledWith( + expect.objectContaining({ taskName: "直接分叉" }) + ); + expect(api.startFork.mock.calls[0]?.[0]).not.toHaveProperty( + "anchorMessageId" + ); + }); + + it("sends durable IDs to strict lifecycle actions from a live user event", async () => { + await act(async () => { + root.unmount(); + root = createRoot(container); + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await click(container, "分叉"); + const input = document.body.querySelector("input") as HTMLInputElement; + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )?.set; + await act(async () => { + valueSetter?.call(input, "带锚点分叉"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click(document.body, "确认"); + await act(async () => {}); + expect(api.startFork).toHaveBeenCalledWith( + expect.objectContaining({ anchorMessageId: "durable-6" }) + ); + }); + + it("normalizes a live user-event ID for pause, finish, and close-Fork lifecycle actions", async () => { + await act(async () => { + root.unmount(); + root = createRoot(container); + root.render(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await click(container, "结束"); + const select = document.body.querySelector("select") as HTMLSelectElement; + await act(async () => { + select.value = "paused"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await click(document.body, "确认"); + await act(async () => {}); + expect(api.finishTask).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "paused", messageId: "message-6" }) + ); + + await click(container, "关闭分叉"); + await click(document.body, "确认"); + await act(async () => {}); + expect(api.closeFork).toHaveBeenCalledWith( + expect.objectContaining({ forkId: "fork-a", messageId: "message-6" }), + expect.any(String) + ); + }); + + it("closes a fork through the durable close command and retries a failed review", async () => { + await click(container, "关闭分叉"); + await click(document.body, "确认"); + await act(async () => {}); + expect(api.closeFork).toHaveBeenCalledWith( + expect.objectContaining({ forkId: "fork-a", messageId: "message-6" }), + expect.any(String) + ); + + await click(container, "审核"); + await click(container, "重试审核"); + await act(async () => {}); + expect(api.retryReview).toHaveBeenCalledWith( + expect.objectContaining({ + reviewId: "review-a", + }) + ); + }); + + it("jumps to the exact parent anchor after discard and return", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + await click(container, "审核"); + await click(container, "丢弃"); + await act(async () => {}); + expect(api.onJump).toHaveBeenLastCalledWith("anchor-1"); + + api.onJump.mockClear(); + await click(container, "返回主干"); + await act(async () => {}); + expect(api.onJump).toHaveBeenLastCalledWith("anchor-1"); + }); + + it("docks in the WorkStation pane, floats, hides, and reopens", async () => { + await click(container, "审核"); + const dock = container.querySelector('[data-testid="dock"]'); + expect( + dock?.querySelector('[data-testid="journey-review-panel"]') + ).toBeTruthy(); + expect( + dock?.querySelector('[data-testid="journey-review-panel"]')?.className + ).not.toContain("fixed"); + + await click(container, "浮动"); + expect( + dock?.querySelector('[data-testid="journey-review-panel"]') + ).toBeFalsy(); + expect( + container.querySelector('[data-testid="journey-review-panel"]')?.className + ).toContain("fixed"); + + await click(container, "隐藏"); + expect( + container.querySelector('[data-testid="journey-review-panel"]') + ).toBeFalsy(); + await click(container, "审核"); + expect( + dock?.querySelector('[data-testid="journey-review-panel"]') + ).toBeTruthy(); + }); + + it("offers recovery from durable active Journey state", async () => { + expect(document.body.textContent).toContain("恢复会话旅程"); + await click(document.body, "继续当前会话"); + expect(document.body.textContent).not.toContain("恢复会话旅程"); + }); +}); diff --git a/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx new file mode 100644 index 0000000000..28eb442c62 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/SessionJourneyControls.tsx @@ -0,0 +1,801 @@ +import { + ChevronLeft, + Flag, + GitFork, + MapPin, + PanelLeft, + Play, + X, +} from "lucide-react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { + type ForkCompareResponse, + type JourneyReview, + 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, + 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()}`; + +/** + * Live user events are emitted as `user-message-{durable id}` while hydrated + * transcript rows already use the durable `agent_messages.id`. Journey + * commands address the durable row, so normalize only at this boundary. Do + * not alter the global SessionEvent identity: stream reconciliation relies on + * the prefixed live id. + */ +export const resolveDurableJourneyMessageId = ( + messageId: string | null | undefined +): string | null => { + if (!messageId) return null; + // Optimistic bubbles are excluded at their source via + // `isSyntheticUserInputEvent` in ChatView. Do not reject by `user-input-*` + // prefix here: CLI persistence can legitimately emit a durable message ID + // with that prefix, and strict Journey actions must preserve it exactly. + const liveUserEventPrefix = "user-message-"; + return messageId.startsWith(liveUserEventPrefix) + ? messageId.slice(liveUserEventPrefix.length) || null + : messageId; +}; + +export const SessionJourneyControls: React.FC<{ + sessionId: string | null; + messageId?: string | null; + onJumpToMessage?: (messageId: string) => void; + /** Docked content is rendered by the Communication WorkStation secondary pane. */ + onDockedReviewPanelChange?: (panel: React.ReactNode | null) => void; +}> = ({ sessionId, messageId, onJumpToMessage, onDockedReviewPanelChange }) => { + const [snapshot, setSnapshot] = useState(null); + const [comparison, setComparison] = useState( + null + ); + const [error, setError] = useState(null); + const [dialog, setDialog] = useState< + "task" | "checkpoint" | "finish" | "fork" | "closeFork" | 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 requestGenerationRef = useRef(0); + const reload = useCallback(async () => { + const generation = ++requestGenerationRef.current; + if (!sessionId) { + if (generation === requestGenerationRef.current) { + setSnapshot(null); + setComparison(null); + } + return; + } + try { + const [response, forkComparison] = await Promise.all([ + sessionJourneyApi.snapshot(sessionId), + sessionJourneyApi.forkCompare(sessionId), + ]); + if (generation !== requestGenerationRef.current) return; + setSnapshot(response.snapshot); + setComparison(forkComparison); + setError(null); + } catch (reason) { + if (generation !== requestGenerationRef.current) return; + setError(String(reason)); + } + }, [sessionId]); + useEffect(() => { + const timer = window.setTimeout(() => void reload(), 0); + return () => { + window.clearTimeout(timer); + requestGenerationRef.current += 1; + }; + }, [reload]); + useEffect(() => { + if ( + snapshot && + hasRecoverableJourney(snapshot) && + sessionStorage.getItem(`orgii-journey-recovery:${sessionId}`) !== "seen" + ) + queueMicrotask(() => setShowRecovery(true)); + }, [sessionId, snapshot]); + useEffect(() => { + if (panelMode === "hidden") return; + let timer: number | null = null; + let disposed = false; + let running = false; + const clearTimer = () => { + if (timer !== null) window.clearTimeout(timer); + timer = null; + }; + const schedule = () => { + clearTimer(); + if (disposed || document.visibilityState === "hidden") return; + timer = window.setTimeout(() => void run(), 5000); + }; + const run = async () => { + if (disposed || running || document.visibilityState === "hidden") return; + running = true; + try { + await reload(); + } finally { + running = false; + schedule(); + } + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") clearTimer(); + else void run(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + schedule(); + return () => { + disposed = true; + clearTimer(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [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 = useMemo(() => visibleReviews(snapshot), [snapshot]); + const revision = snapshot?.revision ?? 0; + const durableMessageId = resolveDurableJourneyMessageId(messageId); + const needsExplicitAnchor = !durableMessageId; + useEffect(() => { + if (!onDockedReviewPanelChange) return; + onDockedReviewPanelChange( + panelMode === "dock" ? ( + + ) : null + ); + return () => onDockedReviewPanelChange(null); + }, [ + comparison, + durableMessageId, + onDockedReviewPanelChange, + onJumpToMessage, + panelMode, + reload, + reviews, + sessionId, + snapshot, + ]); + const action = useMemo(() => { + if (!sessionId) return null; + if (dialog === "task") + return () => + sessionJourneyApi.startTask({ + sessionId, + expectedRevision: revision, + taskId: makeId("task"), + name, + position, + }); + if (dialog === "checkpoint" && durableMessageId) + return () => + sessionJourneyApi.checkpoint({ + sessionId, + expectedRevision: revision, + checkpointId: makeId("checkpoint"), + name, + messageId: durableMessageId, + }); + if (dialog === "finish" && durableMessageId) + return () => + sessionJourneyApi.finishTask({ + sessionId, + expectedRevision: revision, + outcome, + messageId: durableMessageId, + }); + if (dialog === "closeFork" && durableMessageId) + return () => + sessionJourneyApi.closeFork( + { + sessionId, + expectedRevision: revision, + forkId: snapshot?.active_branch_id ?? "", + reviewId: makeId("review"), + outcome, + messageId: durableMessageId, + }, + makeId("review-job") + ); + if (dialog === "fork") + return () => + sessionJourneyApi.startFork({ + sessionId, + expectedRevision: revision, + forkId: makeId("fork"), + taskId: makeId("task"), + taskName: name, + // An omitted anchor is the explicit direct-Fork semantic. The + // backend resolves only the latest durable user row in the active + // branch; every other action remains strict and explicit. + ...(durableMessageId ? { anchorMessageId: durableMessageId } : {}), + }); + return null; + }, [ + dialog, + durableMessageId, + name, + outcome, + position, + revision, + sessionId, + snapshot?.active_branch_id, + ]); + if (!sessionId) return null; + return ( + <> +
+ {task ? ( + + + 任务:{task.name} + + ) : ( + + )} + + {task && ( + <> + + + {snapshot?.active_branch_id !== "main" && ( + + )} + + )} + +
+ {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" && panelMode !== "dock" && ( + + )} + + ); +}; + +const JourneyDialog: React.FC<{ + kind: "task" | "checkpoint" | "finish" | "fork" | "closeFork" | null; + name: string; + position: "最近用户消息" | "下一条用户消息"; + outcome: TaskOutcome; + needsExplicitAnchor: boolean; + onClose: () => void; + onName: (value: string) => void; + onPosition: (value: "最近用户消息" | "下一条用户消息") => void; + onOutcome: (value: TaskOutcome) => void; + onSubmit: () => void; +}> = ({ + kind, + name, + position, + outcome, + needsExplicitAnchor, + onClose, + onName, + onPosition, + onOutcome, + onSubmit, +}) => ( + + {kind === "task" || kind === "checkpoint" || kind === "fork" ? ( + + ) : null} + {kind === "task" ? ( + + ) : null} + {kind === "finish" || kind === "closeFork" ? ( + + ) : null} + {kind === "closeFork" ? ( +

+ 关闭后将以所选精确消息为结尾,并进入后台审核。 +

+ ) : null} + {kind === "fork" && needsExplicitAnchor ? ( +

+ 将使用当前分支最近一条已持久化的用户消息作为精确分叉锚点。 +

+ ) : null} + {(kind === "checkpoint" || kind === "finish" || kind === "closeFork") && + needsExplicitAnchor ? ( +

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

+ ) : null} +
+); + +const ReviewPanel: React.FC<{ + mode: ReviewPanelMode; + reviews: ReturnType; + snapshot: JourneySnapshot | null; + comparison: ForkCompareResponse | null; + sessionId: string; + selectedEvidenceMessageId: string | null; + onMode: (mode: ReviewPanelMode) => void; + onReload: () => Promise; + onJump?: (messageId: string) => void; +}> = ({ + mode, + reviews, + snapshot, + comparison, + sessionId, + selectedEvidenceMessageId, + onMode, + onReload, + onJump, +}) => { + const panelClass = + mode === "float" + ? "fixed right-5 top-20 z-50 w-80 shadow-lg" + : "w-80 shrink-0 border-l border-border-2"; + const mutate = async (operation: () => Promise) => { + await operation(); + await onReload(); + }; + return ( + + ); +}; + +function journeyTaskStateLabel(state: string) { + return ( + { + pending_next_user: "等待下一条用户消息", + pending: "待开始", + active: "进行中", + finished: "已结束", + }[state] ?? state + ); +} + +function journeyOutcomeLabel(outcome: string) { + return ( + outcomeOptions.find((option) => option.value === outcome)?.label ?? outcome + ); +} + +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/journeyMessageJump.test.ts b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.test.ts new file mode 100644 index 0000000000..e27e95edde --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; + +import { + focusJourneyMessage, + 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((jump) => { + received = jump.messageId; + }); + requestJourneyMessageJump("session-exact", "message-exact-42"); + stop(); + addEventListener.mockRestore(); + dispatchEvent.mockRestore(); + expect(received).toBe("message-exact-42"); + }); + + it("registers a listener that scrolls and highlights the exact rendered message", () => { + const scrollIntoView = vi.fn(); + const target = document.createElement("article"); + target.dataset.journeyMessageId = "checkpoint-message-42"; + target.scrollIntoView = scrollIntoView; + document.body.append(target); + let listener: ((event: Event) => void) | undefined; + const addEventListener = vi + .spyOn(window, "addEventListener") + .mockImplementation((_type, callback) => { + listener = callback as (event: Event) => void; + }); + + const stop = listenForJourneyMessageJump((jump) => { + focusJourneyMessage(jump.messageId); + }); + listener?.({ + detail: { + sessionId: "session-exact", + messageId: "checkpoint-message-42", + }, + } as CustomEvent<{ sessionId: string; messageId: string }>); + stop(); + + expect(target.dataset.journeyHighlight).toBe("true"); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "center", + behavior: "smooth", + }); + addEventListener.mockRestore(); + target.remove(); + }); +}); diff --git a/src/modules/WorkStation/Chat/Journey/journeyMessageJump.ts b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.ts new file mode 100644 index 0000000000..39cf1fba37 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/journeyMessageJump.ts @@ -0,0 +1,40 @@ +const JOURNEY_MESSAGE_JUMP_EVENT = "orgii:journey-message-jump"; + +export interface JourneyMessageJump { + sessionId: string; + messageId: string; +} + +export function requestJourneyMessageJump( + sessionId: string, + messageId: string +): void { + if (sessionId && messageId) + window.dispatchEvent( + new window.CustomEvent(JOURNEY_MESSAGE_JUMP_EVENT, { + detail: { sessionId, messageId }, + }) + ); +} + +export function listenForJourneyMessageJump( + onJump: (jump: JourneyMessageJump) => void +): () => void { + const listener = (event: Event) => { + const jump = (event as CustomEvent).detail; + if (jump?.sessionId && jump.messageId) onJump(jump); + }; + window.addEventListener(JOURNEY_MESSAGE_JUMP_EVENT, listener); + return () => window.removeEventListener(JOURNEY_MESSAGE_JUMP_EVENT, listener); +} + +/** Focus the exact rendered transcript row selected by a Journey checkpoint. */ +export function focusJourneyMessage(messageId: string): boolean { + const target = Array.from( + document.querySelectorAll("[data-journey-message-id]") + ).find((element) => element.dataset.journeyMessageId === messageId); + if (!target) return false; + target.dataset.journeyHighlight = "true"; + target.scrollIntoView({ block: "center", behavior: "smooth" }); + return true; +} 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..697c504a25 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import type { JourneySnapshot } from "@src/api/tauri/sessionJourney"; + +import { + activeTask, + 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("keeps every review state visible as durable Journey history", () => { + expect(visibleReviews(snapshot()).map((review) => review.id)).toEqual([ + "queued", + "ready", + "done", + ]); + }); + + 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("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..3315927b61 --- /dev/null +++ b/src/modules/WorkStation/Chat/Journey/sessionJourneyModel.ts @@ -0,0 +1,39 @@ +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[] { + // 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 { + 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") + ) + ); +} diff --git a/src/modules/WorkStation/TabContent/registry.ts b/src/modules/WorkStation/TabContent/registry.ts index ef3a6f5874..6f08a1d031 100644 --- a/src/modules/WorkStation/TabContent/registry.ts +++ b/src/modules/WorkStation/TabContent/registry.ts @@ -203,6 +203,21 @@ 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 SessionJourneyEntry: RendererEntry = { + Component: lazy(() => import("./renderers/sessionJourney")), + debugLabel: "session-journey", +}; + const WorkItemDetailEntry: RendererEntry = { Component: lazy(() => import("./renderers/workItemDetail")), debugLabel: "workItem-detail", @@ -285,6 +300,9 @@ export const REGISTRY: TabContentRegistry = { "project-org": ProjectOrgEntry, "project-org-settings": ProjectOrgSettingsEntry, "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/chatSession.contract.test.tsx b/src/modules/WorkStation/TabContent/renderers/chatSession.contract.test.tsx new file mode 100644 index 0000000000..ef75e498df --- /dev/null +++ b/src/modules/WorkStation/TabContent/renderers/chatSession.contract.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ChatSessionTabRenderer from "./chatSession"; + +const receivedTargets: Array = []; + +vi.mock("@src/engines/ChatPanel/ChatView", () => ({ + default: ({ initialMessageId }: { initialMessageId?: string }) => { + receivedTargets.push(initialMessageId); + return
{initialMessageId}
; + }, +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ChatSessionTabRenderer", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + receivedTargets.length = 0; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("reacts when an existing keyed tab receives a new exact message target", async () => { + const render = (initialMessageId?: string) => + root.render( + + ); + + await act(async () => render("parent-anchor-1")); + await act(async () => render("parent-anchor-2")); + + expect(receivedTargets).toEqual(["parent-anchor-1", "parent-anchor-2"]); + expect(container.textContent).toBe("parent-anchor-2"); + }); +}); diff --git a/src/modules/WorkStation/TabContent/renderers/chatSession.tsx b/src/modules/WorkStation/TabContent/renderers/chatSession.tsx index b5f267fa26..06b195ac24 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} @@ -187,6 +188,7 @@ const ChatSessionTabRenderer: React.FC = memo( > = memo( + ({ tab }) => { + return ( + + ); + } +); + +ProjectJourneyTabRenderer.displayName = "ProjectJourneyTabRenderer"; +export default ProjectJourneyTabRenderer; diff --git a/src/modules/WorkStation/TabContent/renderers/projectTree.contract.test.tsx b/src/modules/WorkStation/TabContent/renderers/projectTree.contract.test.tsx new file mode 100644 index 0000000000..668d9fdeee --- /dev/null +++ b/src/modules/WorkStation/TabContent/renderers/projectTree.contract.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { workstationLayoutAtom } from "@src/store/workstation/tabs"; + +import ProjectTreeTabRenderer from "./projectTree"; + +vi.mock("@src/modules/ProjectManager/ProjectJourney", () => ({ + ProjectTreePage: ({ + onOpenSession, + onOpenSessionJourney, + }: { + onOpenSession: (sessionId: string, title: string) => void; + onOpenSessionJourney: ( + sessionId: string, + title: string, + target?: { taskId?: string; forkId?: string; anchorMessageId?: string } + ) => void; + }) => ( +
+ + + +
+ ), +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ProjectTreeTabRenderer session Journey contract", () => { + let container: HTMLDivElement; + let root: Root; + const store = createStore(); + + beforeEach(() => { + store.set(workstationLayoutAtom, { + mainPane: { tabs: [], activeTabId: null }, + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("opens distinct chat and Session Journey tabs using the exact session anchor", async () => { + await act(async () => { + root.render( + + + + ); + }); + + await act(async () => { + ( + container.querySelector( + '[data-testid="open-chat"]' + ) as HTMLButtonElement + ).click(); + ( + container.querySelector( + '[data-testid="open-session-journey"]' + ) as HTMLButtonElement + ).click(); + ( + container.querySelector( + '[data-testid="open-fork-journey"]' + ) as HTMLButtonElement + ).click(); + }); + + const tabs = store.get(workstationLayoutAtom).mainPane.tabs; + expect(tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "chat-session", + data: { sessionId: "session-exact", title: "Exact session" }, + }), + expect.objectContaining({ + type: "session-journey", + data: { + sessionId: "session-exact", + sessionName: "fork-1", + selectedForkId: "fork-1", + selectedAnchorMessageId: "message-9", + }, + }), + ]) + ); + }); +}); diff --git a/src/modules/WorkStation/TabContent/renderers/projectTree.tsx b/src/modules/WorkStation/TabContent/renderers/projectTree.tsx new file mode 100644 index 0000000000..88d1c0bc47 --- /dev/null +++ b/src/modules/WorkStation/TabContent/renderers/projectTree.tsx @@ -0,0 +1,91 @@ +/** + * Renderer for `project-tree` tabs. + */ +import { useSetAtom } from "jotai"; +import React, { memo, useCallback } from "react"; + +import { ProjectTreePage } from "@src/modules/ProjectManager/ProjectJourney"; +import { + createChatSessionTab, + createProjectJourneyTab, + createSessionJourneyTab, + createWorkItemDetailTab, + openTab as openPanelTab, + 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) => ({ + ...prev, + mainPane: openPanelTab(prev.mainPane, tab), + })); + }, + [setLayout] + ); +} + +const ProjectTreeTabRenderer: React.FC = memo(() => { + const openTab = useOpenWorkStationTab(); + + return ( + { + openTab( + createProjectJourneyTab({ + projectId, + projectSlug, + projectName, + }) + ); + }} + onOpenSession={( + sessionId, + sessionTitle, + workItemId, + _projectSlug, + initialMessageId + ) => { + openTab( + createChatSessionTab( + sessionId, + sessionTitle || sessionId.slice(0, 8), + workItemId, + undefined, + initialMessageId + ) + ); + }} + onOpenSessionJourney={(sessionId, sessionName, target) => { + openTab( + createSessionJourneyTab({ + sessionId, + sessionName, + selectedTaskId: target?.taskId, + selectedForkId: target?.forkId, + selectedAnchorMessageId: target?.anchorMessageId, + }) + ); + }} + onOpenWorkItem={(workItemId, projectSlug) => { + openTab( + createWorkItemDetailTab( + undefined, + undefined, + workItemId, + workItemId, + projectSlug + ) + ); + }} + /> + ); +}); + +ProjectTreeTabRenderer.displayName = "ProjectTreeTabRenderer"; +export default ProjectTreeTabRenderer; diff --git a/src/modules/WorkStation/TabContent/renderers/sessionJourney.tsx b/src/modules/WorkStation/TabContent/renderers/sessionJourney.tsx new file mode 100644 index 0000000000..615ef3d5ee --- /dev/null +++ b/src/modules/WorkStation/TabContent/renderers/sessionJourney.tsx @@ -0,0 +1,33 @@ +/** 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/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx index 4de9185412..6249abcb10 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/Rename"; 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, diff --git a/src/store/workstation/tabHost.ts b/src/store/workstation/tabHost.ts index ff541f82a0..0c55aa8243 100644 --- a/src/store/workstation/tabHost.ts +++ b/src/store/workstation/tabHost.ts @@ -58,6 +58,9 @@ export function tabTypeToTabHost(type: WorkStationTabType): WorkstationTabHost { case "project-org": case "project-org-settings": case "project-git-sync-review": + case "project-tree": + case "project-journey": + case "session-journey": case "project-workitems": case "workItem-detail": return "project"; diff --git a/src/store/workstation/tabs/__tests__/tabMutations.test.ts b/src/store/workstation/tabs/__tests__/tabMutations.test.ts index e42606512c..ea300c2e15 100644 --- a/src/store/workstation/tabs/__tests__/tabMutations.test.ts +++ b/src/store/workstation/tabs/__tests__/tabMutations.test.ts @@ -8,6 +8,8 @@ import { deleteSearchTabSessionState, } from "@src/store/workstation/codeEditor/search"; +import { createChatSessionTab } from "../factories/chat"; +import { createSessionJourneyTab } from "../factories/project"; import { closeAllTabs, closeOtherTabs, @@ -74,6 +76,56 @@ describe("openTab", () => { expect(next.tabs[0].data.targetLine).toBe(42); expect(next.activeTabId).toBe("file:a.ts"); }); + + it("clears an exact chat target when a plain keyed chat tab is reopened", () => { + const targeted = createChatSessionTab( + "session-1", + "Chat", + undefined, + undefined, + "message-1" + ); + const next = openTab( + { tabs: [targeted], activeTabId: targeted.id }, + createChatSessionTab("session-1", "Chat") + ); + + expect(next.tabs[0].data).not.toHaveProperty("initialMessageId"); + }); + + it("replaces journey selection atomically and clears it for a plain reopen", () => { + const task = createSessionJourneyTab({ + sessionId: "session-1", + selectedTaskId: "task-1", + selectedAnchorMessageId: "message-1", + }); + const fork = createSessionJourneyTab({ + sessionId: "session-1", + selectedForkId: "fork-1", + selectedAnchorMessageId: "message-2", + }); + const forkState = openTab({ tabs: [task], activeTabId: task.id }, fork); + expect(forkState.tabs[0].data).toMatchObject({ + selectedForkId: "fork-1", + selectedAnchorMessageId: "message-2", + }); + expect(forkState.tabs[0].data).not.toHaveProperty("selectedTaskId"); + + const taskState = openTab(forkState, task); + expect(taskState.tabs[0].data).toMatchObject({ + selectedTaskId: "task-1", + selectedAnchorMessageId: "message-1", + }); + expect(taskState.tabs[0].data).not.toHaveProperty("selectedForkId"); + + const plain = openTab( + taskState, + createSessionJourneyTab({ sessionId: "session-1" }) + ); + expect(plain.tabs[0].data).not.toHaveProperty("selectedTaskId"); + expect(plain.tabs[0].data).not.toHaveProperty("selectedForkId"); + expect(plain.tabs[0].data).not.toHaveProperty("selectedAnchorMessageId"); + }); }); describe("closeTab", () => { diff --git a/src/store/workstation/tabs/__tests__/workspaceState.test.ts b/src/store/workstation/tabs/__tests__/workspaceState.test.ts index 023d3f38b7..693b6ba605 100644 --- a/src/store/workstation/tabs/__tests__/workspaceState.test.ts +++ b/src/store/workstation/tabs/__tests__/workspaceState.test.ts @@ -60,6 +60,9 @@ 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", + "session-journey": "shared-resource", "project-workitems": "shared-resource", "workItem-detail": "shared-resource", "chat-session": "shared-resource", @@ -119,7 +122,7 @@ describe("WorkStation tab ownership policy", () => { }) ); - expect(results).toHaveLength(39); + expect(results).toHaveLength(42); expect(results.every(({ actual, expected }) => actual === expected)).toBe( true ); diff --git a/src/store/workstation/tabs/factories/chat.ts b/src/store/workstation/tabs/factories/chat.ts index 74937d93cc..34a5a38611 100644 --- a/src/store/workstation/tabs/factories/chat.ts +++ b/src/store/workstation/tabs/factories/chat.ts @@ -14,6 +14,8 @@ export interface ChatSessionTabData { sessionId: string; workItemId?: string; workItemShortId?: string; + /** Exact durable transcript target. Changes must update an existing keyed tab. */ + initialMessageId?: string; } export const chatSessionTabFactory = defineTabFactory< @@ -32,12 +34,14 @@ export function createChatSessionTab( sessionId: string, title: string, workItemId?: string, - workItemShortId?: string + workItemShortId?: string, + initialMessageId?: string ): WorkStationTab { return chatSessionTabFactory({ sessionId, title, ...(workItemId && { workItemId }), ...(workItemShortId && { workItemShortId }), + ...(initialMessageId && { initialMessageId }), }); } diff --git a/src/store/workstation/tabs/factories/index.ts b/src/store/workstation/tabs/factories/index.ts index 2f01edf2ae..bace44d6da 100644 --- a/src/store/workstation/tabs/factories/index.ts +++ b/src/store/workstation/tabs/factories/index.ts @@ -99,6 +99,9 @@ export { projectOrgTabFactory, projectGitSyncReviewTabFactory, projectWorkItemsTabFactory, + projectTreeTabFactory, + projectJourneyTabFactory, + sessionJourneyTabFactory, workItemDetailTabFactory, createProjectDashboardTab, createProjectWorkItemsIndexTab, @@ -111,6 +114,9 @@ export { normalizeProjectDetailSurfaceView, createProjectGitSyncReviewTab, createProjectWorkItemsTab, + createProjectTreeTab, + createProjectJourneyTab, + createSessionJourneyTab, createWorkItemDetailTab, getProjectLinearProjectsTabChrome, getProjectLinearWorkItemsTabChrome, @@ -129,6 +135,8 @@ export type { ProjectGitSyncReviewTabData, 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 a3d7bc225e..c2e2cee600 100644 --- a/src/store/workstation/tabs/factories/project.ts +++ b/src/store/workstation/tabs/factories/project.ts @@ -98,6 +98,21 @@ 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 === "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" @@ -507,3 +522,74 @@ export function createWorkItemDetailTab( ...(returnTabId && { returnTabId }), }); } + +// ============================================ +// 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); +} + +export interface SessionJourneyTabData { + sessionId: string; + sessionName?: string; + selectedTaskId?: string; + selectedForkId?: string; + /** Exact durable anchor of the selected fork; never a sequence approximation. */ + selectedAnchorMessageId?: 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 6ee356b49d..1a7d4dcdb9 100644 --- a/src/store/workstation/tabs/index.ts +++ b/src/store/workstation/tabs/index.ts @@ -166,6 +166,9 @@ export { projectOrgTabFactory, projectGitSyncReviewTabFactory, projectWorkItemsTabFactory, + projectTreeTabFactory, + projectJourneyTabFactory, + sessionJourneyTabFactory, workItemDetailTabFactory, createProjectDashboardTab, createProjectWorkItemsIndexTab, @@ -178,6 +181,9 @@ export { normalizeProjectDetailSurfaceView, createProjectGitSyncReviewTab, createProjectWorkItemsTab, + createProjectTreeTab, + createProjectJourneyTab, + createSessionJourneyTab, createWorkItemDetailTab, getProjectLinearProjectsTabChrome, getProjectLinearWorkItemsTabChrome, @@ -274,3 +280,8 @@ export { clearAllEditorCacheAtom, switchActiveRepoAtom, } from "./editorCache"; + +export type { + ProjectJourneyTabData, + SessionJourneyTabData, +} from "./factories/project"; diff --git a/src/store/workstation/tabs/tabFactory.ts b/src/store/workstation/tabs/tabFactory.ts index aa9b43d3eb..6ccf47ca44 100644 --- a/src/store/workstation/tabs/tabFactory.ts +++ b/src/store/workstation/tabs/tabFactory.ts @@ -94,6 +94,9 @@ const DEFAULT_CATEGORY_BY_TYPE: Record< devtools: "browser", "project-dashboard": "project", "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/tabMutations.ts b/src/store/workstation/tabs/tabMutations.ts index acb786c8ee..ea1271a816 100644 --- a/src/store/workstation/tabs/tabMutations.ts +++ b/src/store/workstation/tabs/tabMutations.ts @@ -33,11 +33,13 @@ function mergeReopenedTab( const nextUnsaved = existingTab.hasUnsavedChanges || incomingTab.hasUnsavedChanges || undefined; + const data = mergeReopenedTabData(existingTab, incomingTab); if ( existingTab.title === incomingTab.title && existingTab.icon === incomingTab.icon && existingTab.hasUnsavedChanges === nextUnsaved && - !hasDataChanges(existingTab.data, incomingTab.data) + !hasDataChanges(existingTab.data, data) && + Object.keys(existingTab.data).length === Object.keys(data).length ) { return existingTab; } @@ -46,11 +48,39 @@ function mergeReopenedTab( ...existingTab, title: incomingTab.title, icon: incomingTab.icon, - data: { ...existingTab.data, ...incomingTab.data }, + data, hasUnsavedChanges: nextUnsaved, }; } +function mergeReopenedTabData( + existingTab: WorkStationTab, + incomingTab: WorkStationTab +): Record { + const data = { ...existingTab.data, ...incomingTab.data }; + if (incomingTab.type === "chat-session") { + delete data.initialMessageId; + if (typeof incomingTab.data.initialMessageId === "string") { + data.initialMessageId = incomingTab.data.initialMessageId; + } + } + if (incomingTab.type === "session-journey") { + delete data.selectedTaskId; + delete data.selectedForkId; + delete data.selectedAnchorMessageId; + for (const key of [ + "selectedTaskId", + "selectedForkId", + "selectedAnchorMessageId", + ]) { + if (typeof incomingTab.data[key] === "string") { + data[key] = incomingTab.data[key]; + } + } + } + return data; +} + /** * Open or switch to a tab */ diff --git a/src/store/workstation/tabs/types.ts b/src/store/workstation/tabs/types.ts index 88d578d5fa..59d34468c9 100644 --- a/src/store/workstation/tabs/types.ts +++ b/src/store/workstation/tabs/types.ts @@ -49,6 +49,9 @@ export type WorkStationTabType = | "project-org" | "project-org-settings" | "project-git-sync-review" + | "project-tree" + | "project-journey" + | "session-journey" | "project-workitems" | "workItem-detail" | "chat-session" @@ -262,6 +265,9 @@ export function getWorkstationTabOwnership( case "project-org": case "project-org-settings": case "project-git-sync-review": + case "project-tree": + case "project-journey": + case "session-journey": case "project-workitems": case "workItem-detail": case "chat-session": @@ -519,6 +525,9 @@ export const TOOL_TAB_TYPES = [ "project-settings", "project-org", "project-org-settings", + "project-tree", + "project-journey", + "session-journey", "project-workitems", "workItem-detail", "chat-session", diff --git a/vitest.config.ts b/vitest.config.ts index eabad86517..0da0a58dee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, }, test: { - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.{ts,tsx}"], environment: "node", globals: true, setupFiles: ["src/test/vitest.setup.ts"],