dsh-plugin-orchd 0.1.0 → 0.1.2
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 +8 -0
- package/client/client.js +106 -118
- package/lib/cache.js +5 -0
- package/lib/cli-invoke.js +12 -5
- package/lib/detect.js +37 -23
- package/lib/git-times.js +240 -0
- package/lib/ideas.js +82 -57
- package/lib/master.js +61 -23
- package/lib/model.js +51 -5
- package/lib/orchd-cli.js +59 -5
- package/lib/routes.js +61 -24
- package/package.json +1 -1
package/lib/master.js
CHANGED
|
@@ -36,15 +36,17 @@ export function launcherOf(root) {
|
|
|
36
36
|
return join(orchdDirOf(root), LAUNCHER_FILENAME)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
// ── 安装痕迹不再是独立的判定步骤 ────────────────────────────────────────────
|
|
40
|
+
//
|
|
41
|
+
// 历史坑(根因治理前):这里曾导出 `hasInstallationTrace(root)`(= 「根下有没有 `.orchd`」)
|
|
42
|
+
// 作为 `detect` 的第 ① 步,而 `resolveLayout` 又自带一套容器主工作树自动探测。两条路径对
|
|
43
|
+
// 「痕迹在哪」的口径不一致 → 每出现一种新布局形态就得在其中一条上补特例、另一条漏掉
|
|
44
|
+
// (junction / 根空 `.orchd` / 根**无** `.orchd` / 子目录标记),于是来回打补丁:
|
|
45
|
+
// 「3DViewer 空 `.orchd`」修过一次,「容器根连 `.orchd` 都没有」又漏一次。
|
|
46
|
+
//
|
|
47
|
+
// 现在把「是否接入 + 主工作树/定义文件在哪」**收敛为唯一真源**:`resolveLayout`。
|
|
48
|
+
// 它返回的 `no-installation` 就是「根与子工作树都没有安装痕迹」这唯一一种结论,
|
|
49
|
+
// `detect` 只消费它,不再有任何独立的痕迹判定。
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
52
|
* 自动探测「容器布局的主工作树」(FR-2.2 补充,用户实测驱动)。
|
|
@@ -127,7 +129,17 @@ function findContainerWorktree(root) {
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
/**
|
|
130
|
-
*
|
|
132
|
+
* 布局发现 —— 「是否接入 orchd」与「主工作树/定义文件在哪」的**唯一真源**。
|
|
133
|
+
*
|
|
134
|
+
* 所有布局形态(junction / 根空 `.orchd` / 根**无** `.orchd` / 子目录标记 / 标记与实际工作树
|
|
135
|
+
* 不一致)都在**这一个函数**里收敛;`detect` 只消费它的结论,不再另判安装痕迹(历史坑见上方注释)。
|
|
136
|
+
*
|
|
137
|
+
* 三种结论:
|
|
138
|
+
* · resolved → `ok:true`,带 kind / mainWorktree / masterPath / storeHint
|
|
139
|
+
* · broken → `ok:false`,reason ∈ { layout-marker-mismatch, layout-unresolved }
|
|
140
|
+
* · no-installation → `ok:false`,reason = no-installation ← detect 据此判 not_installed
|
|
141
|
+
*
|
|
142
|
+
* 三套路径:
|
|
131
143
|
*
|
|
132
144
|
* 「」「
|
|
133
145
|
* container → mainWorktree = json.main_worktree
|
|
@@ -137,19 +149,28 @@ function findContainerWorktree(root) {
|
|
|
137
149
|
* → mainWorktree = root
|
|
138
150
|
* masterPath = <root>/.orchd/_master.json
|
|
139
151
|
* storeHint = <root>/.orchd
|
|
140
|
-
*
|
|
152
|
+
* 探针兜底:主候选 masterPath 不存在则试另一分支候选
|
|
153
|
+
*
|
|
154
|
+
* 探针兜底后,扫一层非隐藏子目录找容器主工作树(见 findContainerWorktree):
|
|
155
|
+
* 找到(自述 container / 有 _master.json / 有 __main__.py)→ container
|
|
156
|
+
* 找不到时再看根自己有没有 `.orchd`:
|
|
157
|
+
* 有(哪怕空目录)→ flat(是否 uninitialized 交给 detect 的定义层判定)
|
|
158
|
+
* 没有 → no-installation(根与子工作树都没有痕迹)
|
|
159
|
+
* container 标记下两边 master 都不在 → layout-unresolved
|
|
141
160
|
* 「」「
|
|
142
161
|
*
|
|
143
162
|
* @param {string} root 项目根(可能是容器根)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
* @param {{ probeFallback?: boolean }} [options] probeFallback=false 时跳过探针兜底与自动探测(纯逻辑测试用)
|
|
164
|
+
* @returns {{ ok: true, kind: 'container'|'flat', mainWorktree: string, masterPath: string, storeHint: string,
|
|
165
|
+
* containerRoot: string|null, markerPath: string, markerRead: boolean, markerError?: string, note?: string }
|
|
166
|
+
* | { ok: false, reason: 'layout-marker-mismatch'|'layout-unresolved'|'no-installation', detail: object }}
|
|
148
167
|
*/
|
|
149
168
|
export function resolveLayout(root, options = {}) {
|
|
150
169
|
const probeFallback = options.probeFallback !== false
|
|
151
170
|
const rootCanonical = canonicalPath(root)
|
|
152
171
|
const markerPath = layoutFileOf(rootCanonical)
|
|
172
|
+
// 「根自己有没有 .orchd」是区分 flat 与 no-installation 的唯一依据(即原 hasInstallationTrace 的语义)。
|
|
173
|
+
const rootHasOrchd = isDirectory(orchdDirOf(rootCanonical))
|
|
153
174
|
|
|
154
175
|
let marker
|
|
155
176
|
let markerRead = false
|
|
@@ -167,6 +188,10 @@ export function resolveLayout(root, options = {}) {
|
|
|
167
188
|
ok: true,
|
|
168
189
|
kind,
|
|
169
190
|
mainWorktree: worktree,
|
|
191
|
+
// 容器根 = 主工作树的父目录(仅 container 布局有意义,flat 下为 null)。
|
|
192
|
+
// 用途:引擎可能装在容器根的 `.orchd/`(主工作树只有定义层),启动器定位要能回退过去
|
|
193
|
+
// —— 见 `orchd-cli.js::resolveLauncherPath`。
|
|
194
|
+
containerRoot: kind === 'container' ? dirname(worktree) : null,
|
|
170
195
|
masterPath: join(orchdDir, MASTER_FILENAME),
|
|
171
196
|
storeHint: kind === 'container' ? join(dirname(worktree), '.orchd-runtime') : orchdDir,
|
|
172
197
|
markerPath,
|
|
@@ -223,9 +248,9 @@ export function resolveLayout(root, options = {}) {
|
|
|
223
248
|
}
|
|
224
249
|
}
|
|
225
250
|
|
|
226
|
-
// ⚠ 根上**没有容器标记**(`alternate === null
|
|
227
|
-
//
|
|
228
|
-
//
|
|
251
|
+
// ⚠ 根上**没有容器标记**(`alternate === null`)时:先扫一层子目录找容器主工作树
|
|
252
|
+
// (「容器根的标记只在 main/ 下」这种项目——不论根自己有没有 `.orchd` 都要找)。
|
|
253
|
+
// 找不到时再看根自己有没有 `.orchd`,决定是 flat 还是「未接入」。
|
|
229
254
|
if (alternate === null) {
|
|
230
255
|
const auto = findContainerWorktree(rootCanonical)
|
|
231
256
|
if (auto !== null) {
|
|
@@ -235,23 +260,36 @@ export function resolveLayout(root, options = {}) {
|
|
|
235
260
|
markerRead: auto.markerRead
|
|
236
261
|
}
|
|
237
262
|
}
|
|
238
|
-
}
|
|
239
263
|
|
|
240
|
-
|
|
264
|
+
// 探测不到主工作树:根有 `.orchd`(哪怕空目录)→ flat(装了没初始化,交给 detect 判);
|
|
265
|
+
// 根连 `.orchd` 都没有 → 根与子工作树都没有痕迹 → 未接入。
|
|
266
|
+
if (rootHasOrchd) return primary
|
|
267
|
+
|
|
241
268
|
return {
|
|
242
269
|
ok: false,
|
|
243
|
-
reason: '
|
|
270
|
+
reason: 'no-installation',
|
|
244
271
|
detail: {
|
|
245
272
|
root: rootCanonical,
|
|
246
273
|
markerPath,
|
|
247
274
|
markerRead,
|
|
248
275
|
...(markerError === undefined ? {} : { markerError }),
|
|
249
|
-
|
|
276
|
+
hint: 'no .orchd directory at the project root and no container main worktree found under it'
|
|
250
277
|
}
|
|
251
278
|
}
|
|
252
279
|
}
|
|
253
280
|
|
|
254
|
-
|
|
281
|
+
// container 标记在,但两边候选都没有主工作树的定义文件 → 布局无法收敛
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
reason: 'layout-unresolved',
|
|
285
|
+
detail: {
|
|
286
|
+
root: rootCanonical,
|
|
287
|
+
markerPath,
|
|
288
|
+
markerRead,
|
|
289
|
+
...(markerError === undefined ? {} : { markerError }),
|
|
290
|
+
triedMasterPaths: [primary.masterPath, alternate.masterPath]
|
|
291
|
+
}
|
|
292
|
+
}
|
|
255
293
|
}
|
|
256
294
|
|
|
257
295
|
/**
|
package/lib/model.js
CHANGED
|
@@ -78,6 +78,8 @@ export function buildTaskRow(definition, state, extra = {}, weights = null) {
|
|
|
78
78
|
// 状态层用 `task_id`(`status --all` 的 tasks[]),`pool --all` 用 `id`;两个都认。
|
|
79
79
|
const stateId = state !== null && typeof state === 'object' ? (state.task_id ?? state.id) : undefined
|
|
80
80
|
const id = definitionRow.id ?? (typeof stateId === 'string' ? stateId : undefined) ?? null
|
|
81
|
+
const times = extra.times ?? null
|
|
82
|
+
const timeOf = (map) => (typeof id === 'string' && typeof map?.[id] === 'string' ? map[id] : null)
|
|
81
83
|
|
|
82
84
|
return {
|
|
83
85
|
id,
|
|
@@ -99,6 +101,14 @@ export function buildTaskRow(definition, state, extra = {}, weights = null) {
|
|
|
99
101
|
reviewPhase: stateRow.reviewPhase,
|
|
100
102
|
reviewClaimedBy: stateRow.reviewClaimedBy,
|
|
101
103
|
blockedDownstream: Number.isInteger(extra.blockedDownstream) ? extra.blockedDownstream : null,
|
|
104
|
+
/**
|
|
105
|
+
* 「最近动作时间」的 git 近似(FR-5.4 修订):活跃任务取 `task/<id>` 分支的 tip 时间。
|
|
106
|
+
* 引擎不提供任何时间字段,来源与口径见 `git-times.js`;取不到就是 null(排序兜底,
|
|
107
|
+
* **不臆造时间**,UI 若显示则必须走占位符)。
|
|
108
|
+
*/
|
|
109
|
+
lastActionAt: timeOf(times?.branchTimes ?? null),
|
|
110
|
+
/** 「合并进主分支时间」的 git 近似:终态任务取 `Merge branch 'task/<id>'` 提交时间。 */
|
|
111
|
+
mergedAt: timeOf(times?.mergeTimes ?? null),
|
|
102
112
|
inDefinition: definition !== undefined && definition !== null,
|
|
103
113
|
inState: state !== undefined && state !== null
|
|
104
114
|
}
|
|
@@ -155,7 +165,7 @@ export function buildTaskDetail(definition, state, index, extra = {}) {
|
|
|
155
165
|
* @param {object} args.index 定义层索引
|
|
156
166
|
* @param {string} [args.masterPath]
|
|
157
167
|
*/
|
|
158
|
-
export function buildBoard({ statusJson, poolJson, index, masterPath }) {
|
|
168
|
+
export function buildBoard({ statusJson, poolJson, index, masterPath, gitTimes }) {
|
|
159
169
|
const stateRows = new Map()
|
|
160
170
|
for (const task of Array.isArray(statusJson?.tasks) ? statusJson.tasks : []) {
|
|
161
171
|
if (task !== null && typeof task === 'object' && typeof task.task_id === 'string') stateRows.set(task.task_id, task)
|
|
@@ -175,7 +185,7 @@ export function buildBoard({ statusJson, poolJson, index, masterPath }) {
|
|
|
175
185
|
for (const id of ids) {
|
|
176
186
|
const definition = index?.tasks?.get(id)
|
|
177
187
|
const state = stateRows.get(id)
|
|
178
|
-
rows.push(buildTaskRow(definition, state, { blockedDownstream: blockedByTask.get(id) }, weights))
|
|
188
|
+
rows.push(buildTaskRow(definition, state, { blockedDownstream: blockedByTask.get(id), times: gitTimes }, weights))
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
sortRows(rows, 'default', weights)
|
|
@@ -196,7 +206,14 @@ export function buildBoard({ statusJson, poolJson, index, masterPath }) {
|
|
|
196
206
|
hasDag: (index?.tasks?.size ?? 0) > 0,
|
|
197
207
|
hasBlockedCount: blockedByTask.size > 0,
|
|
198
208
|
hasDefinitionLayer: (index?.tasks?.size ?? 0) > 0,
|
|
199
|
-
hasStateLayer: stateRows.size > 0
|
|
209
|
+
hasStateLayer: stateRows.size > 0,
|
|
210
|
+
/** 排序用的 git 时间层(FR-5.4 修订):活跃=分支 tip 时间、终态=合并提交时间。 */
|
|
211
|
+
hasActionTimes: Object.keys(gitTimes?.branchTimes ?? {}).length > 0,
|
|
212
|
+
hasMergeTimes: Object.keys(gitTimes?.mergeTimes ?? {}).length > 0,
|
|
213
|
+
/** git 时间层不可用时的原因(非 git 项目 / git 缺失 / 超时);可用时为 null。 */
|
|
214
|
+
gitTimesReason: gitTimes !== undefined && gitTimes !== null && gitTimes.available === false
|
|
215
|
+
? (gitTimes.reason ?? 'unavailable')
|
|
216
|
+
: null
|
|
200
217
|
},
|
|
201
218
|
/** 两层不一致时显式报告(不静默丢数据)。 */
|
|
202
219
|
orphans: { onlyInState, onlyInDefinition }
|
|
@@ -226,16 +243,45 @@ export function summarize(engineSummary, rows) {
|
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
/**
|
|
229
|
-
*
|
|
246
|
+
* 排序。默认档 = 「活跃优先 → 组内按时间」(FR-5.4 修订,2026-10-29):
|
|
247
|
+
* · 组:活跃(claimed / done / in_review)→ 待办(pending)→ 终态(completed / cancelled);
|
|
248
|
+
* · 活跃组内按 `lastActionAt` 由近到远(git 近似:`task/<id>` 分支 tip 时间);
|
|
249
|
+
* · 终态组内按 `mergedAt` 由近到远(git 近似:合并进主分支的提交时间);
|
|
250
|
+
* · 取不到时间的(未 claim 的 pending、cancelled、非 git 项目)留在本组最后,退回
|
|
251
|
+
* 「重要度 → id」——**不臆造时间**。
|
|
252
|
+
* 其它档位(importance / difficulty / module / status)行为不变。
|
|
253
|
+
*
|
|
230
254
|
* @param {object[]} rows 原地排序
|
|
231
255
|
* @param {'default'|'importance'|'difficulty'|'module'|'status'} by
|
|
256
|
+
* @param {object|null} weights 引擎的重要度权重
|
|
232
257
|
*/
|
|
233
258
|
export function sortRows(rows, by = 'default', weights = null) {
|
|
234
259
|
const statusRank = (status) => (ACTIVE_STATUSES.has(status) ? 0 : TERMINAL_STATUSES.has(status) ? 2 : 1)
|
|
260
|
+
// 时间戳只算一次(比较器会被调用 O(n log n) 次);非法值一律折叠成 null
|
|
261
|
+
const timeCache = new Map()
|
|
262
|
+
const timeValue = (row) => {
|
|
263
|
+
if (timeCache.has(row)) return timeCache.get(row)
|
|
264
|
+
const raw = TERMINAL_STATUSES.has(row.status) ? row.mergedAt : row.lastActionAt
|
|
265
|
+
const parsed = typeof raw === 'string' && raw !== '' ? Date.parse(raw) : Number.NaN
|
|
266
|
+
const value = Number.isFinite(parsed) ? parsed : null
|
|
267
|
+
timeCache.set(row, value)
|
|
268
|
+
return value
|
|
269
|
+
}
|
|
270
|
+
const byTime = (a, b) => {
|
|
271
|
+
const ta = timeValue(a)
|
|
272
|
+
const tb = timeValue(b)
|
|
273
|
+
if (ta === null && tb === null) return 0
|
|
274
|
+
if (ta === null) return 1
|
|
275
|
+
if (tb === null) return -1
|
|
276
|
+
return tb - ta
|
|
277
|
+
}
|
|
278
|
+
const byImportance = (a, b) =>
|
|
279
|
+
importanceRank(b.importance ?? b.importanceDerived, weights) - importanceRank(a.importance ?? a.importanceDerived, weights)
|
|
235
280
|
const comparators = {
|
|
236
281
|
default: (a, b) =>
|
|
237
282
|
statusRank(a.status) - statusRank(b.status) ||
|
|
238
|
-
|
|
283
|
+
byTime(a, b) ||
|
|
284
|
+
byImportance(a, b) ||
|
|
239
285
|
(a.id ?? '').localeCompare(b.id ?? ''),
|
|
240
286
|
importance: (a, b) => importanceRank(b.importance ?? b.importanceDerived, weights) - importanceRank(a.importance ?? a.importanceDerived, weights) || (a.id ?? '').localeCompare(b.id ?? ''),
|
|
241
287
|
difficulty: (a, b) => difficultyRank(b.difficulty) - difficultyRank(a.difficulty) || (a.id ?? '').localeCompare(b.id ?? ''),
|
package/lib/orchd-cli.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
16
|
import { launcherOf } from './master.js'
|
|
17
|
+
import { isFile, keyOf } from './paths.js'
|
|
17
18
|
|
|
18
19
|
/** 子进程环境:逐项登记(docs/03 第 6.3 节)。 */
|
|
19
20
|
export const CHILD_ENV = Object.freeze({
|
|
@@ -118,6 +119,55 @@ function parseJson(text) {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* 启动器定位:按「同一工作树 → 会话根 → 布局声明的容器根」的顺序取第一个**真实存在**的
|
|
124
|
+
* `.orchd/__main__.py`。
|
|
125
|
+
*
|
|
126
|
+
* 背景(用户实测,WallDetect 形态):引擎的安装落点与主工作树**可以分离**——有的项目在
|
|
127
|
+
* 容器根跑安装器,引擎落在 `<容器根>/.orchd/`(含 `orchd/` 包与入口),而主工作树
|
|
128
|
+
* `<容器根>/main/.orchd/` 只有定义层(`_master.json` / `.layout.json` / `shared/` /
|
|
129
|
+
* `snapshots/`),**没有**引擎入口。此时按「启动器取主工作树」的旧逻辑会
|
|
130
|
+
* `can't open file ... __main__.py`(exit 2)→ 四态检测恒为 `broken/cli-failed`
|
|
131
|
+
* (面板显示「状态异常」,所有读命令连带失败)。
|
|
132
|
+
*
|
|
133
|
+
* 实测:`cwd = 主工作树` + `启动器 = 容器根 .orchd/__main__.py` → `exit=0`(正常返回 JSON)。
|
|
134
|
+
* 原因是启动器用 `Path(__file__).resolve()` 定位引擎包,**不依赖 cwd**;而 E036 约束只针对
|
|
135
|
+
* cwd,与启动器在哪一侧无关。所以 cwd 仍然必须是主工作树(见 `runOrchdCli` 头注释)。
|
|
136
|
+
*
|
|
137
|
+
* 三个候选的顺序与去重(按启动器路径去重,Windows 大小写不敏感):
|
|
138
|
+
* ① `cwd`(主工作树)—— 正常形态,也是历史行为;
|
|
139
|
+
* ② `root`(会话根)—— 会话根就是容器根时命中;
|
|
140
|
+
* ③ `containerRoot`(由 `resolveLayout` 给出,= 主工作树的父目录)—— 会话根是主工作树时命中。
|
|
141
|
+
*
|
|
142
|
+
* 三个都不存在时返回 ①:让解释器自己报错,stderr 里那条路径正好用于诊断。
|
|
143
|
+
*
|
|
144
|
+
* @param {string} cwd 工作目录(主工作树;flat 布局下等于项目根)
|
|
145
|
+
* @param {string} root 会话根(容器根或 flat 根或主工作树)
|
|
146
|
+
* @param {string|null} [containerRoot] 布局声明的容器根(flat 布局下为 null)
|
|
147
|
+
* @returns {{ path: string, source: 'worktree'|'container-root-fallback' }}
|
|
148
|
+
*/
|
|
149
|
+
export function resolveLauncherPath(cwd, root, containerRoot) {
|
|
150
|
+
const seen = new Set()
|
|
151
|
+
const candidates = []
|
|
152
|
+
const add = (dir, source) => {
|
|
153
|
+
if (typeof dir !== 'string' || dir === '') return
|
|
154
|
+
const path = launcherOf(dir)
|
|
155
|
+
const key = keyOf(path)
|
|
156
|
+
if (seen.has(key)) return
|
|
157
|
+
seen.add(key)
|
|
158
|
+
candidates.push({ path, source })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
add(cwd, 'worktree')
|
|
162
|
+
add(root, 'container-root-fallback')
|
|
163
|
+
add(containerRoot ?? null, 'container-root-fallback')
|
|
164
|
+
|
|
165
|
+
for (const candidate of candidates) {
|
|
166
|
+
if (isFile(candidate.path)) return candidate
|
|
167
|
+
}
|
|
168
|
+
return candidates[0]
|
|
169
|
+
}
|
|
170
|
+
|
|
121
171
|
/**
|
|
122
172
|
* 调用项目自带的引擎(唯一入口)。
|
|
123
173
|
*
|
|
@@ -128,12 +178,14 @@ function parseJson(text) {
|
|
|
128
178
|
* 所以 `workDir` 由调用方从 `layout.mainWorktree` 传进来;布局解析不出来时它等于项目根,
|
|
129
179
|
* 这正好就是 flat 布局的情形,行为与 P0 一致。
|
|
130
180
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
181
|
+
* 启动器优先取**同一个工作树**下的 `.orchd/__main__.py`;该文件不存在时回退到项目根
|
|
182
|
+
* (容器根)的 `.orchd/__main__.py`——见 `resolveLauncherPath`:引擎的安装落点与主工作树
|
|
183
|
+
* 可以分离(用户实测的项目正是这种形态),该组合实测 `exit=0`。
|
|
133
184
|
*
|
|
134
185
|
* @param {object} args
|
|
135
186
|
* @param {string} args.root 项目根(容器根或 flat 根)——仅用于诊断信息
|
|
136
187
|
* @param {string} [args.workDir] 引擎工作目录(**必须是主工作树**)
|
|
188
|
+
* @param {string|null} [args.containerRoot] 布局声明的容器根(`resolveLayout` 给出;启动器回退候选之一)
|
|
137
189
|
* @param {{path: string, args: string[]}} args.python 已探测到的解释器
|
|
138
190
|
* @param {string[]} args.argv 例如 `['status', '--all']`
|
|
139
191
|
* @param {number} [args.timeoutMs]
|
|
@@ -141,7 +193,7 @@ function parseJson(text) {
|
|
|
141
193
|
* @param {Function} [args.spawnImpl] 仅测试用:替换 `child_process.spawn`
|
|
142
194
|
* @returns {Promise<object>} 见下方各 return 分支
|
|
143
195
|
*/
|
|
144
|
-
export async function runOrchdCli({ root, workDir, python, argv, timeoutMs = 20_000, signal, spawnImpl = spawn }) {
|
|
196
|
+
export async function runOrchdCli({ root, workDir, containerRoot, python, argv, timeoutMs = 20_000, signal, spawnImpl = spawn }) {
|
|
145
197
|
const allowed = checkWhitelist(argv)
|
|
146
198
|
if (allowed.ok !== true) {
|
|
147
199
|
return {
|
|
@@ -159,7 +211,8 @@ export async function runOrchdCli({ root, workDir, python, argv, timeoutMs = 20_
|
|
|
159
211
|
}
|
|
160
212
|
|
|
161
213
|
const cwd = typeof workDir === 'string' && workDir !== '' ? workDir : root
|
|
162
|
-
const
|
|
214
|
+
const chosenLauncher = resolveLauncherPath(cwd, root, containerRoot ?? null)
|
|
215
|
+
const launcher = chosenLauncher.path
|
|
163
216
|
const command = python.path
|
|
164
217
|
const commandArgs = [...(python.args ?? []), launcher, ...argv]
|
|
165
218
|
|
|
@@ -169,7 +222,8 @@ export async function runOrchdCli({ root, workDir, python, argv, timeoutMs = 20_
|
|
|
169
222
|
const finish = (value) => {
|
|
170
223
|
if (settled) return
|
|
171
224
|
settled = true
|
|
172
|
-
|
|
225
|
+
// launcher / launcherSource 一并带出:回退发生时诊断区能直接看出用的是哪一份入口
|
|
226
|
+
resolve({ ...value, elapsedMs: Date.now() - startedAt, argv, launcher, launcherSource: chosenLauncher.source })
|
|
173
227
|
}
|
|
174
228
|
|
|
175
229
|
let child
|
package/lib/routes.js
CHANGED
|
@@ -21,9 +21,10 @@ import { detect, summarizeLayout } from './detect.js'
|
|
|
21
21
|
import { cachedDefinition } from './cache.js'
|
|
22
22
|
import { readMasterIndexed } from './master.js'
|
|
23
23
|
import { buildBoard, buildGraph, buildReviewTimeline, buildTaskDetail } from './model.js'
|
|
24
|
+
import { collectGitTimes } from './git-times.js'
|
|
24
25
|
import { isTaskId, isUnsupportedCommand } from './cli-invoke.js'
|
|
25
26
|
import { INSTALL_COMMAND, buildInstallPrompt, injectInstallPrompt, probeInjectSupport } from './install-entry.js'
|
|
26
|
-
import { IDEA_OWNER, IDEA_STATUSES,
|
|
27
|
+
import { IDEA_OWNER, IDEA_STATUSES, attachConvertedTasks, buildIdeaTaskIndex, classifyArchive, ideaStripStatuses, ideasArchiveOf, ideasFileOf, parseIdeasMarkdown, scanArchiveFile, summarizeIdeas } from './ideas.js'
|
|
27
28
|
import { ORCHD_OFFICIAL_REPO, ORCHD_MIRROR_REPO, resolveRepo } from './config.js'
|
|
28
29
|
import { keyOf } from './paths.js'
|
|
29
30
|
|
|
@@ -138,11 +139,26 @@ async function loadDefinition(deps, detected) {
|
|
|
138
139
|
return read?.ok === true ? read.index : undefined
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 引擎调用上下文:**每个调用点都走这里**,保证两个位置信息都带上——
|
|
144
|
+
* · `workDir` = 主工作树(E036 约束:container 布局下引擎拒绝以容器根为 cwd);
|
|
145
|
+
* · `containerRoot` = 布局声明的容器根(引擎装在容器根、主工作树只有定义层时,
|
|
146
|
+
* 启动器要从那里回退取,见 `orchd-cli.js::resolveLauncherPath`)。
|
|
147
|
+
*/
|
|
148
|
+
function cliTarget(detected) {
|
|
149
|
+
return {
|
|
150
|
+
root: detected.root,
|
|
151
|
+
workDir: detected.layout?.mainWorktree,
|
|
152
|
+
containerRoot: detected.layout?.containerRoot ?? null,
|
|
153
|
+
python: detected.python
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
141
157
|
/** 状态层 `status --all`(5 s TTL;失败不污染缓存由 cache 保证)。 */
|
|
142
158
|
async function loadStatusLayer(deps, detected, refresh) {
|
|
143
159
|
const root = detected.root
|
|
144
160
|
return deps.caches.status.get(keyOf(root), refresh, () =>
|
|
145
|
-
deps.cli.statusAll(
|
|
161
|
+
deps.cli.statusAll(cliTarget(detected))
|
|
146
162
|
)
|
|
147
163
|
}
|
|
148
164
|
|
|
@@ -150,10 +166,24 @@ async function loadStatusLayer(deps, detected, refresh) {
|
|
|
150
166
|
async function loadPoolLayer(deps, detected, refresh) {
|
|
151
167
|
const root = detected.root
|
|
152
168
|
return deps.caches.pool.get(keyOf(root), refresh, () =>
|
|
153
|
-
deps.cli.poolAll(
|
|
169
|
+
deps.cli.poolAll(cliTarget(detected))
|
|
154
170
|
)
|
|
155
171
|
}
|
|
156
172
|
|
|
173
|
+
/**
|
|
174
|
+
* git 时间层(FR-5.4 修订):默认排序要的「最近动作时间 / 合并时间」。
|
|
175
|
+
*
|
|
176
|
+
* 为什么走 git 而不是引擎:`status --all` 不返回任何时间字段,唯一带时间戳的账本
|
|
177
|
+
* 插件不直读(A2)。口径与只读纪律见 `git-times.js` 头注释。
|
|
178
|
+
* 失败/非 git 项目返回 `available:false`(带原因),排序退回「重要度」——不臆造、不挂面板。
|
|
179
|
+
*/
|
|
180
|
+
async function loadGitTimes(deps, detected, refresh) {
|
|
181
|
+
// 采集器可注入(测试用假实现,避免测试去真跑 git)
|
|
182
|
+
const collect = deps.collectGitTimes ?? collectGitTimes
|
|
183
|
+
const worktree = detected.layout?.mainWorktree ?? detected.root
|
|
184
|
+
return deps.caches.gitTimes.get(keyOf(worktree), refresh, () => collect({ worktree }))
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
/** GET /orchd/api/context */
|
|
158
188
|
async function handleContext(deps, req, res, url) {
|
|
159
189
|
const startedAt = Date.now()
|
|
@@ -280,17 +310,20 @@ async function handleBoard(deps, req, res, url) {
|
|
|
280
310
|
}
|
|
281
311
|
|
|
282
312
|
const pool = await loadPoolLayer(deps, detected, refresh)
|
|
313
|
+
const gitTimes = await loadGitTimes(deps, detected, refresh)
|
|
283
314
|
|
|
284
315
|
const board = buildBoard({
|
|
285
316
|
statusJson: status.json,
|
|
286
317
|
poolJson: pool?.ok === true ? pool.json : undefined,
|
|
287
318
|
index,
|
|
288
|
-
masterPath: detected.layout?.masterPath
|
|
319
|
+
masterPath: detected.layout?.masterPath,
|
|
320
|
+
gitTimes
|
|
289
321
|
})
|
|
290
322
|
board.layers = {
|
|
291
323
|
definition: index === undefined ? 'missing' : 'ok',
|
|
292
324
|
state: 'ok',
|
|
293
|
-
pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded')
|
|
325
|
+
pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded'),
|
|
326
|
+
gitTimes: gitTimes?.available === true ? 'ok' : (gitTimes?.reason ?? 'not-loaded')
|
|
294
327
|
}
|
|
295
328
|
sendJson(res, 200, envelope({ ok: true, startedAt, data: board }))
|
|
296
329
|
} catch (error) {
|
|
@@ -333,7 +366,7 @@ async function handleTask(deps, req, res, url) {
|
|
|
333
366
|
const definition = index?.tasks?.get(taskId)
|
|
334
367
|
|
|
335
368
|
const detail = await deps.caches.detail.get(`${keyOf(detected.root)}|${taskId}`, refresh, async () =>
|
|
336
|
-
deps.cli.statusOne(
|
|
369
|
+
deps.cli.statusOne(cliTarget(detected), taskId)
|
|
337
370
|
)
|
|
338
371
|
|
|
339
372
|
// 单任务失败**不得**让整个面板进入 broken(docs/02 第 12 节末):这里返回 ok:false,
|
|
@@ -364,7 +397,7 @@ async function handleTask(deps, req, res, url) {
|
|
|
364
397
|
let reviewHistory
|
|
365
398
|
if (url.searchParams.get('review') === '1') {
|
|
366
399
|
const review = await deps.caches.review.get(`${keyOf(detected.root)}|${taskId}`, refresh, () =>
|
|
367
|
-
deps.cli.reviewShow(
|
|
400
|
+
deps.cli.reviewShow(cliTarget(detected), taskId)
|
|
368
401
|
)
|
|
369
402
|
reviewHistory = review?.ok === true ? review.json : { unavailable: true, reason: review?.reason ?? 'not-loaded' }
|
|
370
403
|
}
|
|
@@ -427,7 +460,7 @@ async function handleHistory(deps, req, res, url) {
|
|
|
427
460
|
return
|
|
428
461
|
}
|
|
429
462
|
|
|
430
|
-
const target =
|
|
463
|
+
const target = cliTarget(detected)
|
|
431
464
|
const review = await deps.caches.review.get(`${keyOf(detected.root)}|${taskId}`, refresh, () =>
|
|
432
465
|
deps.cli.reviewShow(target, taskId)
|
|
433
466
|
)
|
|
@@ -529,16 +562,19 @@ async function handleGraph(deps, req, res, url) {
|
|
|
529
562
|
}
|
|
530
563
|
|
|
531
564
|
const pool = await loadPoolLayer(deps, detected, refresh)
|
|
565
|
+
const gitTimes = await loadGitTimes(deps, detected, refresh)
|
|
532
566
|
const board = buildBoard({
|
|
533
567
|
statusJson: status.json,
|
|
534
568
|
poolJson: pool?.ok === true ? pool.json : undefined,
|
|
535
569
|
index,
|
|
536
|
-
masterPath: detected.layout?.masterPath
|
|
570
|
+
masterPath: detected.layout?.masterPath,
|
|
571
|
+
gitTimes
|
|
537
572
|
})
|
|
538
573
|
board.layers = {
|
|
539
574
|
definition: index === undefined ? 'missing' : 'ok',
|
|
540
575
|
state: 'ok',
|
|
541
|
-
pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded')
|
|
576
|
+
pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded'),
|
|
577
|
+
gitTimes: gitTimes?.available === true ? 'ok' : (gitTimes?.reason ?? 'not-loaded')
|
|
542
578
|
}
|
|
543
579
|
|
|
544
580
|
if (focus !== null && !board.tasks.some((row) => row.id === focus)) {
|
|
@@ -603,7 +639,7 @@ async function handleIdeas(deps, req, res, url) {
|
|
|
603
639
|
|
|
604
640
|
// 清单:引擎只给 title / id / status
|
|
605
641
|
const list = await deps.caches.ideas.get(keyOf(detected.root), refresh, () =>
|
|
606
|
-
deps.cli.ideasList(
|
|
642
|
+
deps.cli.ideasList(cliTarget(detected))
|
|
607
643
|
)
|
|
608
644
|
// 引擎**没有 `ideas` 子命令**(旧版本,实测 v1.5.0:argparse `invalid choice: 'ideas'`,
|
|
609
645
|
// exit=2,E007)⇒ 这不是项目坏了,而是**能力缺失**:优雅降级成「只读 IDEAS.md + 归档」,
|
|
@@ -640,20 +676,20 @@ async function handleIdeas(deps, req, res, url) {
|
|
|
640
676
|
|
|
641
677
|
const parsed = parseIdeasMarkdown(files.markdown.text ?? '')
|
|
642
678
|
// ⚠ `status` 不是「是否已立任务」的标记(引擎 rules/intake.md:在途条目按 E025
|
|
643
|
-
// 必须保持 pending,`
|
|
644
|
-
//
|
|
679
|
+
// 必须保持 pending,`covered` 只服务撞车并入)。所以用定义层做「已转任务」判定,
|
|
680
|
+
// 但**不改写 status**——派生桶单独放在 `displayStatus`(FR-13.3b / docs/03 E025 溯源)。
|
|
645
681
|
const definition = await loadDefinition(deps, detected)
|
|
646
682
|
// 引用判据 = `source` ∪ `additional_sources`(引擎 rules ④ 同判据)
|
|
647
683
|
const ideaTaskIndex = buildIdeaTaskIndex(definition?.tasks)
|
|
648
684
|
// 引擎不支持 `ideas` 时没有清单可合并,只读 Markdown(`mergeIdeasList(null, …)` 就是这条路,
|
|
649
685
|
// 条目会带上「仅存在于 IDEAS.md」的标记,界面本来就会这么显示)
|
|
650
686
|
const engineList = ideasUnsupported ? null : list.json
|
|
651
|
-
const entries =
|
|
687
|
+
const entries = attachConvertedTasks(mergeIdeasList(engineList, parsed.entries), ideaTaskIndex)
|
|
652
688
|
const summary = summarizeIdeas(entries)
|
|
653
689
|
// 归档分组:**与任务池对照**(用户口径 / FR-13.7)。
|
|
654
690
|
// 不按归档件里的字面 status 分组——引擎明确归档不改写 status(语义是「已消费」),
|
|
655
691
|
// 实测 312/373 条仍写着 pending,其中 287 条在任务池里有引用。
|
|
656
|
-
//
|
|
692
|
+
// 「历史已放弃」用扫描时收下的归档件自身 status(`statusById`),不能用 IDEAS.md 的状态表。
|
|
657
693
|
const archived = classifyArchive(files.archive, ideaTaskIndex)
|
|
658
694
|
// ⚠ 两个集合必须各自取「本层」的 id:曾误用合并后的 entries 当作引擎清单,
|
|
659
695
|
// 导致只存在于 Markdown 的条目也被算成「引擎也有」,mismatches 恒为空。
|
|
@@ -679,25 +715,26 @@ async function handleIdeas(deps, req, res, url) {
|
|
|
679
715
|
unsupportedHint: ideasUnsupported ? 'engine-ideas-command-missing' : null,
|
|
680
716
|
/**
|
|
681
717
|
* 活跃层的**三分区**(用户口径):
|
|
682
|
-
* `total = active + converted +
|
|
683
|
-
* 其中 `active = 论证中 + 待补充 +
|
|
718
|
+
* `total = active + converted + covered`
|
|
719
|
+
* 其中 `active = 论证中 + 待补充 + 待拆解`;
|
|
720
|
+
* `converted`(定义层有任务引用)与 `covered`(撞车并入既有任务)都不计入活跃。
|
|
684
721
|
*/
|
|
685
722
|
active: summary.active,
|
|
686
723
|
/** 活跃层内已转任务的条数(由任务池引用推出,不是归档计数) */
|
|
687
724
|
converted: summary.converted,
|
|
688
|
-
/**
|
|
689
|
-
|
|
725
|
+
/** 活跃层内已覆盖(撞车并入既有任务,待 close 归档) */
|
|
726
|
+
covered: summary.covered,
|
|
690
727
|
archivedCount: files.archive.count,
|
|
691
728
|
/**
|
|
692
729
|
* 归档分组(FR-13.7):**与任务池对照**判定「已转任务」,不按归档件里的字面 status。
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
* untasked
|
|
696
|
-
* noId
|
|
730
|
+
* converted 任务池(source ∪ additional_sources)引用了该 id
|
|
731
|
+
* legacyDropped 未被引用且字面 status = dropped(旧词表遗留历史值)
|
|
732
|
+
* untasked 未被引用(人工裁决离场,含 close 归档的 covered/pending/study)
|
|
733
|
+
* noId 小节缺 `- id:`,无从对照(存量数据问题,显式计数不静默丢)
|
|
697
734
|
* `byLiteralStatus` 仅作诊断:引擎明确归档不改写 status,不是分组依据。
|
|
698
735
|
*/
|
|
699
736
|
archived: { ...archived, byLiteralStatus: files.archive.byStatus ?? {} },
|
|
700
|
-
//
|
|
737
|
+
// 统计条要渲染的芯片集合:4 个引擎状态 + 派生桶 converted,保证三分区在界面上闭合(FR-13.6)
|
|
701
738
|
activeStatuses: ideaStripStatuses(),
|
|
702
739
|
layers: {
|
|
703
740
|
list: ideasUnsupported ? 'unsupported' : 'ok',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-orchd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Read-only orchd panel for the DSH right sidebar: task board, dependency graph, ideas and review timeline for the project of the current session. · 把 orchd 项目状态带进 DSH 右侧栏的只读面板。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|