dsh-vscode-mode 0.25.0 → 0.26.0
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/README.md +11 -5
- package/lib/client.js +1613 -1317
- package/lib/client.js.map +2 -2
- package/lib/index.js +7028 -6516
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/downloadFile.ts +36 -6
- package/src/client/downloadSink.ts +162 -0
- package/src/client/reviewBar.ts +3 -0
- package/src/client/reviewBatch.ts +9 -1
- package/src/client/reviewKeepAll.ts +72 -0
- package/src/client/state/diffSummary.ts +34 -7
- package/src/client/ui/DownloadPicker.ts +2 -1
- package/src/client/ui/EditorView.ts +117 -29
- package/src/client/ui/ReviewAllConfirm.ts +1 -1
- package/src/index.ts +1 -1
- package/src/pending/persist.ts +94 -5
- package/src/pending/store.ts +76 -1
- package/src/pendingCompat.ts +80 -9
- package/src/pendingRpc.ts +7 -6
- package/src/reviewDb.ts +119 -8
- package/src/reviewRpc.ts +110 -3
- package/src/rpc.ts +14 -10
- package/src/shared/review.ts +21 -0
- package/src/shared/rpc.ts +3 -1
- package/src/store.ts +44 -0
|
@@ -24,10 +24,12 @@ import { dbg, isDebug, rpc } from '../rpc.js'
|
|
|
24
24
|
import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
|
|
25
25
|
import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
|
|
26
26
|
import { runReviewBatch } from '../reviewBatch.js'
|
|
27
|
+
import { runKeepAll } from '../reviewKeepAll.js'
|
|
28
|
+
import { toastDom } from '../externalOpen.js'
|
|
27
29
|
import { ReviewAllConfirm } from './ReviewAllConfirm.js'
|
|
28
30
|
import { reviewRegions } from '../../shared/reviewRegions.js'
|
|
29
31
|
import { PENDING_PREFIX } from '../../shared/pendingReview.js'
|
|
30
|
-
import {
|
|
32
|
+
import { coveredPathKeys, diffPathKey, diffSummary } from '../state/diffSummary.js'
|
|
31
33
|
import { emitFileChanged, emitRefresh } from '../events.js'
|
|
32
34
|
import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
|
|
33
35
|
import { bindEditActions, runEditAction } from '../monaco/editActions.js'
|
|
@@ -134,7 +136,7 @@ import { clampToolbarPosition, loadToolbarPosition, saveToolbarPosition } from '
|
|
|
134
136
|
import { editorModelPathOf } from '../dap/modelPath.js'
|
|
135
137
|
import { dapTrace } from '../dap/trace.js'
|
|
136
138
|
import { isSessionMissingError, sessionErrorText, sessionMissingDetail, sessionMissingTitle } from '../sessionError.js'
|
|
137
|
-
import { officialDownView, SESSION_DOWN_NOTICE } from '../reviewBar.js'
|
|
139
|
+
import { officialDownView, SESSION_DOWN_NOTICE, SIDECAR_KEEP_DONE } from '../reviewBar.js'
|
|
138
140
|
|
|
139
141
|
/** 官方差异读取失败前缀:仅用于清理同源陈旧错误,不吞并保存类错误。@author ddj 2026年10月27号 */
|
|
140
142
|
const REVIEW_LOAD_ERROR = '官方差异读取失败:'
|
|
@@ -565,14 +567,17 @@ export function EditorView(props) {
|
|
|
565
567
|
layoutRef.current = layout
|
|
566
568
|
if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t))
|
|
567
569
|
|
|
570
|
+
// 已有权威通道(官方汇总 / 待审累计基线)在管的路径:旧工具记录不得再画出来,也不得被批量纳入
|
|
571
|
+
const coveredPaths = React.useMemo(() => coveredPathKeys(review.page), [review.page])
|
|
572
|
+
|
|
568
573
|
const currentRecords = React.useMemo(() => {
|
|
569
574
|
const list = []
|
|
570
575
|
for (const rec of Object.values(records)) {
|
|
571
|
-
if (rec.archived === true ||
|
|
576
|
+
if (rec.archived === true || coveredPaths.has(diffPathKey(rec.path))) continue
|
|
572
577
|
if (sameFile(rec.path, active)) list.push(rec)
|
|
573
578
|
}
|
|
574
579
|
return list
|
|
575
|
-
}, [records, active, cwd,
|
|
580
|
+
}, [records, active, cwd, coveredPaths])
|
|
576
581
|
|
|
577
582
|
const reviewOpsRef = React.useRef(new Map())
|
|
578
583
|
// 已自动结案的记录 id:防止「改动已不在文件中」的记录在刷新期间被反复提交判定
|
|
@@ -3315,8 +3320,10 @@ export function EditorView(props) {
|
|
|
3315
3320
|
}
|
|
3316
3321
|
// #endregion
|
|
3317
3322
|
if (!res || !res.ok || !Array.isArray(res.results)) {
|
|
3318
|
-
|
|
3319
|
-
|
|
3323
|
+
const failure = res?.error ? String(res.error) : '批量处理失败'
|
|
3324
|
+
setError(failure)
|
|
3325
|
+
// 真实原因要一路带到调用处:调用方按计数兜底会把 Host 原文盖掉,用户只看得到泛化文案
|
|
3326
|
+
return { ok: 0, fail: items.length, stale: 0, error: failure }
|
|
3320
3327
|
}
|
|
3321
3328
|
let ok = 0
|
|
3322
3329
|
let fail = 0
|
|
@@ -3336,8 +3343,9 @@ export function EditorView(props) {
|
|
|
3336
3343
|
if (Object.keys(next).length) setRecords((prev) => Object.assign({}, prev, next))
|
|
3337
3344
|
return { ok, fail, stale }
|
|
3338
3345
|
}).catch((e) => {
|
|
3339
|
-
|
|
3340
|
-
|
|
3346
|
+
const failure = '批量处理异常:' + String(e)
|
|
3347
|
+
setError(failure)
|
|
3348
|
+
return { ok: 0, fail: items.length, stale: 0, error: failure }
|
|
3341
3349
|
})
|
|
3342
3350
|
}
|
|
3343
3351
|
|
|
@@ -3470,6 +3478,33 @@ export function EditorView(props) {
|
|
|
3470
3478
|
finally { batchBusyRef.current = false; if (lifetime?.live) setReviewBatchBusy(false) }
|
|
3471
3479
|
}
|
|
3472
3480
|
|
|
3481
|
+
/**
|
|
3482
|
+
* 批量失败提示:Host 原文优先,只有拿不到原因时才按计数兜底。
|
|
3483
|
+
* @author ddj 2026年11月3号
|
|
3484
|
+
* @param result actMany 的结果
|
|
3485
|
+
* @returns 提示文案;没有失败返回 null
|
|
3486
|
+
*/
|
|
3487
|
+
const batchError = (result) => {
|
|
3488
|
+
if (!result.error) return batchFailText(result)
|
|
3489
|
+
// 会话没在当前端激活:写类 RPC 一律被 requireSession 拒绝(冷读列表却正常),
|
|
3490
|
+
// 只把「会话不存在」原文丢给用户会像新故障,这里改说清「没执行、记录没动、怎么恢复」。
|
|
3491
|
+
if (isSessionMissingError(result.error)) return '这个会话没在当前端激活,Keep/Undo 没有执行(记录未改动);在会话里发一条消息激活后重试'
|
|
3492
|
+
return result.error
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
/**
|
|
3496
|
+
* 批量结果没有 Host 原文时的计数兜底文案。
|
|
3497
|
+
* @author ddj 2026年11月3号
|
|
3498
|
+
* @param result actMany 的结果
|
|
3499
|
+
* @returns 提示文案;没有失败返回 null
|
|
3500
|
+
*/
|
|
3501
|
+
const batchFailText = (result) => {
|
|
3502
|
+
if (!result.fail) return null
|
|
3503
|
+
return result.stale
|
|
3504
|
+
? `${result.fail} 处未处理(其中 ${result.stale} 处已不在文件中,只更新了记录),可刷新后重试`
|
|
3505
|
+
: `${result.fail} 处未处理,可刷新后重试`
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3473
3508
|
const acceptFile = () => {
|
|
3474
3509
|
if (missingNow && !officialDetail) return decideMissing(false)
|
|
3475
3510
|
if (officialDetail) return decideOfficial(false)
|
|
@@ -3479,8 +3514,11 @@ export function EditorView(props) {
|
|
|
3479
3514
|
batchBusyRef.current = false
|
|
3480
3515
|
reloadFile(true)
|
|
3481
3516
|
emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
|
|
3482
|
-
|
|
3483
|
-
|
|
3517
|
+
// 能走到这里的都是「历史改动」通道:Keep 只把记录标成已看过,不写盘,
|
|
3518
|
+
// 用「已采纳」会让人以为文件被改过,这里必须换成准确措辞
|
|
3519
|
+
setStatus(result.ok ? `${SIDECAR_KEEP_DONE}(${result.ok} 处)` : decideStatus('已采纳 ', result))
|
|
3520
|
+
const message = batchError(result)
|
|
3521
|
+
if (message) setError(message)
|
|
3484
3522
|
}).catch(() => { batchBusyRef.current = false })
|
|
3485
3523
|
}
|
|
3486
3524
|
const undoFile = () => {
|
|
@@ -3493,7 +3531,8 @@ export function EditorView(props) {
|
|
|
3493
3531
|
reloadFile(true)
|
|
3494
3532
|
emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
|
|
3495
3533
|
setStatus(decideStatus('已不采纳 ', result))
|
|
3496
|
-
|
|
3534
|
+
const message = batchError(result)
|
|
3535
|
+
if (message) setError(message)
|
|
3497
3536
|
}).catch(() => { batchBusyRef.current = false })
|
|
3498
3537
|
}
|
|
3499
3538
|
|
|
@@ -3501,7 +3540,7 @@ export function EditorView(props) {
|
|
|
3501
3540
|
const allPending = React.useMemo(() => {
|
|
3502
3541
|
const out = []
|
|
3503
3542
|
for (const rec of Object.values(records)) {
|
|
3504
|
-
if (rec.superseded === true ||
|
|
3543
|
+
if (rec.superseded === true || coveredPaths.has(diffPathKey(rec.path))) continue
|
|
3505
3544
|
const perHunk = Array.isArray(rec.decisions?.perHunk) ? rec.decisions.perHunk : []
|
|
3506
3545
|
const hunks = Array.isArray(rec.hunks) ? rec.hunks : []
|
|
3507
3546
|
for (let i = 0; i < hunks.length; i++) {
|
|
@@ -3555,7 +3594,8 @@ export function EditorView(props) {
|
|
|
3555
3594
|
if (reject || !missingNow) reloadFile(true)
|
|
3556
3595
|
emitRefresh({ scope, paths, kind: 'structure' })
|
|
3557
3596
|
setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result) + `;删除文件 ${handled.size} 个`)
|
|
3558
|
-
|
|
3597
|
+
const message = batchError(result)
|
|
3598
|
+
if (message) setError(message)
|
|
3559
3599
|
} catch (cause) { if (lifetime?.live) setError(String(cause)) }
|
|
3560
3600
|
finally { batchBusyRef.current = false; if (lifetime?.live) refreshRecords() }
|
|
3561
3601
|
}
|
|
@@ -3592,6 +3632,68 @@ export function EditorView(props) {
|
|
|
3592
3632
|
return false
|
|
3593
3633
|
}
|
|
3594
3634
|
|
|
3635
|
+
/** @private @author ddj 2026年11月03号 @description 批量通道的一次性完整快照(分页口径的 pending 只是页内计数)。 @returns 完整待审页 */
|
|
3636
|
+
async function batchSnapshot() {
|
|
3637
|
+
const res = await rpc('edrv.review.list', { sessionId, all: true })
|
|
3638
|
+
if (!res.ok) throw new Error(res.error)
|
|
3639
|
+
return res.page
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
/** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 逐条 Undo 的精确部分完成(冲突处停止) */
|
|
3643
|
+
function undoAllBatch(token) {
|
|
3644
|
+
return runReviewBatch({
|
|
3645
|
+
snapshot: batchSnapshot,
|
|
3646
|
+
detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
|
|
3647
|
+
decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
|
|
3648
|
+
guard: () => guardReviewAll(true, token), operation: batchOperation,
|
|
3649
|
+
}, true)
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 一次 RPC 的批量 Keep 结果(跳过项不算失败) */
|
|
3653
|
+
function keepAllBatch(token) {
|
|
3654
|
+
return runKeepAll({
|
|
3655
|
+
snapshot: batchSnapshot,
|
|
3656
|
+
keepAll: (items, batchId) => rpc('edrv.review.keepAll', { sessionId, batchId, items }),
|
|
3657
|
+
guard: () => guardReviewAll(false, token),
|
|
3658
|
+
})
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
/**
|
|
3662
|
+
* @private
|
|
3663
|
+
* @author ddj 2026年11月03号
|
|
3664
|
+
* @description: 批量结果落界面:刷新列表、状态栏计数;Keep 的跳过只做提示不当错误,
|
|
3665
|
+
* Undo 保持「遇冲突停止」口径。
|
|
3666
|
+
* @param result 批量结果
|
|
3667
|
+
* @param reject 本次是 Undo 还是 Keep
|
|
3668
|
+
* @param undoPath 起批时的活动文件
|
|
3669
|
+
* @param undoModel 起批时的编辑器模型
|
|
3670
|
+
* @param undoVersion 起批时的模型版本
|
|
3671
|
+
* @returns 官方段是否完全成功(legacy 通道是否继续)
|
|
3672
|
+
*/
|
|
3673
|
+
function finishReviewAll(result, reject, undoPath, undoModel, undoVersion) {
|
|
3674
|
+
refreshReview(sessionId)
|
|
3675
|
+
if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
|
|
3676
|
+
if (reject) {
|
|
3677
|
+
if (result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
|
|
3678
|
+
&& !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
|
|
3679
|
+
setStatus(`Undo All:官方记录已完成 ${result.completed} / ${result.total}`)
|
|
3680
|
+
if (result.error) setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`)
|
|
3681
|
+
return !result.error
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
const skipped = result.skipped ?? []
|
|
3685
|
+
setStatus(`Keep All:已保留 ${result.completed} / ${result.total}` + (skipped.length ? `,跳过 ${skipped.length} 条` : ''))
|
|
3686
|
+
if (result.error) setError(`Keep All 未全部完成:${result.error}`)
|
|
3687
|
+
if (skipped.length) toastDom(skippedTip(skipped))
|
|
3688
|
+
return !result.error
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
/** @private @author ddj 2026年11月03号 @param skipped 被跳过的条目 @returns 最多 3 条、余量汇总的提示文案 */
|
|
3692
|
+
function skippedTip(skipped) {
|
|
3693
|
+
const head = skipped.slice(0, 3).map((item) => `${item.display}:${item.reason}`).join(';')
|
|
3694
|
+
return skipped.length > 3 ? `${head};等 ${skipped.length} 条` : head
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3595
3697
|
/** @private @author ddj 2026年10月27号 @param reject Undo or Keep all workspace pending records, including previous rounds. @returns Explicit partial results; legacy proceeds only when official stage fully succeeds. */
|
|
3596
3698
|
async function actReviewAll(reject) {
|
|
3597
3699
|
if (batchBusyRef.current) return
|
|
@@ -3604,23 +3706,9 @@ export function EditorView(props) {
|
|
|
3604
3706
|
batchBusyRef.current = true; setReviewBatchBusy(true)
|
|
3605
3707
|
setStatus(reject ? '正在检查并 Undo 全部待审记录…' : '正在 Keep 全部待审记录…')
|
|
3606
3708
|
try {
|
|
3607
|
-
const result = await
|
|
3608
|
-
snapshot: async () => {
|
|
3609
|
-
const res = await rpc('edrv.review.list', { sessionId, all: true })
|
|
3610
|
-
if (!res.ok) throw new Error(res.error)
|
|
3611
|
-
return res.page
|
|
3612
|
-
},
|
|
3613
|
-
detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
|
|
3614
|
-
decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
|
|
3615
|
-
guard: () => guardReviewAll(reject, token), operation: batchOperation,
|
|
3616
|
-
}, reject)
|
|
3709
|
+
const result = reject ? await undoAllBatch(token) : await keepAllBatch(token)
|
|
3617
3710
|
if (!token?.live) return
|
|
3618
|
-
|
|
3619
|
-
if (reject && result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
|
|
3620
|
-
&& !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
|
|
3621
|
-
if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
|
|
3622
|
-
setStatus(`${reject ? 'Undo All' : 'Keep All'}:官方记录已完成 ${result.completed} / ${result.total}`)
|
|
3623
|
-
if (result.error) { setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`); return }
|
|
3711
|
+
if (!finishReviewAll(result, reject, undoPath, undoModel, undoVersion)) return
|
|
3624
3712
|
guardReviewAll(reject, token)
|
|
3625
3713
|
// Official-owned paths are excluded from allPending; never run legacy writes for official history.
|
|
3626
3714
|
if (allPending.length) { batchBusyRef.current = false; await actAllPending(reject) }
|
|
@@ -19,7 +19,7 @@ export function ReviewAllConfirm(props: ReviewAllProps): React.ReactElement {
|
|
|
19
19
|
const action = props.reject ? 'Undo All' : 'Keep All'
|
|
20
20
|
const warning = props.reject
|
|
21
21
|
? '撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。'
|
|
22
|
-
: '
|
|
22
|
+
: '只标记待审修改为保留,不改写文件;个别条目若已变化或需在活跃端判定会被跳过并留在列表里,可逐个处理。'
|
|
23
23
|
const footer = React.createElement(React.Fragment, null,
|
|
24
24
|
React.createElement(Button, { variant: 'ghost', onClick: props.onCancel, autoFocus: true }, '取消'),
|
|
25
25
|
React.createElement(Button, { variant: 'primary', onClick: props.onConfirm }, '确认 ' + action))
|
package/src/index.ts
CHANGED
|
@@ -76,7 +76,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
|
|
|
76
76
|
const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
|
|
77
77
|
// 待审累计基线:只有写后内容确实变化才落库,异常一律不打断用户工具调用
|
|
78
78
|
const pending = installPending(ctx, (write) => {
|
|
79
|
-
try { trackPendingCover(write.cwd, write.path, true) } catch { /* 内存登记失败不阻断采集 */ }
|
|
79
|
+
try { trackPendingCover(write.cwd, write.path, true, write.sessionId) } catch { /* 内存登记失败不阻断采集 */ }
|
|
80
80
|
try { recordWrite(write) } catch (error) { log.warn('待审差异落库失败:' + String(error)) }
|
|
81
81
|
})
|
|
82
82
|
/** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
|
package/src/pending/persist.ts
CHANGED
|
@@ -107,8 +107,20 @@ export function loadEntries(cwd: string): StoredEntry[] {
|
|
|
107
107
|
* @returns 无
|
|
108
108
|
*/
|
|
109
109
|
export function saveEntry(cwd: string, stored: StoredEntry): void {
|
|
110
|
+
upsertEntry(pendingDb(cwd), stored)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @private
|
|
115
|
+
* @author ddj 2026年11月03号
|
|
116
|
+
* @description: 在给定连接上写入或更新一条条目(含标记),供单条与批量两个入口复用。
|
|
117
|
+
* @param db 已迁移连接(可能已在事务中)
|
|
118
|
+
* @param stored 条目与标记
|
|
119
|
+
* @returns 无
|
|
120
|
+
*/
|
|
121
|
+
function upsertEntry(db: DatabaseSync, stored: StoredEntry): void {
|
|
110
122
|
const { entry, flags } = stored
|
|
111
|
-
|
|
123
|
+
db.prepare(`INSERT INTO pending_entries
|
|
112
124
|
(id,path,earlier,old_text,new_text,updated_at,unseen,missing,diverged,oversized,revision,session_id,session_ids,lineage)
|
|
113
125
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
114
126
|
ON CONFLICT(id) DO UPDATE SET path=excluded.path,earlier=excluded.earlier,old_text=excluded.old_text,
|
|
@@ -165,9 +177,7 @@ export function saveBlocks(cwd: string, entryId: string, blocks: { id: string; d
|
|
|
165
177
|
const db = pendingDb(cwd)
|
|
166
178
|
db.exec('BEGIN IMMEDIATE')
|
|
167
179
|
try {
|
|
168
|
-
db
|
|
169
|
-
const insert = db.prepare('INSERT INTO pending_blocks VALUES(?,?,?)')
|
|
170
|
-
for (const block of blocks) insert.run(entryId, block.id, block.decision)
|
|
180
|
+
writeBlocks(db, entryId, blocks)
|
|
171
181
|
db.exec('COMMIT')
|
|
172
182
|
} catch (error) {
|
|
173
183
|
if (db.isTransaction) db.exec('ROLLBACK')
|
|
@@ -175,6 +185,21 @@ export function saveBlocks(cwd: string, entryId: string, blocks: { id: string; d
|
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
/**
|
|
189
|
+
* @private
|
|
190
|
+
* @author ddj 2026年11月03号
|
|
191
|
+
* @description: 在给定连接上覆盖某条目的块决定集合,供单条与批量两个入口复用。
|
|
192
|
+
* @param db 已迁移连接(可能已在事务中)
|
|
193
|
+
* @param entryId 条目身份
|
|
194
|
+
* @param blocks 块决定
|
|
195
|
+
* @returns 无
|
|
196
|
+
*/
|
|
197
|
+
function writeBlocks(db: DatabaseSync, entryId: string, blocks: { id: string; decision: string }[]): void {
|
|
198
|
+
db.prepare('DELETE FROM pending_blocks WHERE entry_id=?').run(entryId)
|
|
199
|
+
const insert = db.prepare('INSERT INTO pending_blocks VALUES(?,?,?)')
|
|
200
|
+
for (const block of blocks) insert.run(entryId, block.id, block.decision)
|
|
201
|
+
}
|
|
202
|
+
|
|
178
203
|
|
|
179
204
|
/**
|
|
180
205
|
* @author ddj 2026年10月29号
|
|
@@ -222,7 +247,21 @@ export function writeOp(
|
|
|
222
247
|
* @returns 无
|
|
223
248
|
*/
|
|
224
249
|
export function pushHistory(cwd: string, kind: string, entryId: string, snapshot: string, now: number): void {
|
|
225
|
-
|
|
250
|
+
insertHistory(pendingDb(cwd), kind, entryId, snapshot, now)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @private
|
|
255
|
+
* @author ddj 2026年11月03号
|
|
256
|
+
* @description: 在给定连接上追加撤销栈记录并截断重做分支,供单条与批量两个入口复用。
|
|
257
|
+
* @param db 已迁移连接(可能已在事务中)
|
|
258
|
+
* @param kind 操作种类
|
|
259
|
+
* @param entryId 涉及的条目
|
|
260
|
+
* @param snapshot 可重放快照
|
|
261
|
+
* @param now 时间
|
|
262
|
+
* @returns 无
|
|
263
|
+
*/
|
|
264
|
+
function insertHistory(db: DatabaseSync, kind: string, entryId: string, snapshot: string, now: number): void {
|
|
226
265
|
db.prepare("DELETE FROM pending_history WHERE state='undone'").run()
|
|
227
266
|
db.prepare("INSERT INTO pending_history(kind,entry_id,snapshot,created_at,state) VALUES(?,?,?,?,'applied')")
|
|
228
267
|
.run(kind, entryId, snapshot, now)
|
|
@@ -327,3 +366,53 @@ function flagsOfRow(row: Row): PendingFlags {
|
|
|
327
366
|
revision: Number(row.revision),
|
|
328
367
|
}
|
|
329
368
|
}
|
|
369
|
+
|
|
370
|
+
/** 批量 Keep 的一条落库记录:stored 提供 CAS 依据与撤销快照 before,next 是推进后的条目。 */
|
|
371
|
+
export interface KeepRecord {
|
|
372
|
+
entryId: string
|
|
373
|
+
stored: StoredEntry
|
|
374
|
+
next: StoredEntry
|
|
375
|
+
blocks: { id: string; decision: string }[]
|
|
376
|
+
snapshot: string
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @public
|
|
381
|
+
* @author ddj 2026年11月03号
|
|
382
|
+
* @description: 在一次事务内批量推进多条待审条目(批量 Keep 用)。每条按 revision 做 CAS,
|
|
383
|
+
* 只跳过冲突的那一条,不牵连整批;块决定与撤销快照与单条路径同口径。
|
|
384
|
+
* @param cwd 工作区
|
|
385
|
+
* @param records 待落库记录
|
|
386
|
+
* @returns 因版本已变被跳过的条目
|
|
387
|
+
* @throws 事务级异常(SQL/程序错误)整体回滚后抛出
|
|
388
|
+
*/
|
|
389
|
+
export function saveDecisions(cwd: string, records: KeepRecord[]): { id: string; reason: string }[] {
|
|
390
|
+
const db = pendingDb(cwd)
|
|
391
|
+
const skipped: { id: string; reason: string }[] = []
|
|
392
|
+
const advance = db.prepare(`UPDATE pending_entries SET path=?,earlier=?,old_text=?,new_text=?,updated_at=?,unseen=?,
|
|
393
|
+
missing=?,diverged=?,oversized=?,revision=?,session_id=?,session_ids=?,lineage=? WHERE id=? AND revision=?`)
|
|
394
|
+
|
|
395
|
+
db.exec('BEGIN IMMEDIATE')
|
|
396
|
+
try {
|
|
397
|
+
for (const record of records) {
|
|
398
|
+
const { entry, flags } = record.next
|
|
399
|
+
const expected = record.stored.flags.revision
|
|
400
|
+
const changed = advance.run(entry.path, entry.earlierVersion, entry.oldText, entry.newText, entry.updatedAt,
|
|
401
|
+
entry.unseen === true ? 1 : 0, flags.missing ? 1 : 0, flags.diverged ? 1 : 0, flags.oversized ? 1 : 0,
|
|
402
|
+
flags.revision, entry.sessionId, JSON.stringify(entry.sessionIds),
|
|
403
|
+
entry.lineage ? JSON.stringify(entry.lineage) : null, record.entryId, expected)
|
|
404
|
+
// 读条目到落库之间被别的决定推进过:只放弃这一条
|
|
405
|
+
if (changed.changes !== 1) {
|
|
406
|
+
skipped.push({ id: record.entryId, reason: '待审条目已变化,请重试' })
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
writeBlocks(db, record.entryId, record.blocks)
|
|
410
|
+
insertHistory(db, 'decide', record.entryId, record.snapshot, Date.now())
|
|
411
|
+
}
|
|
412
|
+
db.exec('COMMIT')
|
|
413
|
+
return skipped
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (db.isTransaction) db.exec('ROLLBACK')
|
|
416
|
+
throw error
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/pending/store.ts
CHANGED
|
@@ -16,9 +16,10 @@ import { capabilityOf, decide, diffOf } from './decide.js'
|
|
|
16
16
|
import { entryFromNew, isSettled, mergeEntries } from './kernel.js'
|
|
17
17
|
import type { PendingEntry } from './kernel.js'
|
|
18
18
|
import type { PendingFlags, StoredEntry } from './persist.js'
|
|
19
|
+
import type { KeepRecord } from './persist.js'
|
|
19
20
|
import {
|
|
20
21
|
dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pushHistory,
|
|
21
|
-
readOp, saveBlocks, saveEntry, setHistoryState, writeOp,
|
|
22
|
+
readOp, saveBlocks, saveDecisions, saveEntry, setHistoryState, writeOp,
|
|
22
23
|
} from './persist.js'
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -289,6 +290,80 @@ export async function applyDecide(
|
|
|
289
290
|
return outcome
|
|
290
291
|
}
|
|
291
292
|
|
|
293
|
+
/** 批量 Keep 的目标条目:display 只用于回报,operationId 由 host 按批次派生。 */
|
|
294
|
+
export interface PendingKeepTarget {
|
|
295
|
+
id: string
|
|
296
|
+
display: string
|
|
297
|
+
operationId: string
|
|
298
|
+
}
|
|
299
|
+
/** 批量 Keep 的结果:kept 是真正推进到保留态的条目,skipped 说明该条为什么没保留。 */
|
|
300
|
+
export interface PendingKeepOutcome {
|
|
301
|
+
kept: { id: string; path: string }[]
|
|
302
|
+
skipped: { id: string; display: string; reason: string }[]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @public
|
|
307
|
+
* @author ddj 2026年11月03号
|
|
308
|
+
* @description: 一次读取全表后批量保留待审条目(Keep 不写盘)。写入用刚读到的最新 revision,
|
|
309
|
+
* 客户端快照漂移不会整批中止;单条 oversized、不存在或落库时被抢先推进只跳过自己。
|
|
310
|
+
* @param cwd 工作区
|
|
311
|
+
* @param items 待保留条目
|
|
312
|
+
* @returns 保留成功与跳过的明细
|
|
313
|
+
* @throws 待审库迁移未就绪等存储级异常
|
|
314
|
+
*/
|
|
315
|
+
export async function keepAll(cwd: string, items: PendingKeepTarget[]): Promise<PendingKeepOutcome> {
|
|
316
|
+
const stored = new Map(loadEntries(cwd).map((item) => [item.entry.id, item]))
|
|
317
|
+
const now = Date.now()
|
|
318
|
+
const outcome: PendingKeepOutcome = { kept: [], skipped: [] }
|
|
319
|
+
const records: KeepRecord[] = []
|
|
320
|
+
|
|
321
|
+
for (const item of items) {
|
|
322
|
+
const before = stored.get(item.id)
|
|
323
|
+
if (!before) {
|
|
324
|
+
outcome.skipped.push({ id: item.id, display: item.display, reason: '待审条目不存在' })
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (before.flags.oversized) {
|
|
328
|
+
outcome.skipped.push({ id: item.id, display: item.display, reason: '文本超出上限,不能整文件保留' })
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
// 已经是保留态:幂等无操作,仍算保留成功
|
|
332
|
+
if (isSettled(before.entry)) {
|
|
333
|
+
outcome.kept.push({ id: item.id, path: before.entry.path })
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const beforeBlocks = blockStatesOf(before.entry)
|
|
338
|
+
const outcomeOf = await decide({
|
|
339
|
+
request: { sessionId: before.entry.sessionId, id: item.id, revision: before.flags.revision,
|
|
340
|
+
operationId: item.operationId, decision: 'accepted', scope: 'file' },
|
|
341
|
+
entry: before.entry, flags: before.flags, blocks: beforeBlocks, now,
|
|
342
|
+
// Keep 永不写盘:真走到写回说明逻辑被改坏,宁可炸掉也不静默落盘
|
|
343
|
+
write: () => { throw new Error('批量 Keep 不写盘') },
|
|
344
|
+
})
|
|
345
|
+
if (!outcomeOf.ok) {
|
|
346
|
+
outcome.skipped.push({ id: item.id, display: item.display, reason: outcomeOf.reason })
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
outcome.kept.push({ id: item.id, path: before.entry.path })
|
|
350
|
+
records.push({ entryId: item.id, stored: before, next: { entry: outcomeOf.entry, flags: outcomeOf.flags },
|
|
351
|
+
blocks: outcomeOf.blocks, snapshot: JSON.stringify({
|
|
352
|
+
before: { entry: before.entry, flags: before.flags, blocks: beforeBlocks },
|
|
353
|
+
after: { entry: outcomeOf.entry, flags: outcomeOf.flags, blocks: outcomeOf.blocks },
|
|
354
|
+
wrote: outcomeOf.wrote,
|
|
355
|
+
}) })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 落库阶段被别的决定抢先推进的条目从 kept 挪到 skipped,如实反映磁盘状态
|
|
359
|
+
const displayOf = new Map(items.map((item) => [item.id, item.display]))
|
|
360
|
+
for (const conflict of saveDecisions(cwd, records)) {
|
|
361
|
+
outcome.kept = outcome.kept.filter((entry) => entry.id !== conflict.id)
|
|
362
|
+
outcome.skipped.push({ id: conflict.id, display: displayOf.get(conflict.id) ?? conflict.id, reason: conflict.reason })
|
|
363
|
+
}
|
|
364
|
+
return outcome
|
|
365
|
+
}
|
|
366
|
+
|
|
292
367
|
/**
|
|
293
368
|
* @author ddj 2026年10月30号
|
|
294
369
|
* @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。
|
package/src/pendingCompat.ts
CHANGED
|
@@ -7,8 +7,13 @@ import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
|
|
|
7
7
|
import type { PendingDetail, PendingItem, PendingPage } from './shared/pendingReview.js'
|
|
8
8
|
import { pathKeyFor } from './shared/pathKey.js'
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。
|
|
12
|
+
* 按会话登记:待审列表按会话可见,守卫必须同口径,否则会出现"拦得住、看不见"的死锁。
|
|
13
|
+
* 无会话归属的登记落在 workspace 集合(兼容只有工作区+路径的旧用法)。
|
|
14
|
+
*/
|
|
15
|
+
const coveredBySession = new Map<string, Set<string>>()
|
|
16
|
+
const coveredWorkspace = new Set<string>()
|
|
12
17
|
|
|
13
18
|
/** 待审条目在面板列表里的 id 前缀,用于把请求路由回待审内核。 */
|
|
14
19
|
export { PENDING_PREFIX } from './shared/pendingReview.js'
|
|
@@ -138,28 +143,94 @@ export function pendingDetailOf(detail: PendingDetail, capturedAt: string, cwd?:
|
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
/**
|
|
141
|
-
* @author ddj 2026年
|
|
146
|
+
* @author ddj 2026年11月3号
|
|
147
|
+
* @description: 覆盖登记键:工作区与路径各自归一化后再拼接,避免同一文件多种写法登记成两条。
|
|
148
|
+
* @param cwd 工作区
|
|
149
|
+
* @param path 路径
|
|
150
|
+
* @returns 覆盖登记键
|
|
151
|
+
*/
|
|
152
|
+
function coverKey(cwd: string, path: string): string {
|
|
153
|
+
return `${pathKey(cwd)}|${pathKey(path)}`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @author ddj 2026年11月3号
|
|
158
|
+
* @description: 取某会话的覆盖集合;首次登记时就地建集合,避免调用方判空。
|
|
159
|
+
* @param sessionId 会话 id
|
|
160
|
+
* @returns 该会话的覆盖键集合
|
|
161
|
+
*/
|
|
162
|
+
function sessionCovers(sessionId: string): Set<string> {
|
|
163
|
+
const existing = coveredBySession.get(sessionId)
|
|
164
|
+
if (existing) return existing
|
|
165
|
+
const created = new Set<string>()
|
|
166
|
+
coveredBySession.set(sessionId, created)
|
|
167
|
+
return created
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @author ddj 2026年11月3号
|
|
142
172
|
* @description: 记住/忘记"该路径由待审投影接管"。进程内集合,绝不在旧写入口上查库(避免撞上写事务锁)。
|
|
143
173
|
* @param cwd 工作区
|
|
144
174
|
* @param path 路径
|
|
145
175
|
* @param covered 是否接管
|
|
176
|
+
* @param sessionId 会话 id;缺省落到无归属的工作区集合
|
|
177
|
+
* @returns 无
|
|
178
|
+
*/
|
|
179
|
+
export function trackPendingCover(cwd: string, path: string, covered: boolean, sessionId?: string): void {
|
|
180
|
+
const key = coverKey(cwd, path)
|
|
181
|
+
const target = sessionId ? sessionCovers(sessionId) : coveredWorkspace
|
|
182
|
+
if (covered) target.add(key)
|
|
183
|
+
else target.delete(key)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @author ddj 2026年11月3号
|
|
188
|
+
* @description: 用一次列表结果整体重建某会话的接管集合:条目被移除或结案后自动失效,不再残留登记。
|
|
189
|
+
* @param cwd 工作区
|
|
190
|
+
* @param sessionId 会话 id
|
|
191
|
+
* @param paths 本会话当前可见的待审条目路径
|
|
146
192
|
* @returns 无
|
|
147
193
|
*/
|
|
148
|
-
export function
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
194
|
+
export function setPendingCovers(cwd: string, sessionId: string, paths: readonly string[]): void {
|
|
195
|
+
const next = new Set<string>()
|
|
196
|
+
for (const path of paths) {
|
|
197
|
+
if (path) next.add(coverKey(cwd, path))
|
|
198
|
+
}
|
|
199
|
+
coveredBySession.set(sessionId, next)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @author ddj 2026年11月3号
|
|
204
|
+
* @description: 找出接管某路径的会话,用于把"谁在管"写进提示文案;仅读内存,不查库。
|
|
205
|
+
* @param cwd 工作区
|
|
206
|
+
* @param path 路径
|
|
207
|
+
* @returns 首个接管会话 id;无人接管返回 null
|
|
208
|
+
*/
|
|
209
|
+
export function pendingCoverOwner(cwd: string, path: string): string | null {
|
|
210
|
+
const key = coverKey(cwd, path)
|
|
211
|
+
for (const [sessionId, set] of coveredBySession) {
|
|
212
|
+
if (set.has(key)) return sessionId
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
152
215
|
}
|
|
153
216
|
|
|
154
217
|
/**
|
|
155
218
|
* @author ddj 2026年10月29号
|
|
156
219
|
* @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
|
|
220
|
+
* 给 sessionId 时按该会话的可见范围判定(列表看不到的接管不拦旧入口),缺省按全工作区并集判定。
|
|
157
221
|
* @param cwd 工作区
|
|
158
222
|
* @param path 请求路径
|
|
223
|
+
* @param sessionId 会话 id;缺省按并集判定
|
|
159
224
|
* @returns 是否已被接管
|
|
160
225
|
*/
|
|
161
|
-
export function pendingCovers(cwd: string, path: string): boolean {
|
|
162
|
-
|
|
226
|
+
export function pendingCovers(cwd: string, path: string, sessionId?: string): boolean {
|
|
227
|
+
const key = coverKey(cwd, path)
|
|
228
|
+
if (coveredWorkspace.has(key)) return true
|
|
229
|
+
if (sessionId) return coveredBySession.get(sessionId)?.has(key) === true
|
|
230
|
+
for (const set of coveredBySession.values()) {
|
|
231
|
+
if (set.has(key)) return true
|
|
232
|
+
}
|
|
233
|
+
return false
|
|
163
234
|
}
|
|
164
235
|
|
|
165
236
|
/**
|
package/src/pendingRpc.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveScope, sessionOf } from './registry.js'
|
|
|
5
5
|
import type { Registry } from './registry.js'
|
|
6
6
|
import { bucketOf } from './registry.js'
|
|
7
7
|
import { readText } from './pending/capture.js'
|
|
8
|
-
import { pendingRawId, trackPendingCover } from './pendingCompat.js'
|
|
8
|
+
import { pendingRawId, setPendingCovers, trackPendingCover } from './pendingCompat.js'
|
|
9
9
|
import { loadEntries, saveEntry } from './pending/persist.js'
|
|
10
10
|
import { applyDecide, importFromVcs, itemOf, loadDetail, loadPage, refreshed, removeEntry, undoDecide } from './pending/store.js'
|
|
11
11
|
import { addedFiles } from './reviewDb.js'
|
|
@@ -85,8 +85,8 @@ function readHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
|
|
|
85
85
|
const cwd = await kit.read(args.sessionId)
|
|
86
86
|
// 与官方入口同口径:本会话新建的文件基线置空,列表里的增删行数才等于整文件行数
|
|
87
87
|
const page = loadPage(cwd, args.sessionId, addedFiles(cwd, args.sessionId))
|
|
88
|
-
//
|
|
89
|
-
|
|
88
|
+
// 列表即登记接管关系:整集重建本会话的登记,条目被移出列表后自动失效
|
|
89
|
+
setPendingCovers(cwd, args.sessionId, page.items.map((item) => item.path))
|
|
90
90
|
return { ok: true, page }
|
|
91
91
|
},
|
|
92
92
|
'edrv.pending.detail': async (args: PendingRequest & { id: string }) => {
|
|
@@ -124,8 +124,8 @@ function decideHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
|
|
|
124
124
|
const id = pendingRawId(args.id)
|
|
125
125
|
const path = loadEntries(cwd).find((stored) => stored.entry.id === id)?.entry.path
|
|
126
126
|
const removed = removeEntry(cwd, id, args.revision)
|
|
127
|
-
//
|
|
128
|
-
if (removed && path) trackPendingCover(cwd, path, false)
|
|
127
|
+
// 移除后该路径不再由待审投影接管,旧入口守卫随之放行(仅注销本会话的登记)
|
|
128
|
+
if (removed && path) trackPendingCover(cwd, path, false, args.sessionId)
|
|
129
129
|
return { ok: true, removed }
|
|
130
130
|
},
|
|
131
131
|
}
|
|
@@ -168,7 +168,8 @@ function refreshHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
|
|
|
168
168
|
try {
|
|
169
169
|
const shell = shellOf(raw, policy)
|
|
170
170
|
const result = await importFromVcs(shell, cwd, args.sessionId, { includeUntracked: args.includeUntracked, path: args.path })
|
|
171
|
-
|
|
171
|
+
const page = loadPage(cwd, args.sessionId)
|
|
172
|
+
setPendingCovers(cwd, args.sessionId, page.items.map((item) => item.path))
|
|
172
173
|
return { ok: true, imported: result.imported, changes: result.changes }
|
|
173
174
|
} catch (error) {
|
|
174
175
|
return { ok: false, error: failureOf(error) }
|