Skip to content

Commit caee3f8

Browse files
committed
fix(renderer): restore session proposal backfill
- synced proposal rail and lineage IPC specs - archived fix-session-proposal-backfill
1 parent ad20edd commit caee3f8

10 files changed

Lines changed: 425 additions & 9 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
schema: spec-driven
2+
created: 2026-06-25T00:00:00.000Z
3+
status: applying
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
## Context
2+
3+
`ChatSessionEventRail` 通过 `useSessionStore.sessionProposals` 展示当前 chat session 关联的 proposal。运行中创建 proposal 时,MCP server 写出 create-proposal 事件,主进程 lineage consumer 将 `sessionId -> changeId` 写入 lineage,并启动 `ProposalStatusService.watchProposal`。重启后 renderer 的内存态 `sessionProposals` 丢失,需要在进入 session 时通过 lineage 与 proposal 列表恢复。
4+
5+
现有 `useSessionStore.backfillSessionProposals(sessionId)` 已调用 `lineageApi.getBySession(projectId, sessionId)`,再用 lineage 中的 `LineageProposalLink.changeId` 去匹配 `useProposalStore.proposals`。问题在于 archived proposal 的 `ProposalMeta.id` 当前可能是 archive 目录名 `YYYY-MM-DD-<changeId>`,而 lineage 保存的是原始 `changeId`。主进程已有 `stripArchivePrefix` / `resolveChangeDirAnywhere` 等路径定位统一化,但 `proposal:list` 仍保留 archive 目录名作为 `ProposalMeta.id`,现有测试也覆盖了该行为。
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
11+
- 进入 session 时恢复展示 lineage 已记录的所有 proposal,包括 archived proposal。
12+
- 保持 archived proposal 为终态展示,不为它重新开启 `proposal:watch`
13+
- 保持非 archived proposal 在回填后继续启动 watch,以便后续状态变化能推送到 renderer。
14+
- 补齐 `lineage:getBySession` 的 IPC 规格,使 renderer 的恢复路径有明确契约。
15+
16+
**Non-Goals:**
17+
18+
- 不统一修改 `ProposalMeta.id` 的主进程返回格式;archive proposal 可继续以 `YYYY-MM-DD-<changeId>` 作为 id。
19+
- 不新增 IPC channel,不改变 lineage 持久化结构。
20+
- 不重构 `ProposalStatusService` 的 watcher key 策略。
21+
- 不改变 `ChatProposalPanel` 的按钮、badge 或详情页导航语义。
22+
23+
## Decisions
24+
25+
1. 在 renderer 回填层做 `changeId` 等价匹配,而不是修改 `proposal:list`
26+
- 选择:在 `src/renderer/src/stores/session.ts` 中新增局部 helper,将 proposal id 与 lineage changeId 视为等价,当 `proposal.id === changeId``proposal.id` 去掉 `YYYY-MM-DD-` 前缀后等于 `changeId`
27+
- 理由:主进程列表行为已有测试断言保留 archive 前缀;直接改变 `ProposalMeta.id` 会影响 proposal 列表、详情路由、archive 后查找和 overview 等多个调用方,超出本次 bug fix 范围。
28+
- 替代方案:修改 `readProposalFiles()` 让 archived `ProposalMeta.id` 始终无前缀。拒绝,因为这是更大的行为变更,且现有测试明确覆盖了前缀保留。
29+
30+
2. watch 启动由匹配到的 `ProposalMeta.status` 决定。
31+
- 选择:`backfillSessionProposals` 回填 `matched` 后,只对 `proposal.status !== "archived"` 的项目调用 `ensureProposalWatched(proposal, sessionId)`
32+
- 理由:archived proposal 已是终态,重新 watch 只会增加主进程 watcher 资源,不提供状态同步价值;active proposal 仍需要 watch 以接收 draft/applying/archived 变化。
33+
- 替代方案:对所有回填 proposal 都 watch。拒绝,因为它会对 archived 终态产生不必要监听,且与本次确认的产品边界冲突。
34+
35+
3. 保持 statusChanged 推送路径的防御性 watch 行为,但避免对 archived payload 重启 watch。
36+
- 选择:`handleProposalStatusChanged` 中如果收到未知 proposal 的 status push,可继续刷新 proposal store 并 upsert;但仅当 payload/status 对应 proposal 非 archived 时才需要补 watch。
37+
- 理由:status push 本身说明主进程已有 watcher 或刚处理事件。对 archived 继续 watch 没必要;对非 archived 保持既有防御逻辑。
38+
39+
## Risks / Trade-offs
40+
41+
- [Risk] Renderer 本地实现 archive 前缀剥离规则可能与主进程 `stripArchivePrefix` 未来变化不一致。→ Mitigation:使用同一正则语义 `^\d{4}-\d{2}-\d{2}-`,并通过 store 单测固定行为;若未来主进程规则变化,再同步调整 renderer helper。
42+
- [Risk] 一个项目同时存在 active `foo` 和 archived `YYYY-MM-DD-foo` 时,回填可能匹配两个 proposal。→ Mitigation:这是现有 `readProposalFiles` 可返回的状态;回填展示 session lineage 关联的所有匹配项时应排序并保持去重,watch 只作用于非 archived 项。
43+
- [Risk] `lineage:getBySession` 已在代码中存在但 spec 描述缺失,Apply 阶段可能只改 renderer。→ Mitigation:本 change 增加 `lineage-ipc` delta,明确该 channel 是恢复路径的依赖。
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## Why
2+
3+
重启 FylloCode 后,已经产生 proposal 的 chat session 重新打开时可能不再展示 `ChatProposalPanel`,尤其是已归档 proposal 因 archive 目录名带日期前缀而无法用 lineage 中的原始 `changeId` 精确匹配。现有规格已经要求 EventRail 展示当前 session 关联的所有 proposal,因此这是恢复路径没有履行既有契约的 bug。
4+
5+
## What Changes
6+
7+
- 修复 `useSessionStore` 在进入 session 时从 `lineage:getBySession` 回填 proposal 的匹配逻辑,使 lineage 中的原始 `changeId` 能匹配 active proposal,也能匹配 archive 目录名形如 `YYYY-MM-DD-<changeId>` 的 archived proposal。
8+
- 回填后继续展示 archived proposal,但不再为 archived proposal 调用 `proposal:watch`;只有 `creating``draft``applying` 等非终态 proposal 需要启动主进程状态监听。
9+
- 补齐 `lineage:getBySession` 的 IPC 规格描述,明确 renderer 可用它读取 session 所属 lineage 及该 session 的 proposal 产出列表。
10+
- 增加 renderer store 测试,覆盖 session 重开/切换后的 proposal 回填、archive id 匹配和 archived 不 watch 的行为。
11+
12+
## Capabilities
13+
14+
### New Capabilities
15+
16+
- 无。
17+
18+
### Modified Capabilities
19+
20+
- `chat-event-rail-proposal-status`: 明确 Chat EventRail 在进入 session 时必须从 lineage 回填历史 proposal,并只对非 archived proposal 启动状态监听。
21+
- `lineage-ipc`: 补齐 `lineage:getBySession` channel 的渲染进程 IPC 契约,用于按 session 查询 lineage 投影。
22+
23+
## Impact
24+
25+
- 影响 `src/renderer/src/stores/session.ts``backfillSessionProposals` / watch 启动边界。
26+
- 影响 `test/renderer/src/stores/session.spec.ts` 的 lineage/proposal API mock 与新增恢复场景。
27+
- 不新增 IPC channel,不改变持久化 schema,不改变 `ProposalMeta.id` 的主进程返回格式。
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Chat EventRail 展示当前 session 的 proposal 列表
4+
5+
`ChatSessionEventRail` SHALL 展示与当前活跃 session 关联的所有 proposal,按创建时间倒序排列,并实时反映非终态 proposal 的状态变化。进入或切换到一个已存在的 session 时,renderer SHALL 通过 `lineage:getBySession` 读取该 session 的 proposal 产出列表,并用 `useProposalStore.proposals` 中的完整 `ProposalMeta` 回填 `useSessionStore.sessionProposals`。回填匹配 SHALL 使用 lineage 中的原始 `changeId`,并且 SHALL 同时匹配 `ProposalMeta.id === changeId` 与 archived proposal 的 `ProposalMeta.id === YYYY-MM-DD-<changeId>` 形式。
6+
7+
回填完成后,renderer SHALL 仅对 `status !== "archived"` 的 proposal 调用 `proposal:watch` 启动主进程状态监听;`archived` proposal SHALL 作为终态展示,不启动新的状态监听。
8+
9+
`ChatProposalPanel` SHALL 从 `ProposalMeta.status` 与当前匹配的 `ApplyRunMeta.status` 派生卡片展示态:
10+
11+
- `creating`:显示 badge “创建中”
12+
- `draft`:显示 badge “草稿”
13+
- `applying` 且没有匹配的 done apply run:显示 badge “实施中”
14+
- `applying``proposalRunStore.runMeta.changeId === proposal.id``proposalRunStore.runMeta.status === "done"`:显示 badge “可归档”
15+
- `applying``proposalRunStore.isArchiving === true``proposalRunStore.runMeta.changeId === proposal.id`:显示 badge “归档中”
16+
- `archived`:显示 badge “已归档”
17+
18+
#### Scenario: 当前 session 有一个 draft proposal
19+
20+
- **GIVEN** 用户处于 `session-1`,该 session 已关联一个 `draft` proposal
21+
- **WHEN** `ChatSessionEventRail` 渲染
22+
- **THEN** rail SHALL 展示该 proposal 的标题、状态 badge 为“草稿”,并提供“开始实现”按钮
23+
24+
#### Scenario: proposal 状态实时更新
25+
26+
- **GIVEN** `ChatSessionEventRail` 已展示一个 `draft` proposal
27+
- **WHEN** renderer 收到 `proposal:statusChanged` 事件,payload 中 `status` 变为 `applying`
28+
- **THEN** rail 中对应 proposal 的状态 badge SHALL 立即更新为“实施中”,且“开始实现”按钮消失
29+
30+
#### Scenario: apply run 完成后显示可归档
31+
32+
- **GIVEN** `ChatSessionEventRail` 已展示一个 `applying` proposal
33+
- **AND** `proposalRunStore.runMeta.changeId` 等于该 proposal id
34+
- **AND** `proposalRunStore.runMeta.status === "done"`
35+
- **WHEN** `ChatProposalPanel` 渲染该 proposal
36+
- **THEN** 该 proposal 的状态 badge SHALL 显示“可归档”
37+
- **AND** 该 proposal SHALL 显示“归档”按钮
38+
39+
#### Scenario: 点击归档后显示归档中
40+
41+
- **GIVEN** `ChatSessionEventRail` 已展示一个“可归档” proposal
42+
- **WHEN** 用户点击“归档”按钮
43+
- **AND** `proposalRunStore.isArchiving === true`
44+
- **AND** `proposalRunStore.runMeta.changeId` 等于该 proposal id
45+
- **THEN** 该 proposal 的状态 badge SHALL 显示“归档中”
46+
- **AND** 该 proposal SHALL 不显示“归档”按钮
47+
- **AND** 该 proposal SHALL 显示“查看详情”按钮
48+
49+
#### Scenario: 切换 session 后展示不同 proposal 列表
50+
51+
- **GIVEN** `session-1` 关联 proposal A,`session-2` 关联 proposal B
52+
- **WHEN** 用户从 `session-1` 切换到 `session-2`
53+
- **THEN** `ChatSessionEventRail` SHALL 隐藏 proposal A,展示 proposal B
54+
55+
#### Scenario: 重启后进入 session 恢复 active proposal 并启动 watch
56+
57+
- **GIVEN** FylloCode 重启后 renderer 内存中的 `sessionProposals` 为空
58+
- **AND** lineage 中 `session-1` 的 proposal 列表包含 `changeId: "fix-login"`
59+
- **AND** `useProposalStore.proposals` 包含 `{ id: "fix-login", status: "draft" }`
60+
- **WHEN** 用户进入 `session-1`
61+
- **THEN** `useSessionStore.sessionProposals["session-1"]` SHALL 包含该完整 `ProposalMeta`
62+
- **AND** renderer SHALL 调用 `proposal:watch`,入参包含 `changeId: "fix-login"``sessionId: "session-1"`
63+
64+
#### Scenario: 重启后进入 session 恢复 archived proposal 且不启动 watch
65+
66+
- **GIVEN** FylloCode 重启后 renderer 内存中的 `sessionProposals` 为空
67+
- **AND** lineage 中 `session-1` 的 proposal 列表包含 `changeId: "fix-login"`
68+
- **AND** `useProposalStore.proposals` 包含 `{ id: "2026-06-25-fix-login", status: "archived" }`
69+
- **WHEN** 用户进入 `session-1`
70+
- **THEN** `useSessionStore.sessionProposals["session-1"]` SHALL 包含该 archived `ProposalMeta`
71+
- **AND** renderer SHALL NOT 为 `2026-06-25-fix-login` 调用 `proposal:watch`
72+
73+
#### Scenario: 回填失败不阻断 session 切换
74+
75+
- **GIVEN** 用户进入 `session-1`
76+
- **AND** `lineage:getBySession` 返回 `null`、错误响应或抛出异常
77+
- **WHEN** `useSessionStore` 执行 proposal 回填
78+
- **THEN** 当前 session SHALL 保持选中
79+
- **AND** `sessionProposals["session-1"]` SHALL 保持为空
80+
- **AND** renderer SHALL NOT 调用 `proposal:watch`
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: lineage 服务向渲染进程暴露 IPC 契约
4+
5+
系统 SHALL 在 `src/main/ipc/lineage.ts` 注册 lineage 的 IPC handler,并在 `src/preload/api/lineage.ts``src/renderer/src/api/lineage.ts` 暴露对应的请求-响应 api。新增 channel SHALL 定义在 lineage 专属的 channel 常量中(与 `ChatChannels` 同模式),SHALL NOT 复用或污染 `ChatChannels`。所有 handler SHALL 复用 `ipc/_kit``wrapHandler``validate`,入参 SHALL 由 `src/shared/schemas/ipc/lineage.ts` 的 zod schema 校验。
6+
7+
本能力 SHALL 暴露以下 channel:
8+
9+
- `lineage:ensureTaskSubject`:入参 `{ projectId: string, snapshot: LineageTaskSnapshot }`,调用 `lineage-service.ensureTaskSubject`,返回创建/复用的 `Subject`
10+
- `lineage:linkTaskSession`:入参 `{ projectId: string, taskRef: LineageTaskRef, sessionId: string }`,调用 `lineage-service.linkTaskSession`(见 project-lineage-model delta),返回更新后的 `Subject``null`
11+
- `lineage:getByTask`:入参 `{ projectId: string, ref: LineageTaskRef }`,调用 `lineage-service.getByTask`,返回 `TaskDownstreamProjection | null`
12+
- `lineage:getBySession`:入参 `{ projectId: string, sessionId: string }`,调用 `lineage-service.getBySession`,返回 `SessionLineageProjection | null`。renderer 可使用该 channel 在进入 session 时恢复该 session 所属 lineage、任务快照与该 session 产出的 proposal 列表。
13+
14+
#### Scenario: ensureTaskSubject 经 IPC 建/复用 subject
15+
16+
- **WHEN** renderer 调用 `lineageApi.ensureTaskSubject(projectId, snapshot)`
17+
- **THEN** 主进程 handler 校验入参后调用 `lineage-service.ensureTaskSubject(projectPath, snapshot)`
18+
- **AND** 返回的 `Subject` 包含与 `snapshot.ref` 对应的 `task` 字段
19+
- **AND** 对同一 `snapshot.ref` 重复调用返回同一个 subjectId(幂等)
20+
21+
#### Scenario: getByTask 经 IPC 读取 subject 快照
22+
23+
- **WHEN** renderer 调用 `lineageApi.getByTask(projectId, ref)`,且该 ref 已存在 subject
24+
- **THEN** 返回 `TaskDownstreamProjection`,其 `task.snapshot` 为发起讨论时拍摄的全量 `TaskItem`
25+
- **AND** 即使该 task 在第三方侧已关闭、本地不可见,仍能从快照读取来源与标题
26+
27+
#### Scenario: getByTask 对未知 ref 返回 null
28+
29+
- **WHEN** renderer 调用 `lineageApi.getByTask(projectId, ref)`,且该 ref 无对应 subject
30+
- **THEN** 返回 `null`,不抛出异常
31+
32+
#### Scenario: getBySession 经 IPC 读取 session lineage
33+
34+
- **WHEN** renderer 调用 `lineageApi.getBySession(projectId, "session-1")`,且该 session 已存在 lineage subject
35+
- **THEN** 主进程 handler 校验入参后调用 `lineage-service.getBySession(projectPath, "session-1")`
36+
- **AND** 返回 `SessionLineageProjection`
37+
- **AND** 返回值的 `session.proposals` SHALL 包含该 session 已记录的 proposal `changeId` 列表
38+
39+
#### Scenario: getBySession 对未知 session 返回 null
40+
41+
- **WHEN** renderer 调用 `lineageApi.getBySession(projectId, "missing-session")`
42+
- **AND** lineage index 中不存在该 session
43+
- **THEN** 返回 `null`,不抛出异常
44+
45+
### Requirement: lineage IPC 入参经 schema 校验且 projectId 解析为 projectPath
46+
47+
系统 SHALL 在 `src/shared/schemas/ipc/lineage.ts` 定义 `ensureTaskSubjectInputSchema``linkTaskSessionInputSchema``getByTaskInputSchema``getBySessionInputSchema`。每个 schema 的 `projectId` SHALL 为非空字符串;`taskRef` / `ref` SHALL 校验为形如 `<source>:<id>` 的字符串;`sessionId` SHALL 为非空字符串;`snapshot` SHALL 校验 `ref` / `snapshot` / `capturedAt` 三字段齐备。handler SHALL 通过 lineage 服务现有的 project 路径解析方式把 `projectId` 解析为 `projectPath` 后再调用服务函数。
48+
49+
#### Scenario: 非法入参被 schema 拒绝
50+
51+
- **WHEN** renderer 以缺失 `projectId`、缺失 `snapshot.ref` 或空 `sessionId` 的入参调用任一 lineage channel
52+
- **THEN** handler 通过 `validate` 抛出校验错误,返回标准 IPC 错误响应
53+
- **AND** 不调用任何 lineage 服务函数
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## 1. Renderer 回填修复
2+
3+
- [x] 1.1 修改 `src/renderer/src/stores/session.ts`:为 `backfillSessionProposals(sessionId)` 增加 proposal id 与 lineage changeId 的等价匹配 helper,匹配 `proposal.id === changeId``proposal.id` 去掉 `YYYY-MM-DD-` 前缀后等于 `changeId`;保持 `ProposalMeta.id` 原值不改写。
4+
- [x] 1.2 修改 `src/renderer/src/stores/session.ts`:回填 `matched` proposal 后,只对 `proposal.status !== "archived"` 的 proposal 调用 `ensureProposalWatched(proposal, sessionId)`;archived proposal 只写入 `sessionProposals` 用于展示。
5+
- [x] 1.3 检查 `handleProposalStatusChanged(payload)` 的未知 proposal 防御逻辑:保持刷新 `useProposalStore().loadProposals()``upsertSessionProposal`,但避免为 `status === "archived"` 的 proposal 重新调用 `ensureProposalWatched`
6+
7+
## 2. 测试
8+
9+
- [x] 2.1 修改 `test/renderer/src/stores/session.spec.ts``@renderer/api/lineage` mock,补充 `getBySession` mock,并在 `beforeEach` 中提供默认返回值。
10+
- [x] 2.2 在 `test/renderer/src/stores/session.spec.ts` 新增场景:选择 session 后,`lineageApi.getBySession(projectId, sessionId)` 返回 `changeId: "fix-login"``useProposalStore.proposals``{ id: "fix-login", status: "draft" }` 时,`sessionProposals[sessionId]` 包含该 proposal,且 `proposalApi.watch` 被调用一次。
11+
- [x] 2.3 在 `test/renderer/src/stores/session.spec.ts` 新增场景:`lineageApi.getBySession` 返回原始 `changeId: "fix-login"``useProposalStore.proposals``{ id: "2026-06-25-fix-login", status: "archived" }` 时,`sessionProposals[sessionId]` 包含该 archived proposal,且 `proposalApi.watch` 不被调用。
12+
- [x] 2.4 在 `test/renderer/src/stores/session.spec.ts` 新增场景:`lineageApi.getBySession` 返回 `null`、错误响应或抛错时,`selectSession` 不抛错,`sessionProposals[sessionId]` 保持空数组,且不调用 `proposalApi.watch`
13+
14+
## 3. 规格与文档同步
15+
16+
- [x] 3.1 确认 `openspec/changes/fix-session-proposal-backfill/specs/chat-event-rail-proposal-status/spec.md` 中的回填、archive id 匹配、archived 不 watch 场景与最终实现一致。
17+
- [x] 3.2 确认 `openspec/changes/fix-session-proposal-backfill/specs/lineage-ipc/spec.md``lineage:getBySession` 的 channel 描述与 `src/shared/types/channels.ts``src/shared/schemas/ipc/lineage.ts``src/main/ipc/lineage.ts``src/renderer/src/api/lineage.ts` 的现有实现一致。
18+
- [x] 3.3 无需更新 `guidelines/RendererProcess.md`:其中 “Chat Session Proposals” 已描述从 proposal store 与 lineage 回填历史 proposals;本 change 只是补充 OpenSpec 与测试中的精确场景。
19+
20+
## 4. 验证
21+
22+
- [x] 4.1 运行 `pnpm vitest run test/renderer/src/stores/session.spec.ts`
23+
- [x] 4.2 运行 `pnpm vitest run test/renderer/src/components/chat/event/ChatProposalPanel.test.ts test/renderer/src/components/chat-session-event-rail.spec.ts`,确认 proposal rail 展示行为未回退。
24+
- [x] 4.3 运行 `pnpm typecheck:web`,确认 renderer 类型仍通过。

0 commit comments

Comments
 (0)