feat(app): define trusted rewind branch flow

This commit is contained in:
Codex
2026-07-28 03:23:57 -04:00
parent cf9507a9dd
commit 497c127e87
15 changed files with 671 additions and 26 deletions
+1 -1
View File
@@ -1 +1 @@
25441c099be603f4bcbe46079868ef851fded230d8bcda6dd627a027d80ba78c
93d800013b9bec3087490ae629039724076cf26d9bb9a5f8834c6aa7473b1ca7
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ForkBranchRequest",
"description": "Requests a new playable branch rooted at an already committed story node.\n\nThe caller identifies the branch head it observed so the trusted runtime can\nreject a rewind racing with a newer turn. The new branch id is generated by\nthe runtime from these trusted inputs and is never supplied by the UI or the\nmodel.",
"type": "object",
"properties": {
"actionId": {
"type": "string"
},
"currentBranchId": {
"type": "string"
},
"expectedCurrentNodeId": {
"type": "string"
},
"sourceNodeId": {
"type": "string"
},
"storyId": {
"type": "string"
}
},
"required": [
"storyId",
"currentBranchId",
"expectedCurrentNodeId",
"sourceNodeId",
"actionId"
]
}
@@ -0,0 +1,397 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ForkBranchResult",
"type": "object",
"properties": {
"branchId": {
"type": "string"
},
"playerView": {
"$ref": "#/$defs/PlayerView"
}
},
"required": [
"branchId",
"playerView"
],
"$defs": {
"ActionSuggestion": {
"type": "object",
"properties": {
"draft": {
"type": "string"
},
"id": {
"type": "string"
},
"label": {
"type": "string"
}
},
"required": [
"id",
"label",
"draft"
]
},
"BeatKind": {
"type": "string",
"enum": [
"narration",
"dialogue",
"action",
"system"
]
},
"HistoryNodeView": {
"type": "object",
"properties": {
"branchId": {
"type": "string"
},
"id": {
"type": "string"
},
"isCurrent": {
"type": "boolean"
},
"label": {
"type": "string"
},
"parentId": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"branchId",
"label",
"isCurrent"
]
},
"ItemPlacement": {
"type": "string",
"enum": [
"bag",
"worn",
"hand",
"scene",
"hidden"
]
},
"KnowledgeCertainty": {
"type": "string",
"enum": [
"suspected",
"reported",
"confirmed"
]
},
"PlayerItemView": {
"type": "object",
"properties": {
"condition": {
"type": "string"
},
"description": {
"type": "string"
},
"instanceId": {
"type": "string"
},
"name": {
"type": "string"
},
"placement": {
"$ref": "#/$defs/ItemPlacement"
},
"quantity": {
"type": "integer",
"format": "uint32",
"minimum": 0
}
},
"required": [
"instanceId",
"name",
"description",
"quantity",
"placement",
"condition"
]
},
"PlayerKnowledgeView": {
"type": "object",
"properties": {
"certainty": {
"$ref": "#/$defs/KnowledgeCertainty"
},
"id": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"id",
"title",
"summary",
"certainty"
]
},
"PlayerPromiseView": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"id": {
"type": "string"
},
"status": {
"$ref": "#/$defs/PromiseStatus"
},
"weight": {
"$ref": "#/$defs/PromiseWeight"
}
},
"required": [
"id",
"content",
"status",
"weight"
]
},
"PlayerView": {
"type": "object",
"properties": {
"beats": {
"type": "array",
"items": {
"$ref": "#/$defs/PresentationBeat"
}
},
"branchId": {
"type": "string"
},
"canContinue": {
"type": "boolean"
},
"characterExpression": {
"type": [
"string",
"null"
],
"default": null
},
"characterName": {
"type": "string"
},
"characterPose": {
"type": [
"string",
"null"
],
"default": null
},
"history": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/HistoryNodeView"
}
},
"inventory": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/PlayerItemView"
}
},
"knowledge": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/PlayerKnowledgeView"
}
},
"nodeId": {
"type": "string"
},
"promises": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/PlayerPromiseView"
}
},
"relationship": {
"$ref": "#/$defs/RelationshipView"
},
"sceneId": {
"type": "string"
},
"sceneTitle": {
"type": "string"
},
"storyId": {
"type": "string"
},
"suggestions": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/ActionSuggestion"
}
}
},
"required": [
"storyId",
"nodeId",
"branchId",
"sceneId",
"sceneTitle",
"characterName",
"beats",
"relationship",
"canContinue"
]
},
"PresentationBeat": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"kind": {
"$ref": "#/$defs/BeatKind"
},
"speaker": {
"type": [
"string",
"null"
]
},
"text": {
"type": "string"
},
"visual": {
"anyOf": [
{
"$ref": "#/$defs/VisualDirective"
},
{
"type": "null"
}
]
}
},
"required": [
"id",
"kind",
"text"
]
},
"PromiseStatus": {
"type": "string",
"enum": [
"proposed",
"accepted",
"fulfilled",
"broken",
"released",
"impossible"
]
},
"PromiseWeight": {
"type": "string",
"enum": [
"minor",
"major"
]
},
"RelationshipBand": {
"type": "string",
"enum": [
"distant",
"guarded",
"warming",
"close",
"bonded"
]
},
"RelationshipView": {
"type": "object",
"properties": {
"affinity": {
"$ref": "#/$defs/RelationshipBand"
},
"attachment": {
"$ref": "#/$defs/RelationshipBand"
},
"hope": {
"$ref": "#/$defs/RelationshipBand"
},
"intimacy": {
"$ref": "#/$defs/RelationshipBand"
},
"respect": {
"$ref": "#/$defs/RelationshipBand"
},
"trust": {
"$ref": "#/$defs/RelationshipBand"
},
"updatedAtNode": {
"type": [
"string",
"null"
]
}
},
"required": [
"affinity",
"trust",
"hope",
"respect",
"intimacy",
"attachment"
]
},
"VisualDirective": {
"type": "object",
"properties": {
"character": {
"type": [
"string",
"null"
]
},
"expression": {
"type": [
"string",
"null"
]
},
"pose": {
"type": [
"string",
"null"
]
},
"scene": {
"type": [
"string",
"null"
]
}
}
}
}
}
+4
View File
@@ -116,6 +116,10 @@ export type TurnRequest = { storyId: string, branchId: string, expectedNodeId: s
export type TurnResult = { committedNodeId: string, playerView: PlayerView, };
export type ForkBranchRequest = { storyId: string, currentBranchId: string, expectedCurrentNodeId: string, sourceNodeId: string, actionId: string, };
export type ForkBranchResult = { branchId: string, playerView: PlayerView, };
export type TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
+14 -9
View File
@@ -6,15 +6,16 @@ use std::{
use nana_domain::{
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
HistoryNodeView, ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec,
KnowledgeCertainty, KnowledgeRecord, Persona, PlayerItemView, PlayerKnowledgeView,
PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure,
PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise,
PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand,
RelationshipDimension, RelationshipState, RelationshipView, ResourceBundle, ResourceHeader,
ResourceId, ResourceKind, ResourceRef, RuntimeState, SkillValue, StateDelta, StateOp, Story,
StoryBinding, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
ValidationCode, ValidationIssue, ValidationReport, VisualDirective, WorldBook, WorldBookEntry,
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona,
PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule,
PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene,
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode,
TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport,
VisualDirective, WorldBook, WorldBookEntry,
};
use schemars::{JsonSchema, schema_for};
use serde::Serialize;
@@ -71,6 +72,8 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
add_schema::<PlayerView>(&mut outputs, &schema_dir, "player-view")?;
add_schema::<TurnRequest>(&mut outputs, &schema_dir, "turn-request")?;
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
add_schema::<ForkBranchRequest>(&mut outputs, &schema_dir, "fork-branch-request")?;
add_schema::<ForkBranchResult>(&mut outputs, &schema_dir, "fork-branch-result")?;
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
@@ -134,6 +137,8 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
TurnIntent::decl(),
TurnRequest::decl(),
TurnResult::decl(),
ForkBranchRequest::decl(),
ForkBranchResult::decl(),
TurnFailureCode::decl(),
TurnFailure::decl(),
AppInfo::decl(),
+25
View File
@@ -720,6 +720,31 @@ pub struct TurnRequest {
pub input: String,
}
/// Requests a new playable branch rooted at an already committed story node.
///
/// The caller identifies the branch head it observed so the trusted runtime can
/// reject a rewind racing with a newer turn. The new branch id is generated by
/// the runtime from these trusted inputs and is never supplied by the UI or the
/// model.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct ForkBranchRequest {
pub story_id: String,
pub current_branch_id: String,
pub expected_current_node_id: String,
pub source_node_id: String,
pub action_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct ForkBranchResult {
pub branch_id: String,
pub player_view: PlayerView,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
+2
View File
@@ -25,6 +25,8 @@ const required = [
"app-info.schema.json",
"character-card.schema.json",
"demo-pack-summary.schema.json",
"fork-branch-request.schema.json",
"fork-branch-result.schema.json",
"item-spec.schema.json",
"persona.schema.json",
"player-view.schema.json",
+20 -5
View File
@@ -97,19 +97,34 @@ describe("App", () => {
wrapper.unmount();
});
it("selects a rewind node without claiming to persist a branch", async () => {
it("creates a new branch from an old node and keeps the old route intact", async () => {
const wrapper = await mountLoadedApp();
await wrapper.get(".continue-button").trigger("click");
await vi.waitFor(() => {
expect(wrapper.get(".statusline span").text()).toBe("node_002");
});
const historyButton = wrapper.findAll(".top-actions button")[2];
await historyButton.trigger("click");
expect(wrapper.find(".rewind-placeholder").exists()).toBe(false);
const nodeButton = wrapper.get(".history-list button");
const nodeButton = wrapper.findAll(".history-list button")[0];
await nodeButton.trigger("click");
expect(nodeButton.attributes("aria-pressed")).toBe("true");
expect(wrapper.get(".rewind-placeholder").text()).toContain("从这里继续(尚未接入)");
expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeDefined();
expect(wrapper.get(".rewind-placeholder").text()).toContain("不会更改当前线路");
expect(wrapper.get(".rewind-placeholder").text()).toContain("旧线路不会被覆盖");
expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeUndefined();
await wrapper.get(".rewind-placeholder button").trigger("click");
expect(wrapper.get(".rewind-placeholder button").text()).toContain("正在创建线路");
await vi.waitFor(() => {
const status = wrapper.findAll(".statusline span");
expect(status[0]?.text()).toBe("node_001");
expect(status[1]?.text()).toContain("branch_fork_");
});
expect(wrapper.find("#history-panel").exists()).toBe(false);
wrapper.unmount();
});
+25 -3
View File
@@ -13,6 +13,7 @@ const {
loading,
pack,
playerView,
forkBranch,
submitTurn,
turnError
} = useDemo();
@@ -97,6 +98,15 @@ async function continueStory() {
await submitTurn("continue", "");
}
async function continueFromSelectedNode() {
if (!selectedHistoryNode.value || busy.value) return;
await forkBranch(selectedHistoryNode.value);
if (!turnError.value) {
selectedHistoryNode.value = null;
closePanel();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape" && activePanel.value) {
closePanel();
@@ -317,7 +327,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
</div>
<div v-else class="panel-content history-panel">
<p class="panel-intro">选择一个故事节点查看回溯入口当前原型不会创建或保存新分支</p>
<p class="panel-intro">从旧节点继续会创建一条新线路当前线路仍会完整保留</p>
<ol class="history-list">
<li v-for="node in playerView.history" :key="node.id">
<button
@@ -333,8 +343,20 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
</ol>
<div v-if="selectedHistoryNode" class="rewind-placeholder" aria-live="polite">
<span>已选择{{ selectedHistoryNode }}</span>
<button type="button" disabled>从这里继续尚未接入</button>
<small>这里只展示入口不会更改当前线路</small>
<button
type="button"
:disabled="busy || selectedHistoryNode === playerView.nodeId"
@click="continueFromSelectedNode"
>
{{ busy ? "正在创建线路…" : "从这里继续" }}
</button>
<small>
{{
selectedHistoryNode === playerView.nodeId
? "这里已经是当前节点。"
: "新线路会从所选节点开始,旧线路不会被覆盖。"
}}
</small>
</div>
</div>
</aside>
+29 -2
View File
@@ -1,7 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import initialView from "../../fixtures/player-view/initial.json";
import type { PlayerView, TurnRequest, TurnResult } from "@contracts";
import type {
ForkBranchRequest,
ForkBranchResult,
PlayerView,
TurnRequest,
TurnResult
} from "@contracts";
const invokeMock = vi.hoisted(() => vi.fn());
@@ -9,7 +15,7 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock
}));
import { submitTurn } from "./bridge";
import { forkBranch, submitTurn } from "./bridge";
const request: TurnRequest = {
storyId: initialView.storyId,
@@ -43,4 +49,25 @@ describe("Tauri bridge", () => {
await expect(submitTurn(request, initialView as PlayerView)).resolves.toEqual(expected);
expect(invokeMock).toHaveBeenCalledWith("submit_turn", { request });
});
it("sends only the typed fork request to the fork_branch command", async () => {
const forkRequest: ForkBranchRequest = {
storyId: initialView.storyId,
currentBranchId: initialView.branchId,
expectedCurrentNodeId: "node_002",
sourceNodeId: initialView.nodeId,
actionId: "fork_bridge_test"
};
const expected: ForkBranchResult = {
branchId: "branch_fork_bridge_test",
playerView: {
...(initialView as PlayerView),
branchId: "branch_fork_bridge_test"
}
};
invokeMock.mockResolvedValue(expected);
await expect(forkBranch(forkRequest, initialView as PlayerView)).resolves.toEqual(expected);
expect(invokeMock).toHaveBeenCalledWith("fork_branch", { request: forkRequest });
});
});
+13 -1
View File
@@ -4,12 +4,14 @@ import initialView from "../../fixtures/player-view/initial.json";
import type {
AppInfo,
DemoPackSummary,
ForkBranchRequest,
ForkBranchResult,
PlayerView,
TurnRequest,
TurnResult
} from "@contracts";
import { submitDemoTurn } from "./turnAdapter";
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
function isTauri(): boolean {
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
@@ -58,3 +60,13 @@ export async function submitTurn(
}
return invoke<TurnResult>("submit_turn", { request });
}
export async function forkBranch(
request: ForkBranchRequest,
currentView: PlayerView
): Promise<ForkBranchResult> {
if (!isTauri()) {
return forkDemoBranch(request, currentView);
}
return invoke<ForkBranchResult>("fork_branch", { request });
}
+26 -2
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import initialView from "../../fixtures/player-view/initial.json";
import type { PlayerView, TurnRequest } from "@contracts";
import type { ForkBranchRequest, PlayerView, TurnRequest } from "@contracts";
import { submitDemoTurn } from "./turnAdapter";
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
function request(intent: TurnRequest["intent"], input: string): TurnRequest {
return {
@@ -36,4 +36,28 @@ describe("local turn adapter", () => {
expect(result.playerView.beats.some((beat) => beat.speaker === "你")).toBe(false);
expect(result.playerView.beats.at(-1)?.speaker).toBe("娜娜");
});
it("mirrors a fork without moving or deleting the source history", async () => {
const advanced = await submitDemoTurn(
request("speak_or_act", "我先去看看站台。"),
initialView as PlayerView
);
const forkRequest: ForkBranchRequest = {
storyId: advanced.playerView.storyId,
currentBranchId: advanced.playerView.branchId,
expectedCurrentNodeId: advanced.playerView.nodeId,
sourceNodeId: initialView.nodeId,
actionId: "fork_adapter_test"
};
const forked = await forkDemoBranch(forkRequest, advanced.playerView);
expect(forked.branchId).toBe("branch_fork_adapter_test");
expect(forked.playerView.nodeId).toBe(initialView.nodeId);
expect(forked.playerView.branchId).toBe(forked.branchId);
expect(forked.playerView.history).toHaveLength(1);
expect(forked.playerView.history[0]?.isCurrent).toBe(true);
expect(advanced.playerView.branchId).toBe(initialView.branchId);
expect(advanced.playerView.nodeId).toBe("node_002");
});
});
+47 -1
View File
@@ -1,4 +1,11 @@
import type { PlayerView, PresentationBeat, TurnRequest, TurnResult } from "@contracts";
import type {
ForkBranchRequest,
ForkBranchResult,
PlayerView,
PresentationBeat,
TurnRequest,
TurnResult
} from "@contracts";
const DEMO_LATENCY_MS = 120;
@@ -95,3 +102,42 @@ export async function submitDemoTurn(
}
};
}
/**
* Browser-only rewind preview. The desktop path performs the durable fork in
* SQLite; this adapter mirrors the same DTO boundary without claiming local
* persistence.
*/
export async function forkDemoBranch(
request: ForkBranchRequest,
currentView: PlayerView
): Promise<ForkBranchResult> {
await new Promise((resolve) => window.setTimeout(resolve, DEMO_LATENCY_MS));
if (
request.storyId !== currentView.storyId ||
request.currentBranchId !== currentView.branchId ||
request.expectedCurrentNodeId !== currentView.nodeId
) {
throw new Error("story branch changed; refresh and retry");
}
const sourceIndex = currentView.history.findIndex((node) => node.id === request.sourceNodeId);
if (sourceIndex < 0) throw new Error("selected story node is unavailable");
const branchId = `branch_${request.actionId.replaceAll(/[^a-zA-Z0-9_.-]/g, "_")}`;
const history = currentView.history.slice(0, sourceIndex + 1).map((node) => ({
...node,
isCurrent: node.id === request.sourceNodeId
}));
return {
branchId,
playerView: {
...currentView,
nodeId: request.sourceNodeId,
branchId,
history
}
};
}
+29
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from "vue";
import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
import {
forkBranch as forkRuntimeBranch,
getAppInfo,
getDemoPackSummary,
getDemoPlayerView,
@@ -77,6 +78,33 @@ export function useDemo() {
}
}
async function forkBranch(sourceNodeId: string): Promise<void> {
const currentView = playerView.value;
if (!currentView || busy.value || sourceNodeId === currentView.nodeId) return;
busy.value = true;
turnError.value = null;
lastSubmittedIntent.value = null;
try {
const result = await forkRuntimeBranch(
{
storyId: currentView.storyId,
currentBranchId: currentView.branchId,
expectedCurrentNodeId: currentView.nodeId,
sourceNodeId,
actionId: `fork_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
},
currentView
);
playerView.value = result.playerView;
} catch (reason) {
turnError.value = errorMessage(reason);
} finally {
busy.value = false;
}
}
return {
appInfo,
busy,
@@ -85,6 +113,7 @@ export function useDemo() {
loading,
pack,
playerView,
forkBranch,
submitTurn,
turnError
};
+9 -2
View File
@@ -720,12 +720,19 @@ textarea:focus-visible {
.rewind-placeholder button {
padding: 11px 14px;
color: rgb(26 20 20 / 64%);
background: rgb(231 184 164 / 54%);
cursor: pointer;
color: #21191b;
background: #e7b8a4;
border: 0;
border-radius: 9px;
}
.rewind-placeholder button:disabled {
cursor: default;
color: rgb(26 20 20 / 52%);
background: rgb(231 184 164 / 44%);
}
.rewind-placeholder small {
color: rgb(245 241 234 / 34%);
}