@aiti-ai/aiti-git-trace 0.0.0-stage → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/api.ts +424 -0
- package/check-client.mjs +273 -0
- package/client/src/common.ts +179 -0
- package/client/src/entry.tsx +81 -0
- package/client/src/host-shim.ts +118 -0
- package/client/src/jsx-runtime.ts +2 -0
- package/client/src/panels.tsx +1377 -0
- package/client/src/trace-app.tsx +275 -0
- package/client/src/trace-files-row.pure.ts +88 -0
- package/client/src/trace-files-row.tsx +110 -0
- package/client/src/version-mention.ts +90 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/dist/aiti-git-trace-client.mjs +4 -0
- package/dist/entry-C_le59dp.js +1594 -0
- package/dist/panels-BpC4XLni.js +1522 -0
- package/ext-icons-deploy.mjs +24 -0
- package/icon.svg +1 -0
- package/icons/git-branch.svg +1 -0
- package/icons/git-commit-horizontal.svg +1 -0
- package/index.ts +705 -0
- package/package.json +33 -4
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/scripts/aiti-trace.mjs +117 -0
- package/skills/index.json +1 -0
- package/skills/rollback-session/SKILL.md +34 -0
- package/skills/trace-ops/SKILL.md +31 -0
- package/skills/watch-path/SKILL.md +34 -0
- package/test/fixtures/ops-sample.jsonl +3 -0
- package/tools/deploy.mjs +103 -0
- package/tools/trace-compact.mjs +58 -0
- package/trace-core.test.mjs +1120 -0
- package/trace-core.ts +1346 -0
- package/trace-files-row.test.mjs +66 -0
- package/tsconfig.check.json +21 -0
- package/version-mention.test.mjs +76 -0
- package/vite.config.ts +43 -0
- package/README.md +0 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# aiti-git-trace 扩展(部署目录直改指南)
|
|
2
|
+
|
|
3
|
+
## 结构
|
|
4
|
+
- 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
|
|
5
|
+
- `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
|
|
6
|
+
- npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
|
|
7
|
+
|
|
8
|
+
## 小改现场流程(改文案/样式/小逻辑)
|
|
9
|
+
1. 改 `src/` 下对应源码
|
|
10
|
+
2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
|
|
11
|
+
3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
|
|
12
|
+
4. 浏览器刷新页面验证
|
|
13
|
+
|
|
14
|
+
## 大改回源码仓
|
|
15
|
+
- 源码仓:`extensions/aiti-git-trace/`(git 管理,deploy 同步回本目录)
|
|
16
|
+
- 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-git-trace`)
|
|
17
|
+
|
|
18
|
+
## 数据目录(数据分层:config / cache)
|
|
19
|
+
- `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
|
|
20
|
+
- `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
|
|
21
|
+
- `ctx.dataDir` 已移除(访问即抛)——新代码禁用
|
|
22
|
+
- 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
|
|
23
|
+
|
|
24
|
+
## 红线
|
|
25
|
+
- 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
|
|
26
|
+
- 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
|
|
27
|
+
- 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
|
|
28
|
+
- 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiti-ai contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/api.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* aiti-git-trace · API 处理层(纯 ESM,node:test 直接可测)
|
|
5
|
+
*
|
|
6
|
+
* handleApi(req, {path, searchParams}, {configDir, dir, allowRoot}) → Response
|
|
7
|
+
* 路由表(方案 §5.2/§5.3/§6.3/§7.2;总览视图 docs/aiti-git-trace-overview-view-plan.md §3):
|
|
8
|
+
* GET ops?session=&fixture= 台账读取(fixture 驱动开发)
|
|
9
|
+
* GET fork-preview?session=&entryId= fork 点后 op 摘要(空 → 前端直通);响应附带 files/totals 聚合(R4)
|
|
10
|
+
* POST fork-preview {session,entryIds} 按被裁轮集合圈定;响应附带 files/totals 聚合(R4)
|
|
11
|
+
* GET files?session=&entryIds=a,b 变更总览聚合(只读;storeMissing/rejected 部分返回)
|
|
12
|
+
* GET file-diff?root=&session=&base=&target=&path=&maxLines= 单文件区间 diff(只读,截断保护)
|
|
13
|
+
* POST rollback 全量回撤(safety → read-tree -u --reset → 记账)
|
|
14
|
+
* POST rollback-partial 部分回滚(过滤逆 patch → apply -R → -3 兜底 → 记账)
|
|
15
|
+
* POST workspace/resolve {cwd} workspace key + 配置 + nogit 探测
|
|
16
|
+
* GET/POST/DELETE workspace?key= 关注路径配置 CRUD(原子写)
|
|
17
|
+
* POST watch/resolve {path} 单路径归属(walk-up + 嵌套仓扫描)
|
|
18
|
+
* POST new-session-cwd {targetDir,cwd} @到新会话 CWD 判定(不随便变更:目标在当前 CWD/关注路径内保持,否则切目标)
|
|
19
|
+
* GET excludes 内置排除清单内容
|
|
20
|
+
* GET events SSE:op 事件实时推(services.trace.onOp 同一 bus;方案 §3.2)
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
repoRootOf, storeOk, sanitizeSession, workspaceKey,
|
|
24
|
+
readOps, readOpsWithMeta, appendOps, forkPreviewOps, sessionRepoRoots,
|
|
25
|
+
rollbackFull, rollbackPartial, forkPreviewOpsByUserEntries, rollbackOpsReversed,
|
|
26
|
+
loadWorkspace, saveWorkspace, deleteWorkspace, resolveWatchPath,
|
|
27
|
+
ensureExcludesFile, looseDecision, cleanupSessionOps, cleanupWorkspace, lineage,
|
|
28
|
+
sessionFileSummary, fileDiffText,
|
|
29
|
+
} from "./trace-core.ts";
|
|
30
|
+
import { resolveNewSessionCwd } from "@aiti-ai/ext-shared/new-session-cwd";
|
|
31
|
+
|
|
32
|
+
const json = (body, status = 200, extraHeaders = {}) => new Response(JSON.stringify(body), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "content-type": "application/json", "cache-control": "no-store", ...extraHeaders },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function readBody(req) {
|
|
38
|
+
try { return await req.json(); } catch { return {}; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 被撤 op 的原台账行(按 op 号映射),用于汇总 files/paths 与 targetOps 明细 */
|
|
42
|
+
function ledgerByOp(configDir, sessionId) {
|
|
43
|
+
const m = new Map();
|
|
44
|
+
for (const o of readOps(configDir, sessionId)) if (typeof o?.op === "number" && !m.has(o.op)) m.set(o.op, o);
|
|
45
|
+
return m;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 汇总一组 op 的 stores[].files/paths 增量并集(与时间线/徽标同口径:只认 before→after 增量) */
|
|
49
|
+
function sumStoresOf(rows) {
|
|
50
|
+
const acc = new Map(); // root → {root, files, paths, partial, before, after}
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
for (const st of row?.stores || []) {
|
|
53
|
+
const cur = acc.get(st.root) || { root: st.root, files: { M: 0, A: 0, D: 0 }, paths: [], before: st.before || null, after: st.after || null, partial: !!st.partial };
|
|
54
|
+
if (st.before) {
|
|
55
|
+
cur.files.M += st.files?.M || 0; cur.files.A += st.files?.A || 0; cur.files.D += st.files?.D || 0;
|
|
56
|
+
for (const p of st.paths || []) if (!cur.paths.includes(p)) cur.paths.push(p);
|
|
57
|
+
}
|
|
58
|
+
if (st.before && !cur.before) cur.before = st.before;
|
|
59
|
+
if (st.after && !cur.after) cur.after = st.after;
|
|
60
|
+
acc.set(st.root, cur);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...acc.values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 回撤一等化记账行:LedgerRow 同构 + 回撤自身可逆(stores[].before=safety, after=记账 commit)。
|
|
67
|
+
* 不占引擎 op 号(kind:"rollback" 标识 + 时间序展示,避免与引擎运行中 nextOp 撞号)。 */
|
|
68
|
+
function appendReverseRollbackLedger(configDir, sessionId, repoRoot, targetOps, r) {
|
|
69
|
+
const byOp = ledgerByOp(configDir, sessionId);
|
|
70
|
+
const rows = targetOps.map((op) => byOp.get(op)).filter(Boolean);
|
|
71
|
+
const stores = sumStoresOf(rows);
|
|
72
|
+
// 回撤动作自身的净效果(before=safety, after=记账 commit)也记进 stores 的 before/after
|
|
73
|
+
const marked = stores.map((s) => ({ ...s, before: r.safety, after: r.rollback }));
|
|
74
|
+
if (marked.length === 0) marked.push({ root: repoRoot, files: { M: 0, A: 0, D: 0 }, paths: [], partial: false, before: r.safety, after: r.rollback });
|
|
75
|
+
const applied = r.applied || 0; const skipped = (r.skipped || []).map((s) => s.op);
|
|
76
|
+
const gapN = (r.skipped || []).length;
|
|
77
|
+
let summary = targetOps.length
|
|
78
|
+
? `回撤 #${targetOps.join(",")}(从此处编辑)· 撤 ${applied}`
|
|
79
|
+
: `撤销回撤 · 撤 ${applied}`;
|
|
80
|
+
if (gapN) summary += ` · 跳过 ${gapN}(无基线)`;
|
|
81
|
+
if (r.failed) summary += ` · 失败@${r.failed.op}`;
|
|
82
|
+
appendOps(configDir, sessionId, {
|
|
83
|
+
kind: "rollback",
|
|
84
|
+
scope: "reverse-ops",
|
|
85
|
+
tool: "rollback",
|
|
86
|
+
summary,
|
|
87
|
+
t0: Date.now(), t1: Date.now(),
|
|
88
|
+
cwd: repoRoot,
|
|
89
|
+
entryId: null, userEntryId: null, prompt: null,
|
|
90
|
+
stores: marked,
|
|
91
|
+
leaks: [],
|
|
92
|
+
rollback: { targetOps, applied, skipped, failed: r.failed || null, usedThreeway: !!r.usedThreeway },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function handleApi(req, ctx, env) {
|
|
97
|
+
const { configDir, dir, services } = env;
|
|
98
|
+
const allowRoot = env.allowRoot || (async () => true);
|
|
99
|
+
const p = ctx.path || [];
|
|
100
|
+
if (!configDir) return json({ ok: false, error: "扩展未初始化" }, 500);
|
|
101
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204 });
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
// ── 台账 ──
|
|
105
|
+
if (p[0] === "ops" && req.method === "GET") {
|
|
106
|
+
const q = ctx.searchParams || new URLSearchParams();
|
|
107
|
+
const sid = sanitizeSession(q.get("session") || "default");
|
|
108
|
+
if (q.get("fixture")) {
|
|
109
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
110
|
+
const { join } = await import("node:path");
|
|
111
|
+
const f = join(dir || "", "test", "fixtures", "ops-" + String(q.get("fixture")).replace(/[^a-z0-9-]/gi, "") + ".jsonl");
|
|
112
|
+
const ops = existsSync(f)
|
|
113
|
+
? readFileSync(f, "utf8").split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l))
|
|
114
|
+
: [];
|
|
115
|
+
return json({ ok: true, session: sid, fixture: true, ops });
|
|
116
|
+
}
|
|
117
|
+
let state = null;
|
|
118
|
+
try { state = JSON.parse(readFileSync(join(dirname(configDir), "cache", "state", sanitizeSession(sid) + ".json"), "utf8")); } catch { /* 无状态文件 */ }
|
|
119
|
+
// [P1-3 意图维 join] 进程内消费 services.plans(plan-first 提供方;缺席静默降级)——
|
|
120
|
+
// 时间线头部据此显示 📋 方案绑定(planRel),实现「本会话依据哪份方案」一眼可读。
|
|
121
|
+
let plan: { planRel: string | null; path: string } | null = null;
|
|
122
|
+
try {
|
|
123
|
+
const plans = services?.get?.("aiti-plan-first.plans");
|
|
124
|
+
const hit = plans?.planDocs?.().find((p2: any) => p2.sessionId === sid);
|
|
125
|
+
if (hit) plan = { planRel: hit.planRel ?? null, path: hit.path };
|
|
126
|
+
} catch { /* 服务缺席/状态异常:plan 保持 null,零影响 */ }
|
|
127
|
+
// ETag 再校验(§11.3 扩缩):台账未变(size+mtime 同)→ 304 零传输。语义只覆盖 ops
|
|
128
|
+
// 本身(state/plan 变化不参与 etag)——消费方仅限 badge/footer(只用 ops);面板不吃 304。
|
|
129
|
+
const { rows, etag } = readOpsWithMeta(configDir, sid);
|
|
130
|
+
const inm = req.headers?.get?.("if-none-match");
|
|
131
|
+
if (etag && inm && inm === etag) return new Response(null, { status: 304, headers: { etag } });
|
|
132
|
+
return json({ ok: true, session: sid, ops: rows, state, plan }, 200, etag ? { etag } : {});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (p[0] === "fork-preview" && req.method === "POST") {
|
|
136
|
+
// 方案 §3.2:按被裁轮集合精确圈定(含被编辑轮)——POST { session, entryIds[] }
|
|
137
|
+
const body = await readBody(req);
|
|
138
|
+
const sid = sanitizeSession(String(body?.session || "default"));
|
|
139
|
+
const entryIds = Array.isArray(body?.entryIds) ? body.entryIds.map(String).filter(Boolean) : [];
|
|
140
|
+
const ops = forkPreviewOpsByUserEntries(configDir, sid, entryIds);
|
|
141
|
+
// R4:回撤弹窗汇总行/文件数由本响应附带(不另发 /files);聚合失败不阻塞预览
|
|
142
|
+
let files, totals;
|
|
143
|
+
try {
|
|
144
|
+
const summary = await sessionFileSummary(configDir, sid, entryIds, { allowRoot });
|
|
145
|
+
files = summary.files; totals = summary.totals;
|
|
146
|
+
} catch { /* 聚合缺席降级 */ }
|
|
147
|
+
return json({ ok: true, session: sid, entryIds, ops, files, totals });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (p[0] === "fork-preview" && req.method === "GET") {
|
|
151
|
+
const q = ctx.searchParams || new URLSearchParams();
|
|
152
|
+
const sid = sanitizeSession(q.get("session") || "default");
|
|
153
|
+
const entryId = q.get("entryId") || "";
|
|
154
|
+
const ops = forkPreviewOps(configDir, sid, entryId);
|
|
155
|
+
// R4:fork 点后 ops 圈定(opSet)出同口径聚合;旧 core 降级路径同样有汇总行
|
|
156
|
+
let files, totals;
|
|
157
|
+
try {
|
|
158
|
+
const opSet = new Set(ops.map((o) => o.op).filter((n) => typeof n === "number"));
|
|
159
|
+
const summary = await sessionFileSummary(configDir, sid, [], { allowRoot, opSet });
|
|
160
|
+
files = summary.files; totals = summary.totals;
|
|
161
|
+
} catch { /* 聚合缺席降级 */ }
|
|
162
|
+
return json({ ok: true, session: sid, entryId, ops, files, totals });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 变更总览(overview 方案 §3,只读)──
|
|
166
|
+
if (p[0] === "files" && req.method === "GET") {
|
|
167
|
+
const q = ctx.searchParams || new URLSearchParams();
|
|
168
|
+
const sid = sanitizeSession(q.get("session") || "default");
|
|
169
|
+
const entryIds = (q.get("entryIds") || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
170
|
+
const r = await sessionFileSummary(configDir, sid, entryIds, { allowRoot });
|
|
171
|
+
return json({ ok: true, session: sid, entryIds, ...r });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (p[0] === "file-diff" && req.method === "GET") {
|
|
175
|
+
const q = ctx.searchParams || new URLSearchParams();
|
|
176
|
+
const root = String(q.get("root") || "");
|
|
177
|
+
if (!root || !storeOk(root)) return json({ ok: false, error: "该仓没有 trace store(.aiti-trace 不存在)" }, 400);
|
|
178
|
+
if (!(await allowRoot(root))) return json({ ok: false, error: "root 不在允许范围(allowed-roots 拒绝)" }, 403);
|
|
179
|
+
const sid = sanitizeSession(q.get("session") || "default");
|
|
180
|
+
const base = String(q.get("base") || ""), target = String(q.get("target") || "");
|
|
181
|
+
if (!base || !target) return json({ ok: false, error: "缺少 base/target" }, 400);
|
|
182
|
+
const path = String(q.get("path") || "");
|
|
183
|
+
const maxLines = parseInt(q.get("maxLines") || "", 10) || undefined;
|
|
184
|
+
const r = await fileDiffText(root, sid, base, target, path, maxLines); // 坏 ref → throw → 400
|
|
185
|
+
return json({ ok: true, session: sid, root, base, target, path, ...r });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 回撤(写路径单一收敛点) ──
|
|
189
|
+
if (p[0] === "rollback" && req.method === "POST") {
|
|
190
|
+
const body = await readBody(req);
|
|
191
|
+
const repoRoot = String(body?.storeRoot || "");
|
|
192
|
+
if (!repoRoot || !storeOk(repoRoot)) return json({ ok: false, error: "该仓没有 trace store(.aiti-trace 不存在)" }, 400);
|
|
193
|
+
if (!(await allowRoot(repoRoot))) return json({ ok: false, error: "storeRoot 不在允许范围(allowed-roots 拒绝)" }, 403);
|
|
194
|
+
const targetCommit = String(body?.targetCommit || "");
|
|
195
|
+
if (!targetCommit) return json({ ok: false, error: "缺少 targetCommit" }, 400);
|
|
196
|
+
const sessionId = sanitizeSession(body?.sessionId);
|
|
197
|
+
const r = await rollbackFull({ repoRoot, sessionId, targetCommit });
|
|
198
|
+
appendOps(configDir, sessionId, {
|
|
199
|
+
kind: "rollback", t: Date.now(), storeRoot: repoRoot, target: targetCommit,
|
|
200
|
+
safety: r.safety, commit: r.rollback, scope: "full",
|
|
201
|
+
});
|
|
202
|
+
return json({ ok: true, ...r });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (p[0] === "rollback-partial" && req.method === "POST") {
|
|
206
|
+
const body = await readBody(req);
|
|
207
|
+
const repoRoot = String(body?.storeRoot || "");
|
|
208
|
+
if (!repoRoot || !storeOk(repoRoot)) return json({ ok: false, error: "该仓没有 trace store(.aiti-trace 不存在)" }, 400);
|
|
209
|
+
if (!(await allowRoot(repoRoot))) return json({ ok: false, error: "storeRoot 不在允许范围(allowed-roots 拒绝)" }, 403);
|
|
210
|
+
const base = String(body?.base || ""), target = String(body?.target || "");
|
|
211
|
+
const files = Array.isArray(body?.files) ? body.files.map(String).filter(Boolean) : [];
|
|
212
|
+
const hunkIdx = Array.isArray(body?.hunkIdx) ? body.hunkIdx.map((n) => Number(n) | 0).filter((n) => n >= 0) : [];
|
|
213
|
+
if (!base || !target) return json({ ok: false, error: "缺少 base/target" }, 400);
|
|
214
|
+
// [P0-1] 空 hunkIdx = 该文件全部 hunk(合法);只有 files 缺失才 400
|
|
215
|
+
if (!files.length) return json({ ok: false, error: "缺少 files" }, 400);
|
|
216
|
+
const sessionId = sanitizeSession(body?.sessionId);
|
|
217
|
+
const r = await rollbackPartial({ repoRoot, sessionId, base, target, files, hunkIdx });
|
|
218
|
+
appendOps(configDir, sessionId, {
|
|
219
|
+
kind: "rollback", t: Date.now(), storeRoot: repoRoot, base, target,
|
|
220
|
+
files, hunkIdx, safety: r.safety, commit: r.rollback, usedThreeway: r.usedThreeway, scope: "partial",
|
|
221
|
+
});
|
|
222
|
+
return json({ ok: true, ...r });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (p[0] === "plan-record" && req.method === "POST") {
|
|
226
|
+
// [P1-1] plan 采认/丢弃记账(GAP-3/5 记账合一):op-less 台账行,kind=plan-accept/plan-discard。
|
|
227
|
+
// 写入方:aiti-plan-review applyOne(web 采认路径)。时间线/摘要按 kind 可查可过滤。
|
|
228
|
+
const body = await readBody(req);
|
|
229
|
+
const sessionId = sanitizeSession(body?.sessionId);
|
|
230
|
+
const outcome = body?.outcome === "accepted" ? "plan-accept" : "plan-discard";
|
|
231
|
+
appendOps(configDir, sessionId, {
|
|
232
|
+
kind: outcome, t: Date.now(),
|
|
233
|
+
sandbox: String(body?.sandbox || ""),
|
|
234
|
+
mission: String(body?.mission || ""),
|
|
235
|
+
files: Array.isArray(body?.files) ? body.files.map(String).filter(Boolean) : [],
|
|
236
|
+
summary: String(body?.summary || "").slice(0, 500),
|
|
237
|
+
});
|
|
238
|
+
return json({ ok: true });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (p[0] === "rollback-reversed" && req.method === "POST") {
|
|
242
|
+
// 方案 §3.2/§7:逐 op 逆序反向回撤 + 回撤自身一等化记账(LedgerRow 同构、可逆)
|
|
243
|
+
const body = await readBody(req);
|
|
244
|
+
const repoRoot = String(body?.storeRoot || "");
|
|
245
|
+
if (!repoRoot || !storeOk(repoRoot)) return json({ ok: false, error: "该仓没有 trace store(.aiti-trace 不存在)" }, 400);
|
|
246
|
+
if (!(await allowRoot(repoRoot))) return json({ ok: false, error: "storeRoot 不在允许范围(allowed-roots 拒绝)" }, 403);
|
|
247
|
+
const sessionId = sanitizeSession(body?.sessionId);
|
|
248
|
+
const refs = Array.isArray(body?.refs) ? body.refs
|
|
249
|
+
.map((rf) => ({ op: Number(rf?.op) | 0, before: rf?.before ? String(rf.before) : null, after: rf?.after ? String(rf.after) : null }))
|
|
250
|
+
.filter((rf) => rf.before || rf.after) : []; // op 可为任意整数(撤销回撤用 -1 表示「非 op 的逆操作」),必须有 before/after
|
|
251
|
+
if (!refs.length) return json({ ok: false, error: "缺少 refs" }, 400);
|
|
252
|
+
const targetOps = refs.filter((rf) => rf.op > 0).map((rf) => rf.op);
|
|
253
|
+
const r = await rollbackOpsReversed({ repoRoot, sessionId, refs });
|
|
254
|
+
// 一等化记账行:LedgerRow 同构(时间线「↩ 回撤记录」区块可读、可「撤销回撤」)
|
|
255
|
+
appendReverseRollbackLedger(configDir, sessionId, repoRoot, targetOps, r);
|
|
256
|
+
return json({ ok: true, ...r });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── 家谱(§3.3):parent 链聚合(祖先 + 当前 + 后代)──
|
|
260
|
+
if (p[0] === "lineage" && req.method === "GET") {
|
|
261
|
+
const q = ctx.searchParams || new URLSearchParams();
|
|
262
|
+
const sid = sanitizeSession(q.get("session") || "");
|
|
263
|
+
if (!sid) return json({ ok: false, error: "缺少 session" }, 400);
|
|
264
|
+
const lin: any = lineage(configDir, sid);
|
|
265
|
+
// [P1-3] 节点 📋 方案绑定标注(services.plans 反查;缺席静默降级为无标注)
|
|
266
|
+
try {
|
|
267
|
+
const plans = services?.get?.("aiti-plan-first.plans");
|
|
268
|
+
const bySid = new Map<string, string>((plans?.planDocs?.() ?? []).map((p2: any) => [p2.sessionId, p2.planRel || p2.path]));
|
|
269
|
+
for (const n of [lin.self, ...(lin.ancestors ?? []), ...(lin.descendants ?? [])]) {
|
|
270
|
+
if (n?.id) n.planRel = bySid.get(n.id) ?? null;
|
|
271
|
+
}
|
|
272
|
+
} catch { /* 服务缺席:节点无标注 */ }
|
|
273
|
+
return json({ ok: true, session: sid, ...lin });
|
|
274
|
+
}
|
|
275
|
+
// ── 清理(§15):会话台账 / 工作区深清理 ──
|
|
276
|
+
if (p[0] === "cleanup" && req.method === "POST") {
|
|
277
|
+
const body = await readBody(req);
|
|
278
|
+
if (body?.scope === "session") {
|
|
279
|
+
const sid = sanitizeSession(String(body.session || ""));
|
|
280
|
+
if (!sid) return json({ ok: false, error: "缺少 session" }, 400);
|
|
281
|
+
const beforeOp = body.beforeOp ? parseInt(body.beforeOp, 10) : undefined;
|
|
282
|
+
const r = cleanupSessionOps(configDir, sid, beforeOp);
|
|
283
|
+
return json({ ok: true, ...r });
|
|
284
|
+
}
|
|
285
|
+
if (body?.scope === "workspace") {
|
|
286
|
+
const root = String(body.root || "");
|
|
287
|
+
if (!root) return json({ ok: false, error: "缺少 root" }, 400);
|
|
288
|
+
if (!(await allowRoot(root))) return json({ ok: false, error: "root 不在允许范围(allowed-roots 拒绝)" }, 403);
|
|
289
|
+
const r = cleanupWorkspace(configDir, root);
|
|
290
|
+
return json({ ok: true, ...r });
|
|
291
|
+
}
|
|
292
|
+
return json({ ok: false, error: "scope 需为 session | workspace" }, 400);
|
|
293
|
+
}
|
|
294
|
+
// ── workspace 关注路径 ──
|
|
295
|
+
if (p[0] === "workspace" && p[1] === "resolve" && req.method === "POST") {
|
|
296
|
+
const body = await readBody(req);
|
|
297
|
+
const cwd = String(body?.cwd || "");
|
|
298
|
+
if (!cwd) return json({ ok: false, error: "缺少 cwd" }, 400);
|
|
299
|
+
const repoRoot = repoRootOf(cwd);
|
|
300
|
+
const key = workspaceKey(repoRoot || cwd); // key 锚定主仓根(与 pi 引擎 workspaceConfigFile 一致)
|
|
301
|
+
// §13.4:无 git 时给出 loose 决策(autoInit 开 + 不在 denylist + 预算内 → 会自动建简易追踪)
|
|
302
|
+
// 会话识别仓(§7.2 session 级):pi 扩展落盘的 state/<sid>.json;
|
|
303
|
+
// 台账补全(sessionRepoRoots):state 只是运行时快照,引擎重启会丢之前的副仓,
|
|
304
|
+
// 台账才是持久记录——合并后供 watch 弹窗展示与跨仓文件分辨(aiti-msg-smart)使用。
|
|
305
|
+
let sessionStores = null;
|
|
306
|
+
const reqSid = String(body?.session || "");
|
|
307
|
+
if (reqSid) {
|
|
308
|
+
try {
|
|
309
|
+
const st = JSON.parse(readFileSync(join(dirname(configDir), "cache", "state", sanitizeSession(reqSid) + ".json"), "utf8"));
|
|
310
|
+
if (Array.isArray(st.stores)) sessionStores = st.stores;
|
|
311
|
+
} catch { /* 无状态文件 */ }
|
|
312
|
+
const opsRoots = sessionRepoRoots(configDir, reqSid);
|
|
313
|
+
if (opsRoots.length) {
|
|
314
|
+
const have = new Set((sessionStores || []).map((s) => (s && s.root) || ""));
|
|
315
|
+
for (const r of opsRoots) {
|
|
316
|
+
if (have.has(r)) continue;
|
|
317
|
+
sessionStores = [...(sessionStores || []), { root: r, role: "secondary" }];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
let loose = null;
|
|
322
|
+
if (!repoRoot && cwd) {
|
|
323
|
+
let cfg2 = {};
|
|
324
|
+
try { cfg2 = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")); } catch { /* 缺省开 */ }
|
|
325
|
+
loose = looseDecision(cwd, { autoInit: cfg2.autoInit !== false, maxFiles: cfg2.autoInitMaxFiles });
|
|
326
|
+
}
|
|
327
|
+
return json({ ok: true, key, repoRoot, nogit: !repoRoot, loose, sessionStores, config: loadWorkspace(configDir, key) });
|
|
328
|
+
}
|
|
329
|
+
if (p[0] === "workspace" && req.method === "GET" && !p[1]) {
|
|
330
|
+
const q = ctx.searchParams || new URLSearchParams();
|
|
331
|
+
const key = String(q.get("key") || "");
|
|
332
|
+
if (!key) return json({ ok: false, error: "缺少 key" }, 400);
|
|
333
|
+
return json({ ok: true, key, config: loadWorkspace(configDir, key) });
|
|
334
|
+
}
|
|
335
|
+
if (p[0] === "workspace" && req.method === "POST" && !p[1]) {
|
|
336
|
+
const body = await readBody(req);
|
|
337
|
+
const key = String(body?.key || "");
|
|
338
|
+
if (!key) return json({ ok: false, error: "缺少 key" }, 400);
|
|
339
|
+
const config = saveWorkspace(configDir, key, body);
|
|
340
|
+
return json({ ok: true, key, config });
|
|
341
|
+
}
|
|
342
|
+
if (p[0] === "workspace" && req.method === "DELETE" && !p[1]) {
|
|
343
|
+
const q = ctx.searchParams || new URLSearchParams();
|
|
344
|
+
const key = String(q.get("key") || "");
|
|
345
|
+
if (!key) return json({ ok: false, error: "缺少 key" }, 400);
|
|
346
|
+
deleteWorkspace(configDir, key);
|
|
347
|
+
return json({ ok: true, key, deleted: true });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (p[0] === "watch" && p[1] === "resolve" && req.method === "POST") {
|
|
351
|
+
const body = await readBody(req);
|
|
352
|
+
const path = String(body?.path || "");
|
|
353
|
+
if (!path) return json({ ok: false, error: "缺少 path" }, 400);
|
|
354
|
+
const r = resolveWatchPath(path);
|
|
355
|
+
return json({ ok: true, ...r, isGit: !!r.repoRoot });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── @到新会话 CWD 判定 v2(docs/pi-web-at-new-session-cwd-plan.md + …-v2-plan.md):
|
|
359
|
+
// ① 目标在当前 CWD 内 → 保持;② 目标在当前 CWD 关注路径(watchPaths,repoRoot ?? path)内
|
|
360
|
+
// → 切到该关注路径;③ 目标在已有 workspace(client 传入 /api/sessions 派生候选)内 → 最长覆盖;
|
|
361
|
+
// ④ 否则用自己的目录(剥离 docs/tmp/design 等常规目录名)。消费方:aiti-editor / aiti-folder /
|
|
362
|
+
// aiti-doc-center 的「@到新会话」统一走本端点复核(watchPaths = 判定数据源,能力归口)。 ──
|
|
363
|
+
if (p[0] === "new-session-cwd" && req.method === "POST") {
|
|
364
|
+
const body = await readBody(req);
|
|
365
|
+
const targetDir = String(body?.targetDir || "");
|
|
366
|
+
const targetPath = String(body?.targetPath || "");
|
|
367
|
+
const cwd = String(body?.cwd || "");
|
|
368
|
+
const knownWorkspaces = Array.isArray(body?.knownWorkspaces)
|
|
369
|
+
? body.knownWorkspaces.filter((x: unknown) => typeof x === "string" && x)
|
|
370
|
+
: [];
|
|
371
|
+
if (!targetDir && !targetPath) return json({ ok: false, error: "缺少 targetDir" }, 400);
|
|
372
|
+
// 与 services.trace.watchPaths 同源(traceService 用 configDir 同参);git 仓根锚定 workspace key;
|
|
373
|
+
// 传完整对象(含 repoRoot)——v2 关注目录命中取 repoRoot ?? path(仓库根优先)。
|
|
374
|
+
const watchPaths = cwd
|
|
375
|
+
? loadWorkspace(configDir, workspaceKey(repoRootOf(cwd) || cwd)).paths
|
|
376
|
+
: [];
|
|
377
|
+
const r = resolveNewSessionCwd({ targetDir, targetPath, currentCwd: cwd, watchPaths, knownWorkspaces });
|
|
378
|
+
return json({ ok: true, cwd: r.cwd, switched: r.switched });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── 事件出口(docs/aiti-plan-first-plan.md §3.2):op 事件实时推浏览器(SSE,aiti-ai-schedule 同款;
|
|
382
|
+
// EventSource 断线自愈非轮询)。消费方:git-trace 自家 UI 实时化 / 任意页面可直听 ──
|
|
383
|
+
if (p[0] === "events" && req.method === "GET") {
|
|
384
|
+
const onTraceOp = (env as any).onTraceOp;
|
|
385
|
+
if (typeof onTraceOp !== "function") return json({ ok: false, error: "事件总线未就绪" }, 503);
|
|
386
|
+
const encoder = new TextEncoder();
|
|
387
|
+
let off: (() => void) | null = null;
|
|
388
|
+
let ping: ReturnType<typeof setInterval> | null = null;
|
|
389
|
+
// 退订必须双钩:cancel 只在流被消费方取消时触发,客户端静默断开(页面刷新/网络断)
|
|
390
|
+
// 可能不走 cancel——漏退订会把 op 监听器 + 15s ping 永久留在 traceBus 上
|
|
391
|
+
// (MaxListenersExceeded + 闭包钉死模块图,2026-09-15 dev 实例内存膨胀实证)。
|
|
392
|
+
const cleanup = () => {
|
|
393
|
+
try { off?.(); } catch {}
|
|
394
|
+
if (ping) clearInterval(ping);
|
|
395
|
+
off = null; ping = null;
|
|
396
|
+
};
|
|
397
|
+
const stream = new ReadableStream({
|
|
398
|
+
start(controller) {
|
|
399
|
+
const send = (chunk: string) => {
|
|
400
|
+
try { controller.enqueue(encoder.encode(chunk)); } catch { /* 客户端已断开 */ }
|
|
401
|
+
};
|
|
402
|
+
send(": connected\n\n");
|
|
403
|
+
off = onTraceOp((evt: unknown) => send(`event: op\ndata: ${JSON.stringify(evt)}\n\n`));
|
|
404
|
+
ping = setInterval(() => send(": ping\n\n"), 15000); // 传输保活(防代理空闲断连;非状态轮询)
|
|
405
|
+
req.signal?.addEventListener("abort", cleanup, { once: true }); // 第二退订钩(aiti-event-bus 同款)
|
|
406
|
+
},
|
|
407
|
+
cancel() { cleanup(); },
|
|
408
|
+
});
|
|
409
|
+
return new Response(stream, {
|
|
410
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" },
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (p[0] === "excludes" && req.method === "GET") {
|
|
415
|
+
const f = ensureExcludesFile(configDir);
|
|
416
|
+
const { readFileSync } = await import("node:fs");
|
|
417
|
+
return json({ ok: true, text: readFileSync(f, "utf8") });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return json({ ok: false, error: "not found" }, 404);
|
|
421
|
+
} catch (e) {
|
|
422
|
+
return json({ ok: false, error: String(e?.message || e) }, 400);
|
|
423
|
+
}
|
|
424
|
+
}
|