Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# CI — PR/push 的最低验证门槛(docs/release.md §4-1/2)。
# 只跑纯 Node 部分(vitest + tsc + vite build);Tauri/NSIS 构建依赖本机 GNU 工具链,
# 仍走手工 release gate(docs/release.md §0/§3)。
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ ResearchThread 是一个**本地优先**的桌面工具(Tauri 2,macOS + Wind
- 阶段:**M3 完成 + 种子发布加固**(M1/M2/M3 全部落地,实机验证)——发布前审阅确认的九项门槛已全部代码落地:
- **事务式写入**(`storage/atomicWrite.ts`):写 `.rt-tmp` → 原文件让位 `.rt-bak` → 新内容就位 → 清备份;任何一步失败或进程中断后,**原文件或备份必有一个可恢复**;启动时 `recoverInterruptedWrites()` 扫描恢复并记账。测试注入 rename 失败/持续故障/崩溃残留全覆盖
- **单实例**(`tauri-plugin-single-instance`):第二实例启动即聚焦已有主窗口后退出,杜绝双 watcher + 同名 tmp/bak 互踩
- **外部编辑并发策略**:storage 保存前比对磁盘内容与应用最后已知内容(`seen` 缓存,读/写后更新),不一致抛 `ExternalConflictError`,UI 弹「覆盖保存(acknowledge 后重试)/ 重新加载 / 取消」。`updateTaskMeta` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 四路受保护;appendEntry 纯追加不设卡
- **外部编辑并发策略**:storage 保存前比对磁盘内容与应用最后已知内容(`seen` 缓存,读/写后更新),不一致抛 `ExternalConflictError`,UI 弹「覆盖保存(acknowledge 后重试)/ 重新加载 / 取消」。`updateTaskMeta` / `appendEntry` / `updateEntry` / `updateProjectDescription` / `saveWeekly` **五路全部受保护**(appendEntry 同样检查冲突后整文件重写,比纯追加更安全)
- **软删除**:`deleteTask` / `deleteInboxItem` 移入数据目录 `.trash/`(时间戳前缀),不物理删除;恢复 = 移回原位或 git 找回(事务写保护不了 delete,这是当天未快照文件的唯一防线)
- **renderer 安全边界**:生产线 CSP 已开启(`app.security.csp`,`default-src 'self'` 起步);`shell` capability(cmd/powershell/node/npm/claude/codex/git,args:true)是高权限边界,任何新 HTML 渲染入口必须 escape-first(`src/lib/markdown.ts` 原则)——详见 docs/release.md §5-0
- **CI 门槛**:`.github/workflows/ci.yml`(npm ci → test → build)是 PR 的 merge gate;Tauri/NSIS 仍手工构建(GNU 工具链,见下)
- **每日快照健康**(`storage/snapshot.ts` `ensureSnapshotHealth()`):git 可用性检测 → 仓库级身份兜底(`--local`,不动全局配置)→ 补有效提交(修旧版 init 静默失败留下的空仓)→ 每日首启自动提交;任何失败 `ok:false`,主界面左下持久警告横幅 + 设置→数据「重试」
- **草稿与覆盖保护**:`editorState` 增 weekly 脏标记,脏编辑器存在时 `onCloseRequested` / `beforeunload` 拦截退出并确认;周文档重新生成与外部冲突覆盖均需二次点击确认(3 秒 armed)
- **周报正确性**:任务定位主键改完整相对路径(修跨项目同名任务归错),活跃工作线 ☆ 置顶稳定排序
Expand Down Expand Up @@ -67,6 +70,7 @@ ResearchThread 是一个**本地优先**的桌面工具(Tauri 2,macOS + Wind
├── .gitignore # 至少忽略 .mcp.json
├── .activity/log.jsonl
├── inbox/ # 快速捕获收件箱(未来的自然语言命令队列)
├── .trash/ # 软删除暂存(删除不物理清除;用户手动恢复或清理)
├── spaces/
│ ├── research/ # 空间(≈ 手写页的分类)
│ │ └── projects/
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ResearchThread

本地优先的科研/工作流程管理桌面应用(Tauri 2,macOS + Windows)。把手写「思路整理」工作流数字化:**空间 → 项目 → 任务**三级层级 + 双层上下文(项目整体上下文 / 任务上下文)+ 周文档。
本地优先的科研/工作流程管理桌面应用(Tauri 2)。当前种子构建:**Windows x64**;macOS 为计划目标、尚未构建验证。把手写「思路整理」工作流数字化:**空间 → 项目 → 任务**三级层级 + 双层上下文(项目整体上下文 / 任务上下文)+ 周文档。

第二步(已规划、未开始):**人只说自然语言,agent 维护整个项目地图**——口述新任务/新项目/任务转折即可,agent 经 MCP 工具接口执行并定时产出日报/周报。

Expand All @@ -21,12 +21,13 @@

## 技术栈

- React 19 + Vite + Tailwind v4(SPA;2026-09-10 采纳外部按 UI.md 交付的 UI,剥离其 SSR/登录/数据库)
- Tauri 2(待接入,起步零自定义 Rust)
- 所有数据访问统一走 `src/dataAccess.ts` 接口([UI.md](UI.md) 第 2 节契约);当前为 mock 实现,M1 换接 storage 层
- 数据层 `src/storage/`(待建):纯核心(core,无 Tauri API)+ IO 适配器(fsAdapter,plugin-fs)拆分——将来同一核心配 `node:fs` 即成 MCP server;`vitest` 单测守护写回契约(宽松读/规范写/保留未知字段)
- 终端面板(M2):`tauri-plugin-shell` + `@xterm/xterm`,经 AgentRunner 接口抽象(第二步对话面板与终端共用后端)
- 数据:Markdown 文件树 + YAML frontmatter,数据目录即 agent 工作区(`AGENTS.md` / `.mcp.json` / `settings.yaml`),可用 Obsidian / git 直接操作
- React 19 + Vite + Tailwind v4(SPA;按 [UI.md](UI.md) 契约集成外部交付的 UI,剥离其 SSR/登录/数据库)
- Tauri 2 桌面壳(plugin-fs / plugin-shell / single-instance / global-shortcut;生产线启用 CSP,见 [docs/release.md](docs/release.md) §5)
- 数据访问统一走 `src/dataAccess.ts` 接口([UI.md](UI.md) 第 2 节契约):浏览器 dev → MockDataAccess;Tauri 桌面 → StorageDataAccess(Markdown 文件树落盘)
- 数据层 `src/storage/`:纯核心 `core.ts`(无 Tauri API)+ IO 适配器 `fsAdapter.ts`(plugin-fs)+ 事务式写入 `atomicWrite.ts` + 每日快照健康 `snapshot.ts`;同一核心配 `node:fs` 即成 2b 阶段 MCP server(`tests/storageNode.test.ts` 的适配器即雏形)
- Vitest 守护写回契约与数据安全(宽松读/规范写/保留未知字段/外部冲突防护/软删除/中断恢复);CI 见 `.github/workflows/ci.yml`
- 终端面板:`tauri-plugin-shell` + `@xterm/xterm`,经 AgentRunner 接口抽象(第二步对话面板与终端共用后端)
- 数据:Markdown 文件树 + YAML frontmatter,数据目录即 agent 工作区(`AGENTS.md` / `.mcp.json` / `settings.yaml`),可用 Obsidian / git 直接操作;删除进 `.trash/` 可恢复

UI 布局:左侧栏(空间/项目树)|中间展示区|底部可开关面板(第一阶段内置终端,第二步 agent 对话)|右侧可开关侧边栏(详情/上下文编辑)。

Expand Down
17 changes: 12 additions & 5 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,20 @@ npm run make-release # ④ 归拢 release/(含门槛自检)

## 5. 数据安全红线(任何版本不得回退,回退 = 事故)

0. **renderer 安全边界**:生产线 CSP 必须开启(`tauri.conf.json` `app.security.csp`,当前 `default-src 'self'` + 按需放开),**禁止改回 `null`**;`shell` capability(cmd/powershell/node/npm/claude/codex/git,`args: true`)是**高权限边界**——任何新的 HTML 渲染入口(markdown 预览、agent 输出、CLI 输出渲染)必须先 HTML 转义再生成白名单标签(参照 `src/lib/markdown.ts` 的 escape-first 原则),改动必须过安全审查。理由:任何 renderer 注入 + shell 白名单 = 本机代码执行。**2a/2b 接入 agent 时必须重新收紧这份 allowlist**( Roadmap Issue #2)。
1. **写入只走事务**:所有文件写入经 `src/storage/atomicWrite.ts`(tmp → bak 让位 → 就位),保证任何失败/中断后原文件或备份必有一个可恢复;不许绕过它直接调 fs 写。
2. **外部冲突守卫**:`updateTaskMeta` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 保存前的外部修改检查(`ExternalConflictError` → 用户选择)不许移除或改成静默合并。
2. **外部冲突守卫**:`updateTaskMeta` / `appendEntry` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 保存前的外部修改检查(`ExternalConflictError` → 用户选择)不许移除或改成静默合并。
3. **单实例**:`tauri-plugin-single-instance` 不许移除。
4. **快照失败必须可见**:git 缺失/提交失败 → 界面持久警告,不许回到静默降级。
5. **脏草稿退出保护**:`onCloseRequested` / `beforeunload` 拦截不许移除。
6. **数据格式向后兼容**:文件树结构、frontmatter 契约、`status` 五态枚举是存量用户资产——宽松读 / 保留未知字段的写回契约(PLAN.md 第三节)不许破坏;确需破坏性变更:先用户确认、写迁移与回滚说明、升 minor。
7. **卸载不删数据目录**:NSIS 卸载只删程序本体与应用界面设置;不得增加「删除数据」的卸载选项。
8. **UI 不触文件**:所有数据访问仍必须经 `src/dataAccess.ts` 边界。
6. **删除 = 软删除**:`deleteTask` / `deleteInboxItem` 只移入 `.trash/`(时间戳前缀,不覆盖、不清理),不做物理删除——事务写保护不了 delete,这是当天新文件尚未进 git 快照时唯一的丢失防线;恢复 = 从 `.trash/` 移回或 git 找回。
7. **数据格式向后兼容**:文件树结构、frontmatter 契约、`status` 五态枚举是存量用户资产——宽松读 / 保留未知字段的写回契约(PLAN.md 第三节)不许破坏;确需破坏性变更:先用户确认、写迁移与回滚说明、升 minor。
8. **卸载不删数据目录**:NSIS 卸载只删程序本体与应用界面设置;不得增加「删除数据」的卸载选项。
9. **UI 不触文件**:所有数据访问仍必须经 `src/dataAccess.ts` 边界。

## 5b. CI 门槛

`.github/workflows/ci.yml` 在每个 PR / push 上强制 `npm ci → npm test → npm run build`——**merge gate 是 CI 绿,不是"本地跑过"的口头证据**。Tauri/NSIS 构建依赖本机 GNU 工具链,仍走 §4 手工 release gate;将来环境标准化后可加入 CI。

## 6. 隐私红线(零遥测,任何版本不得突破)

Expand All @@ -86,4 +92,5 @@ npm run make-release # ④ 归拢 release/(含门槛自检)

| 版本 | 日期 | 构建提交 | 安装包 SHA-256 | 备注 |
|---|---|---|---|---|
| 0.1.0-seed | 2026-09-11 | `87082ad` | `b3a893fbb46711d65383ce29f17db2762c56c405b3d40649671e89649031aa07` | 种子候选包;**尚未做 §4-6 干净环境验证,未发布** |
| 0.1.0-seed | 2026-09-11 | `701c538` | `b83a6c94ba7cbc757b7384699bcb62f5275ea69c8c69dfda2810bad9a7f60a48` | 种子候选包(含 CSP/软删除);**尚未做 §4-6 干净环境验证,未发布**。注:历史因作者邮箱重写过一次(旧 d082ded → 701c538,树内容相同) |
| ~~0.1.0-seed~~ | 2026-09-11 | ~~`87082ad`~~ | `b3a893fbb46711d65383ce29f17db2762c56c405b3d40649671e89649031aa07` | 已废弃(CSP 关闭、硬删除),勿分发 |
1 change: 1 addition & 0 deletions docs/seed-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- **卸载应用不会删除数据目录**。卸载只移除程序本体与应用界面设置(主题、布局偏好等);
重新安装后数据原样回来。
- 建议偶尔把整个数据目录复制到别处(或网盘 / 移动硬盘)作为额外备份。
- **删除是可恢复的**:在应用里删除任务 / 收件箱条目,文件会移到数据目录的 `.trash\` 文件夹(带删除时间戳),不会物理消失——误删后把它移回原目录即可;也可以从 git 历史找回。

## 3. 版本保护(快照)

Expand Down
23 changes: 1 addition & 22 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,7 @@
content="科研人员的思路整理:空间、项目、任务与按日期追加的上下文日志。"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
(function () {
try {
var r = localStorage.getItem("rt-settings-v1");
var s = r ? JSON.parse(r).state || {} : {};
var t = s.theme || "light";
if (t === "system")
t = matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
var e = document.documentElement;
e.dataset.theme = t;
e.dataset.canvas = s.canvas || "white";
e.dataset.density = s.density || "compact";
e.dataset.radius = s.radius || "md";
e.dataset.sidebar = s.sidebarContrast || "soft";
e.dataset.font = s.font || "sans";
e.dataset.width = s.editorWidth || "medium";
if (s.fontSize) e.style.setProperty("--text-ui", s.fontSize + "px");
} catch (e) {}
})();
</script>
<script src="/theme-init.js"></script>
</head>
<body>
<div id="root"></div>
Expand Down
23 changes: 23 additions & 0 deletions public/theme-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// public/theme-init.js — 首帧主题恢复(防闪烁)。
// 外置为文件而非内联脚本:生产 CSP 为 script-src 'self',内联会被拦截。
// 必须同步执行且先于任何渲染。
(function () {
try {
var r = localStorage.getItem("rt-settings-v1");
var s = r ? JSON.parse(r).state || {} : {};
var t = s.theme || "light";
if (t === "system")
t = matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
var e = document.documentElement;
e.dataset.theme = t;
e.dataset.canvas = s.canvas || "white";
e.dataset.density = s.density || "compact";
e.dataset.radius = s.radius || "md";
e.dataset.sidebar = s.sidebarContrast || "soft";
e.dataset.font = s.font || "sans";
e.dataset.width = s.editorWidth || "medium";
if (s.fontSize) e.style.setProperty("--text-ui", s.fontSize + "px");
} catch (e) {}
})();
3 changes: 2 additions & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost",
"devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420"
}
},
"bundle": {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export async function startWatcher(handlers: WatchHandlers): Promise<() => void>
!rel.startsWith(".git/") && // git 内部簿记对 agent 无意义
!rel.endsWith(".rt-tmp") && // 原子写残片
!rel.endsWith(".rt-bak") && // 原子写备份
!rel.startsWith(".activity/"),
!rel.startsWith(".activity/") &&
!rel.startsWith(".trash/"), // 软删除暂存(恢复由人操作 spaces/ 时自然可见)
);
if (external.length > 0) {
void store.logExternalModify(external);
Expand Down
1 change: 1 addition & 0 deletions src/storage/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export const paths = {
`spaces/${spaceId}/projects/${projectId}/tasks/${taskId}`,
inboxDir: () => "inbox",
inboxItem: (id: string) => `inbox/${id}`,
trashDir: () => ".trash",
weeklyDir: () => "weekly",
weeklyFile: (id: string) => `weekly/${id}`,
dailyDir: () => "daily",
Expand Down
29 changes: 25 additions & 4 deletions src/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,29 @@ export class StorageDataAccess implements AppDataAccess {

async deleteTask(spaceId: string, projectId: string, taskId: string): Promise<void> {
const rel = core.paths.taskFile(spaceId, projectId, taskId);
await this.remove(rel);
await this.logActivity("app", "task", rel, "delete_task");
const trashedTo = await this.moveToTrash(rel);
await this.logActivity("app", "task", rel, "delete_task", { trashedTo });
}

/**
* 软删除:移入数据目录 .trash/(时间戳前缀防覆盖),绝不物理删除。
* 事务写保护不了 delete——这是当天新文件尚未进 git 快照时唯一的丢失防线;
* 恢复 = 把文件从 .trash/ 移回原位(或从 git 历史找回)。
*/
private async moveToTrash(rel: string): Promise<string> {
const src = this.abs(rel);
if (!(await this.adapter.exists(src))) throw new Error(`文件不存在:${rel}`);
await this.adapter.mkdir(this.abs(core.paths.trashDir()));
const trashEntries = await this.adapter.readDir(this.abs(core.paths.trashDir()));
const now = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}-${p(now.getMinutes())}-${p(now.getSeconds())}`;
const base = `${stamp}_${rel.split("/").pop()}`;
const trashedRel = `${core.paths.trashDir()}/${core.uniqueId(base, trashEntries.map((e) => e.name))}`;
await this.adapter.rename(src, this.abs(trashedRel));
markSelfWrite(src);
markSelfWrite(this.abs(trashedRel));
return trashedRel;
}

// —— 收件箱(M2③:快速捕获;文件 = inbox/YYYY-MM-DD-HHMMSS.md,正文即内容) ——
Expand Down Expand Up @@ -493,8 +514,8 @@ export class StorageDataAccess implements AppDataAccess {
}

async deleteInboxItem(id: string): Promise<void> {
await this.remove(core.paths.inboxItem(id));
await this.logActivity("app", "inbox", core.paths.inboxItem(id), "inbox_delete");
const trashedTo = await this.moveToTrash(core.paths.inboxItem(id));
await this.logActivity("app", "inbox", core.paths.inboxItem(id), "inbox_delete", { trashedTo });
}
}

Expand Down
10 changes: 7 additions & 3 deletions src/storage/weekly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ interface TaskRef {
}

function diffDays(later: string, earlier: string): number {
const a = Date.UTC(...(later.split("-").map(Number) as [number, number, number]));
const b = Date.UTC(...(earlier.split("-").map(Number) as [number, number, number]));
// Date.UTC 的 month 是 0–11;Markdown 日期是 1–12,必须 -1(跨月边界差 1–3 天)
const [ly, lm, ld] = later.split("-").map(Number);
const [ey, em, ed] = earlier.split("-").map(Number);
const a = Date.UTC(ly, (lm ?? 1) - 1, ld ?? 1);
const b = Date.UTC(ey, (em ?? 1) - 1, ed ?? 1);
return Math.round((a - b) / 86400000);
}

Expand Down Expand Up @@ -81,7 +84,8 @@ export function buildWeeklySkeleton(input: WeeklySkeletonInput): string {
const mmdd = ev.date.slice(5).replace("-", "/");
switch (ev.event) {
case "create_task":
if (name) lines.push(`- ${mmdd} 新建任务「${name}」· ${ref ? loc(ref) : ""}`.trimEnd());
// ref 解析不到(跨周新建后已删等)时不留尾部「·」
if (name) lines.push(ref ? `- ${mmdd} 新建任务「${name}」· ${loc(ref)}` : `- ${mmdd} 新建任务「${name}」`);
break;
case "set_status": {
const d = (ev.detail ?? {}) as { from?: string; to?: string };
Expand Down
Loading
Loading