@goodandready/dsh-moa 0.2.43 → 0.2.45
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/lib/client.js +1 -1
- package/lib/file-workspace.js +29 -31
- package/lib/history.js +1 -0
- package/lib/moa-budget.js +138 -30
- package/lib/moa-candidates.js +0 -1
- package/lib/moa-llm.js +11 -0
- package/lib/moa-runner.js +119 -125
- package/lib/moa-test-gate.js +193 -12
- package/lib/routes.js +48 -52
- package/package.json +1 -1
package/lib/client.js
CHANGED
package/lib/file-workspace.js
CHANGED
|
@@ -78,16 +78,6 @@ export function resolveCandidateFolder(baseDir, candidateTarget, options = {}) {
|
|
|
78
78
|
const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
|
|
79
79
|
const rawRunId = typeof options === 'string' ? options : options?.runId
|
|
80
80
|
const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
|
|
81
|
-
const moaPath = path.resolve(baseDir, '.moa')
|
|
82
|
-
try {
|
|
83
|
-
const lst = fsSync.lstatSync(moaPath)
|
|
84
|
-
if (lst.isSymbolicLink()) {
|
|
85
|
-
const realMoa = fsSync.realpathSync(moaPath), realBase = fsSync.realpathSync(path.resolve(baseDir))
|
|
86
|
-
if (realMoa !== realBase && !realMoa.startsWith(realBase + path.sep)) fsSync.unlinkSync(moaPath)
|
|
87
|
-
}
|
|
88
|
-
} catch (err) {
|
|
89
|
-
if (err?.code !== 'ENOENT') throw err
|
|
90
|
-
}
|
|
91
81
|
const moaRoot = assertPathContained(baseDir, '.moa')
|
|
92
82
|
const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
|
|
93
83
|
return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
|
|
@@ -264,6 +254,18 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
|
|
|
264
254
|
}
|
|
265
255
|
}
|
|
266
256
|
|
|
257
|
+
export function isLoopbackUrl(urlStr) {
|
|
258
|
+
if (!urlStr || typeof urlStr !== 'string') return false
|
|
259
|
+
try {
|
|
260
|
+
const u = new URL(urlStr)
|
|
261
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
|
|
262
|
+
const h = u.hostname.replace(/^\[|\]$/g, '')
|
|
263
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h)
|
|
264
|
+
} catch {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
267
269
|
/**
|
|
268
270
|
* Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
|
|
269
271
|
* before candidate promotion overwrites files in the project workspace.
|
|
@@ -271,8 +273,7 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
|
|
|
271
273
|
export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
|
|
272
274
|
if (!baseDir || options.checkpoint === false) return null
|
|
273
275
|
if (typeof options.checkpointFn === 'function') {
|
|
274
|
-
|
|
275
|
-
return res || null
|
|
276
|
+
return (await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))) || null
|
|
276
277
|
}
|
|
277
278
|
|
|
278
279
|
const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
|
|
@@ -289,6 +290,11 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
|
|
|
289
290
|
return null
|
|
290
291
|
}
|
|
291
292
|
|
|
293
|
+
if (options.timeMachineUrl && !isLoopbackUrl(options.timeMachineUrl)) {
|
|
294
|
+
logger.warn(`[dsh-moa] Blocked non-loopback timeMachineUrl: ${options.timeMachineUrl}`)
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
|
|
292
298
|
const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
|
|
293
299
|
const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
|
|
294
300
|
const res = await fetch(endpoint, {
|
|
@@ -337,9 +343,7 @@ async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { ke
|
|
|
337
343
|
try {
|
|
338
344
|
const entries = await getFilesRecursively(winningDir)
|
|
339
345
|
if (entries.length === 0) {
|
|
340
|
-
if (!options.keepMoa) {
|
|
341
|
-
await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
|
|
342
|
-
}
|
|
346
|
+
if (!options.keepMoa) await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
|
|
343
347
|
return []
|
|
344
348
|
}
|
|
345
349
|
|
|
@@ -500,8 +504,7 @@ export async function readCandidateFiles(baseDir, candidateTarget, options = {})
|
|
|
500
504
|
if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
|
|
501
505
|
let targetDir
|
|
502
506
|
try {
|
|
503
|
-
|
|
504
|
-
targetDir = resolved.candidateDir
|
|
507
|
+
targetDir = resolveCandidateFolder(baseDir, candidateTarget, options).candidateDir
|
|
505
508
|
} catch {
|
|
506
509
|
return []
|
|
507
510
|
}
|
|
@@ -518,9 +521,7 @@ export async function readCandidateFiles(baseDir, candidateTarget, options = {})
|
|
|
518
521
|
const st = await fs.lstat(fullPath)
|
|
519
522
|
if (st.isSymbolicLink()) {
|
|
520
523
|
const realFile = await fs.realpath(fullPath)
|
|
521
|
-
if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep))
|
|
522
|
-
continue
|
|
523
|
-
}
|
|
524
|
+
if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) continue
|
|
524
525
|
}
|
|
525
526
|
const content = await fs.readFile(fullPath, 'utf8')
|
|
526
527
|
result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
|
|
@@ -555,25 +556,22 @@ export function computeLineDiff(oldText = '', newText = '') {
|
|
|
555
556
|
newEnd--
|
|
556
557
|
}
|
|
557
558
|
|
|
558
|
-
const prefixItems =
|
|
559
|
-
for (let k = 0; k < start; k++) prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
|
|
560
|
-
|
|
561
|
-
const suffixItems = []
|
|
559
|
+
const prefixItems = oldLines.slice(0, start).map((line, k) => ({ type: 'same', line, oldNum: k + 1, newNum: k + 1 }))
|
|
562
560
|
const suffixLen = (n - 1) - oldEnd
|
|
563
|
-
|
|
564
|
-
suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k })
|
|
565
|
-
}
|
|
561
|
+
const suffixItems = oldLines.slice(oldEnd + 1, oldEnd + 1 + suffixLen).map((line, k) => ({ type: 'same', line, oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k }))
|
|
566
562
|
|
|
567
563
|
const subOld = oldLines.slice(start, oldEnd + 1)
|
|
568
564
|
const subNew = newLines.slice(start, newEnd + 1)
|
|
569
565
|
const subN = subOld.length, subM = subNew.length
|
|
570
566
|
const middleItems = []
|
|
571
567
|
|
|
572
|
-
if (subN === 0)
|
|
573
|
-
else if (subM === 0)
|
|
568
|
+
if (subN === 0) middleItems.push(...subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 })))
|
|
569
|
+
else if (subM === 0) middleItems.push(...subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 })))
|
|
574
570
|
else if (subN > 2000 || subM > 2000) {
|
|
575
|
-
|
|
576
|
-
|
|
571
|
+
middleItems.push(
|
|
572
|
+
...subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 })),
|
|
573
|
+
...subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 })),
|
|
574
|
+
)
|
|
577
575
|
} else {
|
|
578
576
|
const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
|
|
579
577
|
for (let i = 0; i < subN; i++) {
|
package/lib/history.js
CHANGED
package/lib/moa-budget.js
CHANGED
|
@@ -29,6 +29,72 @@ export function estimateCandidateRunCost(slots = [], prices = {}, promptTokens =
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Estimates downstream synthesis costs for Round 2 and Judge panel.
|
|
34
|
+
*/
|
|
35
|
+
export function estimateDownstreamCost(aggregator, judgeModels = [], numCandidates = 1, prices = {}, expectedOutputTokens = 2048, promptTokens = 500, peerCritiqueEnabled = false) {
|
|
36
|
+
let downstream = 0
|
|
37
|
+
const aggInput = promptTokens + (expectedOutputTokens * numCandidates) + 200
|
|
38
|
+
const aggOutput = Math.min(expectedOutputTokens, 2048)
|
|
39
|
+
|
|
40
|
+
if (aggregator?.provider && aggregator?.model) {
|
|
41
|
+
const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: aggOutput }, prices)
|
|
42
|
+
downstream += aggCost?.costUsd || 0
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(judgeModels) && judgeModels.length > 0) {
|
|
45
|
+
for (const jm of judgeModels) {
|
|
46
|
+
if (jm?.provider && jm?.model) {
|
|
47
|
+
const jCost = estimateTokenCost(jm, { inputTokens: aggInput, outputTokens: aggOutput }, prices)
|
|
48
|
+
downstream += jCost?.costUsd || 0
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return downstream
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function estimateRound2Cost(candidates = [], prices = {}, expectedOutputTokens = 2048, promptTokens = 500) {
|
|
56
|
+
let r2Total = 0
|
|
57
|
+
const numCandidates = candidates.length
|
|
58
|
+
if (numCandidates <= 1) return 0
|
|
59
|
+
// Each candidate reviews proposals from opponents (numCandidates - 1)
|
|
60
|
+
const r2Input = promptTokens + (expectedOutputTokens * (numCandidates - 1)) + 150
|
|
61
|
+
for (const c of candidates) {
|
|
62
|
+
const slot = c.slot || { provider: c.provider, model: c.model }
|
|
63
|
+
const cost = estimateTokenCost(slot, { inputTokens: r2Input, outputTokens: expectedOutputTokens }, prices)?.costUsd || 0
|
|
64
|
+
r2Total += cost
|
|
65
|
+
}
|
|
66
|
+
return r2Total
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Phase-by-phase runtime budget check (#158).
|
|
71
|
+
* Checks if current spent + next phase estimate would exceed budget.
|
|
72
|
+
*/
|
|
73
|
+
export function checkPhaseBudget({
|
|
74
|
+
phaseName = '',
|
|
75
|
+
currentSpentUsd = 0,
|
|
76
|
+
nextPhaseCostUsd = 0,
|
|
77
|
+
maxBudgetUsd = 0,
|
|
78
|
+
action = 'abort',
|
|
79
|
+
enabled = true,
|
|
80
|
+
}) {
|
|
81
|
+
if (!enabled || typeof maxBudgetUsd !== 'number' || maxBudgetUsd <= 0) {
|
|
82
|
+
return { allowed: true }
|
|
83
|
+
}
|
|
84
|
+
const projectedTotal = Number((currentSpentUsd + nextPhaseCostUsd).toFixed(6))
|
|
85
|
+
if (projectedTotal <= maxBudgetUsd) {
|
|
86
|
+
return { allowed: true, projectedTotal }
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
allowed: false,
|
|
90
|
+
action,
|
|
91
|
+
currentSpentUsd,
|
|
92
|
+
projectedTotal,
|
|
93
|
+
maxBudgetUsd,
|
|
94
|
+
reason: `Projected cost ($${projectedTotal.toFixed(4)}) before ${phaseName} exceeds max budget ($${maxBudgetUsd.toFixed(4)}). Current spent: $${currentSpentUsd.toFixed(4)}.`,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
32
98
|
/**
|
|
33
99
|
* Applies budget guardrails before initiating parallel candidate execution.
|
|
34
100
|
* Evaluates candidate runs, peer critique (Round 2) and downstream synthesis phases.
|
|
@@ -40,6 +106,7 @@ export function applyBudgetGuardrails({
|
|
|
40
106
|
action = 'trim', // 'trim' | 'abort'
|
|
41
107
|
prices = {},
|
|
42
108
|
promptLength = 1000,
|
|
109
|
+
projectContext = '',
|
|
43
110
|
messages = [],
|
|
44
111
|
maxTokens = 2048,
|
|
45
112
|
peerCritiqueEnabled = false,
|
|
@@ -55,8 +122,11 @@ export function applyBudgetGuardrails({
|
|
|
55
122
|
}
|
|
56
123
|
}
|
|
57
124
|
|
|
58
|
-
// Calculate full input tokens from userPrompt length + messages context
|
|
125
|
+
// Calculate full input tokens from userPrompt length + messages context + system prompt overhead
|
|
59
126
|
let contextChars = Number(promptLength) || 1000
|
|
127
|
+
if (typeof projectContext === 'string' && projectContext.length > 0 && !String(promptLength).includes(String(projectContext.length))) {
|
|
128
|
+
contextChars += projectContext.length
|
|
129
|
+
}
|
|
60
130
|
if (Array.isArray(messages) && messages.length > 0) {
|
|
61
131
|
for (const m of messages) {
|
|
62
132
|
if (typeof m?.content === 'string') {
|
|
@@ -68,36 +138,27 @@ export function applyBudgetGuardrails({
|
|
|
68
138
|
}
|
|
69
139
|
}
|
|
70
140
|
}
|
|
71
|
-
|
|
141
|
+
// System prompt and framing template overhead (~600 chars = ~150 tokens)
|
|
142
|
+
const promptTokens = Math.max(50, Math.round((contextChars + 600) / 4))
|
|
72
143
|
const expectedOutputTokens = typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 2048
|
|
73
144
|
|
|
74
145
|
// Base candidate cost per slot (Round 1)
|
|
75
146
|
const { totalEstimatedUsd, slotEstimates } = estimateCandidateRunCost(references, prices, promptTokens, expectedOutputTokens)
|
|
76
147
|
|
|
77
|
-
//
|
|
148
|
+
// Calculate downstream cost with specific candidate count
|
|
78
149
|
function calcDownstreamUsd(numCandidates) {
|
|
79
|
-
|
|
80
|
-
if (aggregator?.provider && aggregator?.model) {
|
|
81
|
-
const aggInput = promptTokens + expectedOutputTokens * numCandidates
|
|
82
|
-
const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: expectedOutputTokens }, prices)
|
|
83
|
-
downstream += aggCost?.costUsd || 0
|
|
84
|
-
}
|
|
85
|
-
if (Array.isArray(judgeModels) && judgeModels.length > 0) {
|
|
86
|
-
for (const jm of judgeModels) {
|
|
87
|
-
if (jm?.provider && jm?.model) {
|
|
88
|
-
const jInput = promptTokens + expectedOutputTokens * numCandidates
|
|
89
|
-
const jCost = estimateTokenCost(jm, { inputTokens: jInput, outputTokens: expectedOutputTokens }, prices)
|
|
90
|
-
downstream += jCost?.costUsd || 0
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return downstream
|
|
150
|
+
return estimateDownstreamCost(aggregator, judgeModels, numCandidates, prices, expectedOutputTokens, promptTokens, peerCritiqueEnabled)
|
|
95
151
|
}
|
|
96
152
|
|
|
97
|
-
//
|
|
98
|
-
|
|
153
|
+
// Calculate Round 2 (Peer Critique) cost if enabled
|
|
154
|
+
function calcRound2Usd(candidates) {
|
|
155
|
+
if (!peerCritiqueEnabled || candidates.length <= 1) return 0
|
|
156
|
+
return estimateRound2Cost(candidates, prices, expectedOutputTokens, promptTokens)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const r2Usd = calcRound2Usd(references)
|
|
99
160
|
const downstreamUsd = calcDownstreamUsd(references.length)
|
|
100
|
-
const fullEstimatedUsd = Number((
|
|
161
|
+
const fullEstimatedUsd = Number((totalEstimatedUsd + r2Usd + downstreamUsd).toFixed(6))
|
|
101
162
|
|
|
102
163
|
if (fullEstimatedUsd <= maxBudgetUsd) {
|
|
103
164
|
return {
|
|
@@ -120,22 +181,25 @@ export function applyBudgetGuardrails({
|
|
|
120
181
|
}
|
|
121
182
|
|
|
122
183
|
// Action is 'trim': Sort by estimated cost ascending (cheapest first)
|
|
123
|
-
// Factor downstream synthesis cost into candidate selection (#158)
|
|
184
|
+
// Factor downstream synthesis cost and peer critique cost into candidate selection (#158)
|
|
124
185
|
const sorted = [...slotEstimates].sort((a, b) => a.costUsd - b.costUsd)
|
|
125
186
|
const kept = []
|
|
126
|
-
let cumulativeCandidateCost = 0
|
|
127
187
|
|
|
128
188
|
for (const item of sorted) {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
189
|
+
const candidatePool = [...kept, item.slot]
|
|
190
|
+
const candR1 = candidatePool.reduce((acc, c) => {
|
|
191
|
+
const e = slotEstimates.find((s) => s.slot === c)
|
|
192
|
+
return acc + (e?.costUsd || 0)
|
|
193
|
+
}, 0)
|
|
194
|
+
const candR2 = calcRound2Usd(candidatePool)
|
|
195
|
+
const nextDownstream = calcDownstreamUsd(candidatePool.length)
|
|
196
|
+
if (candR1 + candR2 + nextDownstream <= maxBudgetUsd) {
|
|
132
197
|
kept.push(item.slot)
|
|
133
|
-
cumulativeCandidateCost += candidateCost
|
|
134
198
|
}
|
|
135
199
|
}
|
|
136
200
|
|
|
137
201
|
if (kept.length === 0) {
|
|
138
|
-
const minCandidateCost =
|
|
202
|
+
const minCandidateCost = sorted[0]?.costUsd || 0
|
|
139
203
|
const minTotal = minCandidateCost + calcDownstreamUsd(1)
|
|
140
204
|
return {
|
|
141
205
|
allowed: false,
|
|
@@ -149,7 +213,13 @@ export function applyBudgetGuardrails({
|
|
|
149
213
|
}
|
|
150
214
|
}
|
|
151
215
|
|
|
152
|
-
const
|
|
216
|
+
const finalR1 = kept.reduce((acc, c) => {
|
|
217
|
+
const e = slotEstimates.find((s) => s.slot === c)
|
|
218
|
+
return acc + (e?.costUsd || 0)
|
|
219
|
+
}, 0)
|
|
220
|
+
const finalR2 = calcRound2Usd(kept)
|
|
221
|
+
const finalEstimatedUsd = Number((finalR1 + finalR2 + calcDownstreamUsd(kept.length)).toFixed(6))
|
|
222
|
+
|
|
153
223
|
return {
|
|
154
224
|
allowed: true,
|
|
155
225
|
references: kept,
|
|
@@ -161,3 +231,41 @@ export function applyBudgetGuardrails({
|
|
|
161
231
|
reason: `Trimmed candidate pool from ${references.length} to ${kept.length} models to stay within budget ($${maxBudgetUsd.toFixed(4)}).`,
|
|
162
232
|
}
|
|
163
233
|
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Constructs a structured budget abort failure payload (#158).
|
|
237
|
+
*/
|
|
238
|
+
export function createBudgetAbortPayload({
|
|
239
|
+
reason,
|
|
240
|
+
primaryJudge,
|
|
241
|
+
referenceOutputs = [],
|
|
242
|
+
currentSpentUsd = 0,
|
|
243
|
+
preset = {},
|
|
244
|
+
isRefinement = false,
|
|
245
|
+
isFastMode = false,
|
|
246
|
+
collectedCtx = {},
|
|
247
|
+
startTime = Date.now(),
|
|
248
|
+
}) {
|
|
249
|
+
const aggLabel = (primaryJudge?.provider && primaryJudge?.model) ? `${primaryJudge.provider}:${primaryJudge.model}` : "none";
|
|
250
|
+
return {
|
|
251
|
+
kind: "failure",
|
|
252
|
+
content: `🛑 Budget Guardrail Abort: ${reason}`,
|
|
253
|
+
aggregator: aggLabel,
|
|
254
|
+
references: referenceOutputs,
|
|
255
|
+
presetName: preset?.name || "default",
|
|
256
|
+
isRefinement,
|
|
257
|
+
isFastMode,
|
|
258
|
+
winningIndex: 0,
|
|
259
|
+
winnerModel: "none",
|
|
260
|
+
promotedFiles: [],
|
|
261
|
+
usage: {
|
|
262
|
+
totalTokens: referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0),
|
|
263
|
+
totalCostUsd: Number(currentSpentUsd.toFixed(5)),
|
|
264
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
265
|
+
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
266
|
+
},
|
|
267
|
+
skippedFiles: collectedCtx?.skippedFiles || 0,
|
|
268
|
+
skippedList: collectedCtx?.skippedList || [],
|
|
269
|
+
durationMs: Date.now() - startTime,
|
|
270
|
+
};
|
|
271
|
+
}
|
package/lib/moa-candidates.js
CHANGED
|
@@ -122,7 +122,6 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
122
122
|
})
|
|
123
123
|
reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
|
|
124
124
|
}, timeoutMs)
|
|
125
|
-
candidateTimer?.unref?.()
|
|
126
125
|
})
|
|
127
126
|
|
|
128
127
|
const res = await Promise.race([callPromise, timeoutPromise])
|
package/lib/moa-llm.js
CHANGED
|
@@ -139,3 +139,14 @@ export function createLlmCaller(ctx) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
export function callConfigEquals(a, b) {
|
|
145
|
+
if (!a || !b) return false
|
|
146
|
+
if (a.provider !== b.provider) return false
|
|
147
|
+
if (a.model !== b.model) return false
|
|
148
|
+
if (a.temperature !== b.temperature) return false
|
|
149
|
+
if (a.maxTokens !== b.maxTokens) return false
|
|
150
|
+
if (a.reasoningEffort !== b.reasoningEffort) return false
|
|
151
|
+
return true
|
|
152
|
+
}
|
package/lib/moa-runner.js
CHANGED
|
@@ -9,7 +9,7 @@ import { parseWinnerIndex, parseRecommendedAssembler } from './moa-parser.js'
|
|
|
9
9
|
import { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
|
|
10
10
|
import { executeTestGateForCandidates } from './moa-test-gate.js'
|
|
11
11
|
import { executeMultiJudgePanel, buildCompositeBlockDirectives } from './moa-multi-judge.js'
|
|
12
|
-
import { applyBudgetGuardrails } from './moa-budget.js'
|
|
12
|
+
import { applyBudgetGuardrails, checkPhaseBudget, estimateRound2Cost, estimateDownstreamCost, createBudgetAbortPayload } from './moa-budget.js'
|
|
13
13
|
import { extractPriorTurnBaseline, pruneMultiTurnMessages } from './moa-context.js'
|
|
14
14
|
import { generateMoABenchmarkReport } from './moa-report.js'
|
|
15
15
|
// Re-exports for consumers & backward compatibility
|
|
@@ -60,14 +60,33 @@ export async function runMoAPipeline({
|
|
|
60
60
|
const aggTimeoutSec = typeof preset?.aggregator_timeout_sec === 'number' ? preset.aggregator_timeout_sec : 180
|
|
61
61
|
const isBlindEvaluation = Boolean(preset?.blind_evaluation), isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
|
|
62
62
|
const allowCandidateOverride = Boolean(preset?.allow_candidate_override)
|
|
63
|
-
//
|
|
63
|
+
// 1. Collect project context for refinement tasks
|
|
64
|
+
const collectedCtx = await collectProjectContext(cwd, 16000)
|
|
65
|
+
const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
|
|
66
|
+
let projectContext = ''
|
|
67
|
+
if (collectedCtx?.skippedFiles > 0 && typeof onProgress === 'function') {
|
|
68
|
+
const preview = collectedCtx.skippedList.slice(0, 3).join(', ')
|
|
69
|
+
const more = collectedCtx.skippedList.length > 3 ? ` (+${collectedCtx.skippedList.length - 3})` : ''
|
|
70
|
+
onProgress(`⚠️ *Workspace scan note: ${collectedCtx.skippedFiles} file(s) skipped (${preview}${more})*\n`)
|
|
71
|
+
}
|
|
72
|
+
if (isRefinement) {
|
|
73
|
+
if (typeof onProgress === 'function') {
|
|
74
|
+
onProgress('🔍 *Reading project files for refinement context...*\n')
|
|
75
|
+
}
|
|
76
|
+
projectContext = formatProjectContext(collectedCtx.files, {
|
|
77
|
+
skippedFiles: collectedCtx.skippedFiles,
|
|
78
|
+
skippedList: collectedCtx.skippedList,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
// 2. Budget Guardrail check
|
|
64
82
|
const budgetCheck = applyBudgetGuardrails({
|
|
65
83
|
references: referenceModels,
|
|
66
84
|
enabled: preset?.budget_guard_enabled,
|
|
67
85
|
maxBudgetUsd: preset?.max_budget_usd,
|
|
68
86
|
action: preset?.budget_action || 'trim',
|
|
69
87
|
prices,
|
|
70
|
-
promptLength: userPrompt?.length || 1000,
|
|
88
|
+
promptLength: (userPrompt?.length || 1000) + (projectContext ? projectContext.length : 0),
|
|
89
|
+
projectContext,
|
|
71
90
|
messages,
|
|
72
91
|
maxTokens,
|
|
73
92
|
peerCritiqueEnabled: isPeerCritiqueEnabled,
|
|
@@ -75,45 +94,12 @@ export async function runMoAPipeline({
|
|
|
75
94
|
judgeModels: preset?.multi_judge_enabled ? preset?.judge_models : [],
|
|
76
95
|
})
|
|
77
96
|
if (!budgetCheck.allowed) {
|
|
78
|
-
return {
|
|
79
|
-
kind: 'failure',
|
|
80
|
-
content: `🛑 Budget Guardrail Abort: ${budgetCheck.reason}`,
|
|
81
|
-
aggregator: slotLabel(primaryJudge),
|
|
82
|
-
references: [],
|
|
83
|
-
presetName: preset?.name || 'default',
|
|
84
|
-
isRefinement: false,
|
|
85
|
-
isFastMode,
|
|
86
|
-
winningIndex: 0,
|
|
87
|
-
winnerModel: 'none',
|
|
88
|
-
promotedFiles: [],
|
|
89
|
-
usage: { totalTokens: 0, totalCostUsd: 0, candidates: [], aggregator: { totalTokens: 0, costUsd: 0 } },
|
|
90
|
-
skippedFiles: 0,
|
|
91
|
-
skippedList: [],
|
|
92
|
-
durationMs: Date.now() - startTime,
|
|
93
|
-
}
|
|
97
|
+
return createBudgetAbortPayload({ reason: budgetCheck.reason, primaryJudge, referenceOutputs: [], currentSpentUsd: 0, preset, isRefinement, isFastMode, collectedCtx, startTime })
|
|
94
98
|
}
|
|
95
99
|
const effectiveReferences = budgetCheck.references
|
|
96
100
|
if (budgetCheck.action === 'trim' && typeof onProgress === 'function') {
|
|
97
101
|
onProgress(`✂️ *${budgetCheck.reason}*\n`)
|
|
98
102
|
}
|
|
99
|
-
// 2. Collect project context for refinement tasks
|
|
100
|
-
const collectedCtx = await collectProjectContext(cwd, 16000)
|
|
101
|
-
const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
|
|
102
|
-
let projectContext = ''
|
|
103
|
-
if (collectedCtx?.skippedFiles > 0 && typeof onProgress === 'function') {
|
|
104
|
-
const preview = collectedCtx.skippedList.slice(0, 3).join(', ')
|
|
105
|
-
const more = collectedCtx.skippedList.length > 3 ? ` (+${collectedCtx.skippedList.length - 3})` : ''
|
|
106
|
-
onProgress(`⚠️ *Workspace scan note: ${collectedCtx.skippedFiles} file(s) skipped (${preview}${more})*\n`)
|
|
107
|
-
}
|
|
108
|
-
if (isRefinement) {
|
|
109
|
-
if (typeof onProgress === 'function') {
|
|
110
|
-
onProgress('🔍 *Reading project files for refinement context...*\n')
|
|
111
|
-
}
|
|
112
|
-
projectContext = formatProjectContext(collectedCtx.files, {
|
|
113
|
-
skippedFiles: collectedCtx.skippedFiles,
|
|
114
|
-
skippedList: collectedCtx.skippedList,
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
103
|
// 3. Build prompts & Feature 8 multi-turn context
|
|
118
104
|
const priorTurnBaseline = preset?.multi_turn_enabled !== false ? extractPriorTurnBaseline(messages) : null
|
|
119
105
|
const prunedHistory = preset?.multi_turn_enabled !== false ? pruneMultiTurnMessages(messages) : messages
|
|
@@ -152,26 +138,14 @@ export async function runMoAPipeline({
|
|
|
152
138
|
const successfulRefs = referenceOutputs.filter((r) => r.ok)
|
|
153
139
|
if (successfulRefs.length === 0) {
|
|
154
140
|
const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
|
|
141
|
+
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0)
|
|
142
|
+
const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0)).toFixed(5))
|
|
155
143
|
return {
|
|
156
|
-
kind: 'failure',
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
isRefinement,
|
|
162
|
-
isFastMode,
|
|
163
|
-
winningIndex: 0,
|
|
164
|
-
winnerModel: 'none',
|
|
165
|
-
promotedFiles: [],
|
|
166
|
-
usage: {
|
|
167
|
-
totalTokens: 0,
|
|
168
|
-
totalCostUsd: 0,
|
|
169
|
-
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
170
|
-
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
171
|
-
},
|
|
172
|
-
skippedFiles: collectedCtx?.skippedFiles || 0,
|
|
173
|
-
skippedList: collectedCtx?.skippedList || [],
|
|
174
|
-
durationMs: Date.now() - startTime,
|
|
144
|
+
kind: 'failure', content: `⚠️ All advisor models (${referenceOutputs.length}) failed: ${reasons}`,
|
|
145
|
+
aggregator: slotLabel(primaryJudge), references: referenceOutputs, presetName: preset?.name || 'default',
|
|
146
|
+
isRefinement, isFastMode, winningIndex: 0, winnerModel: 'none', promotedFiles: [],
|
|
147
|
+
usage: { totalTokens, totalCostUsd, candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })), aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 } },
|
|
148
|
+
skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs: Date.now() - startTime,
|
|
175
149
|
}
|
|
176
150
|
}
|
|
177
151
|
// 5. Extract file blocks & write candidate workspaces
|
|
@@ -247,25 +221,11 @@ export async function runMoAPipeline({
|
|
|
247
221
|
logger.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
|
|
248
222
|
}
|
|
249
223
|
return {
|
|
250
|
-
kind: 'questions',
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
isRefinement,
|
|
256
|
-
isFastMode,
|
|
257
|
-
winningIndex: 0,
|
|
258
|
-
winnerModel: 'none',
|
|
259
|
-
promotedFiles: [],
|
|
260
|
-
usage: {
|
|
261
|
-
totalTokens,
|
|
262
|
-
totalCostUsd,
|
|
263
|
-
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
264
|
-
aggregator: qUsage,
|
|
265
|
-
},
|
|
266
|
-
skippedFiles: collectedCtx?.skippedFiles || 0,
|
|
267
|
-
skippedList: collectedCtx?.skippedList || [],
|
|
268
|
-
durationMs,
|
|
224
|
+
kind: 'questions', content: questionsContent, aggregator: slotLabel(primaryJudge),
|
|
225
|
+
references: referenceOutputs, presetName: preset?.name || 'default', isRefinement, isFastMode,
|
|
226
|
+
winningIndex: 0, winnerModel: 'none', promotedFiles: [],
|
|
227
|
+
usage: { totalTokens, totalCostUsd, candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })), aggregator: qUsage },
|
|
228
|
+
skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs,
|
|
269
229
|
}
|
|
270
230
|
}
|
|
271
231
|
|
|
@@ -284,9 +244,40 @@ export async function runMoAPipeline({
|
|
|
284
244
|
} else if (!allowCandidateOverride) {
|
|
285
245
|
await cleanMoaWorkspaces(cwd, { runId })
|
|
286
246
|
}
|
|
287
|
-
if (
|
|
247
|
+
if (promoteErr) {
|
|
288
248
|
if (!allowCandidateOverride) await cleanMoaWorkspaces(cwd, { runId })
|
|
289
|
-
|
|
249
|
+
if (signal?.aborted || promoteErr?.message?.includes('aborted') || promoteErr?.name === 'AbortError' || promoteErr?.message === 'Turn aborted') {
|
|
250
|
+
return { error: new Error('Turn aborted') }
|
|
251
|
+
}
|
|
252
|
+
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0)
|
|
253
|
+
const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0)).toFixed(5))
|
|
254
|
+
const durationMs = Date.now() - startTime
|
|
255
|
+
try {
|
|
256
|
+
await recordMoaRunAsync({
|
|
257
|
+
id: runId, cwd, prompt: userPrompt, preset: preset?.name || 'default',
|
|
258
|
+
isRefinement, isFastMode: true, candidates: candidatesForHistory(referenceOutputs),
|
|
259
|
+
aggregator: null, winnerIndex: 0, winnerModel: 'none',
|
|
260
|
+
promotedFiles: [], totalTokens, totalCostUsd, durationMs,
|
|
261
|
+
error: promoteErr?.message || String(promoteErr),
|
|
262
|
+
}, historyFilePath || undefined)
|
|
263
|
+
} catch (histErr) {
|
|
264
|
+
logger.warn('[dsh-moa] Failed to record fast synthesis failure in history:', histErr?.message || histErr)
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
kind: 'failure', error: promoteErr,
|
|
268
|
+
content: '🛑 Checkpoint or Promotion Failure: ' + (promoteErr?.message || String(promoteErr)),
|
|
269
|
+
aggregator: 'Fast Mode (Direct)', references: referenceOutputs,
|
|
270
|
+
presetName: preset?.name || 'default', isRefinement, isFastMode: true,
|
|
271
|
+
winningIndex: 0, winnerModel: 'none', promotedFiles: [], runId,
|
|
272
|
+
usage: {
|
|
273
|
+
totalTokens, totalCostUsd,
|
|
274
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
275
|
+
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
276
|
+
},
|
|
277
|
+
skippedFiles: collectedCtx?.skippedFiles || 0,
|
|
278
|
+
skippedList: collectedCtx?.skippedList || [],
|
|
279
|
+
durationMs,
|
|
280
|
+
}
|
|
290
281
|
}
|
|
291
282
|
|
|
292
283
|
const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex: single.index })
|
|
@@ -306,18 +297,11 @@ export async function runMoAPipeline({
|
|
|
306
297
|
}
|
|
307
298
|
|
|
308
299
|
return {
|
|
309
|
-
kind: 'synthesis', content: single.text, aggregator: 'Fast Mode (Direct)',
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
usage: {
|
|
315
|
-
totalTokens, totalCostUsd,
|
|
316
|
-
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
317
|
-
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
318
|
-
},
|
|
319
|
-
skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [],
|
|
320
|
-
durationMs,
|
|
300
|
+
kind: 'synthesis', content: single.text, aggregator: 'Fast Mode (Direct)', references: referenceOutputs,
|
|
301
|
+
presetName: preset?.name || 'default', isRefinement, isFastMode: true, winningIndex: single.index,
|
|
302
|
+
winnerModel: single.label, promotedFiles, runId, ...(livePreview ? { liveCanvas: livePreview } : {}),
|
|
303
|
+
usage: { totalTokens, totalCostUsd, candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })), aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 } },
|
|
304
|
+
skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs,
|
|
321
305
|
}
|
|
322
306
|
}
|
|
323
307
|
|
|
@@ -327,7 +311,29 @@ export async function runMoAPipeline({
|
|
|
327
311
|
}
|
|
328
312
|
|
|
329
313
|
// 7b. Consilium Round 2 (Peer Critique) if enabled
|
|
330
|
-
|
|
314
|
+
let runRound2 = isPeerCritiqueEnabled && successfulRefs.length > 1
|
|
315
|
+
if (runRound2 && preset?.budget_guard_enabled && preset?.max_budget_usd > 0) {
|
|
316
|
+
const currentSpent = referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0)
|
|
317
|
+
const r2Next = estimateRound2Cost(successfulRefs, prices, maxTokens)
|
|
318
|
+
const downNext = estimateDownstreamCost(primaryJudge, preset?.judge_models, successfulRefs.length, prices, maxTokens)
|
|
319
|
+
const phaseCheck = checkPhaseBudget({
|
|
320
|
+
phaseName: 'Consilium Round 2', currentSpentUsd: currentSpent,
|
|
321
|
+
nextPhaseCostUsd: r2Next + downNext, maxBudgetUsd: preset.max_budget_usd,
|
|
322
|
+
action: preset.budget_action || 'trim', enabled: true,
|
|
323
|
+
})
|
|
324
|
+
if (!phaseCheck.allowed) {
|
|
325
|
+
if (phaseCheck.action === 'abort') {
|
|
326
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
327
|
+
return createBudgetAbortPayload({ reason: phaseCheck.reason, primaryJudge, referenceOutputs, currentSpentUsd: currentSpent, preset, isRefinement, isFastMode, collectedCtx, startTime })
|
|
328
|
+
}
|
|
329
|
+
runRound2 = false
|
|
330
|
+
if (typeof onProgress === 'function') {
|
|
331
|
+
onProgress(`✂️ *Skipping Round 2 to stay within budget ($${preset.max_budget_usd.toFixed(4)})*\n`)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (runRound2) {
|
|
331
337
|
if (typeof onProgress === 'function') {
|
|
332
338
|
onProgress('🤝 *Consilium Round 2: Candidates reviewing peer proposals in parallel...*\n')
|
|
333
339
|
}
|
|
@@ -366,8 +372,8 @@ export async function runMoAPipeline({
|
|
|
366
372
|
const extraCost = estimateTokenCost(slot, res.usage, prices)
|
|
367
373
|
cand.costUsd = Number(((cand.costUsd || 0) + extraCost.costUsd).toFixed(5))
|
|
368
374
|
}
|
|
369
|
-
} catch (
|
|
370
|
-
logger.warn(
|
|
375
|
+
} catch (r2Err) {
|
|
376
|
+
logger.warn(`[dsh-moa] Round 2 peer critique failed for candidate ${cand.index}:`, r2Err?.message || r2Err)
|
|
371
377
|
}
|
|
372
378
|
})
|
|
373
379
|
await Promise.allSettled(r2Promises)
|
|
@@ -407,6 +413,19 @@ export async function runMoAPipeline({
|
|
|
407
413
|
: null
|
|
408
414
|
|
|
409
415
|
// 8. Synthesis phase via primary judge or fallback chain
|
|
416
|
+
if (preset?.budget_guard_enabled && preset?.max_budget_usd > 0 && !isFastMode) {
|
|
417
|
+
const currentSpent = referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + (multiJudgeResult?.totalCostUsd || 0)
|
|
418
|
+
const downNext = estimateDownstreamCost(primaryJudge, [], successfulRefs.length, prices, maxTokens)
|
|
419
|
+
const phaseCheck = checkPhaseBudget({
|
|
420
|
+
phaseName: 'Judge Synthesis', currentSpentUsd: currentSpent,
|
|
421
|
+
nextPhaseCostUsd: downNext, maxBudgetUsd: preset.max_budget_usd,
|
|
422
|
+
action: preset.budget_action || 'trim', enabled: true,
|
|
423
|
+
})
|
|
424
|
+
if (!phaseCheck.allowed) {
|
|
425
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
426
|
+
return createBudgetAbortPayload({ reason: phaseCheck.reason, primaryJudge, referenceOutputs, currentSpentUsd: currentSpent, preset, isRefinement, isFastMode, collectedCtx, startTime })
|
|
427
|
+
}
|
|
428
|
+
}
|
|
410
429
|
const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria, {
|
|
411
430
|
curatorSynthesis: isCuratorSynthesis,
|
|
412
431
|
blindEvaluation: isBlindEvaluation,
|
|
@@ -543,46 +562,21 @@ export async function runMoAPipeline({
|
|
|
543
562
|
|
|
544
563
|
try {
|
|
545
564
|
await recordMoaRunAsync({
|
|
546
|
-
id: runId,
|
|
547
|
-
cwd,
|
|
548
|
-
prompt: userPrompt,
|
|
549
|
-
preset: preset?.name || 'default',
|
|
550
|
-
isRefinement,
|
|
565
|
+
id: runId, cwd, prompt: userPrompt, preset: preset?.name || 'default', isRefinement,
|
|
551
566
|
candidates: candidatesForHistory(referenceOutputs),
|
|
552
567
|
aggregator: { provider: chosenJudge.provider, model: chosenJudge.model, usage: aggUsage, costUsd: aggUsage.costUsd },
|
|
553
|
-
winnerIndex: winningIndex,
|
|
554
|
-
|
|
555
|
-
promotedFiles,
|
|
556
|
-
benchmarkReport,
|
|
557
|
-
consensus: multiJudgeResult,
|
|
558
|
-
totalTokens: usageSummary.totalTokens,
|
|
559
|
-
totalCostUsd: usageSummary.totalCostUsd,
|
|
560
|
-
durationMs,
|
|
568
|
+
winnerIndex: winningIndex, winnerModel, promotedFiles, benchmarkReport, consensus: multiJudgeResult,
|
|
569
|
+
totalTokens: usageSummary.totalTokens, totalCostUsd: usageSummary.totalCostUsd, durationMs,
|
|
561
570
|
}, historyFilePath || undefined)
|
|
562
571
|
} catch (histErr) {
|
|
563
572
|
logger.warn('[dsh-moa] Failed to record run in history:', histErr)
|
|
564
573
|
}
|
|
565
574
|
|
|
566
575
|
return {
|
|
567
|
-
kind: 'synthesis',
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
isRefinement,
|
|
573
|
-
isFastMode,
|
|
574
|
-
isCuratorSynthesis,
|
|
575
|
-
recommendedAssembler,
|
|
576
|
-
winningIndex,
|
|
577
|
-
winnerModel,
|
|
578
|
-
promotedFiles,
|
|
579
|
-
runId,
|
|
580
|
-
allowCandidateOverride,
|
|
581
|
-
benchmarkReport,
|
|
582
|
-
consensus: multiJudgeResult,
|
|
583
|
-
...(livePreview ? { liveCanvas: livePreview } : {}),
|
|
584
|
-
usage: usageSummary,
|
|
585
|
-
skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [],
|
|
586
|
-
durationMs,
|
|
576
|
+
kind: 'synthesis', content: synthesizedText, aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
|
|
577
|
+
references: referenceOutputs, presetName: preset?.name || 'default', isRefinement, isFastMode, isCuratorSynthesis,
|
|
578
|
+
recommendedAssembler, winningIndex, winnerModel, promotedFiles, runId, allowCandidateOverride,
|
|
579
|
+
benchmarkReport, consensus: multiJudgeResult, ...(livePreview ? { liveCanvas: livePreview } : {}),
|
|
580
|
+
usage: usageSummary, skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs,
|
|
587
581
|
}
|
|
588
582
|
}
|
package/lib/moa-test-gate.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path'
|
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
6
|
import { resolveCandidateFolder } from './file-workspace.js'
|
|
7
7
|
import { bestEffort } from './best-effort.js'
|
|
8
|
+
import { logger } from './logger.js'
|
|
8
9
|
|
|
9
10
|
const MAX_CAPTURE_BYTES = 256 * 1024
|
|
10
11
|
|
|
@@ -42,6 +43,29 @@ export function resolveCandidateDir(baseDir, candidateIndex, options = {}) {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Validates that an executed script or command does not reference files outside allowed roots (#161).
|
|
48
|
+
*/
|
|
49
|
+
function scanPathTokensForLeak(text, allowedRoots, executionDir) {
|
|
50
|
+
if (!text || typeof text !== 'string') return null
|
|
51
|
+
const sanitized = text
|
|
52
|
+
.replace(/https?:\/\/[^\s"'<>]+/gi, '')
|
|
53
|
+
.replace(/\b(?:node|file|npm|data):[^\s"'<>]+/gi, '')
|
|
54
|
+
const matches = sanitized.match(/(?:[a-zA-Z]:[\\/][^\s"'<>]+|(?<![\w.@\/\\])\/(?:[a-zA-Z0-9_\-.]+\/)*[a-zA-Z0-9_\-.]+|(?<![\w.@\/\\])\.\.[\\/][^\s"'<>]*)/g) || []
|
|
55
|
+
for (const raw of matches) {
|
|
56
|
+
if (/^\/(usr\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\/|$)/.test(raw) && !/\.(txt|json|env|npmrc|key|ssh|md)/i.test(raw)) {
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (!path.isAbsolute(raw) && !raw.startsWith('..')) continue
|
|
60
|
+
const resolved = path.resolve(executionDir, raw)
|
|
61
|
+
const allowed = allowedRoots.some((r) => r && (resolved === r || resolved.startsWith(r + path.sep)))
|
|
62
|
+
if (!allowed) {
|
|
63
|
+
return raw
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
/**
|
|
46
70
|
* Executes a test command in a candidate sandbox or ephemeral test staging directory.
|
|
47
71
|
*/
|
|
@@ -63,6 +87,7 @@ export async function runCandidateTestGate({
|
|
|
63
87
|
|
|
64
88
|
const startTime = Date.now()
|
|
65
89
|
let stageDir = null
|
|
90
|
+
let fenceFile = null
|
|
66
91
|
let executionDir = candidateDir
|
|
67
92
|
|
|
68
93
|
try {
|
|
@@ -70,7 +95,6 @@ export async function runCandidateTestGate({
|
|
|
70
95
|
const candPkg = path.join(candidateDir, 'package.json')
|
|
71
96
|
if (fsSync.existsSync(basePkg) && !fsSync.existsSync(candPkg)) {
|
|
72
97
|
stageDir = await fs.mkdtemp(path.join(os.tmpdir(), `moa-test-stage-${candidateIndex}-`))
|
|
73
|
-
|
|
74
98
|
await fs.cp(cwd, stageDir, {
|
|
75
99
|
recursive: true,
|
|
76
100
|
filter: (src) => {
|
|
@@ -80,8 +104,6 @@ export async function runCandidateTestGate({
|
|
|
80
104
|
return parts[0] !== 'node_modules' && parts[0] !== '.git' && parts[0] !== '.moa'
|
|
81
105
|
},
|
|
82
106
|
})
|
|
83
|
-
|
|
84
|
-
// #162: Preserve ESM & CJS resolution by symlinking project node_modules
|
|
85
107
|
const baseNodeModules = path.join(cwd, 'node_modules')
|
|
86
108
|
const stageNodeModules = path.join(stageDir, 'node_modules')
|
|
87
109
|
if (fsSync.existsSync(baseNodeModules)) {
|
|
@@ -89,15 +111,24 @@ export async function runCandidateTestGate({
|
|
|
89
111
|
fs.symlink(baseNodeModules, stageNodeModules, process.platform === 'win32' ? 'junction' : 'dir')
|
|
90
112
|
)
|
|
91
113
|
}
|
|
92
|
-
|
|
93
114
|
await fs.cp(candidateDir, stageDir, { recursive: true })
|
|
94
115
|
executionDir = stageDir
|
|
95
116
|
}
|
|
96
117
|
|
|
97
|
-
const
|
|
98
|
-
.
|
|
99
|
-
.
|
|
100
|
-
.
|
|
118
|
+
const replacePlaceholderSafely = (cmd, placeholder, dirPath) => {
|
|
119
|
+
if (!cmd.includes(placeholder)) return cmd
|
|
120
|
+
let res = cmd.replace(new RegExp(`(["'])(.*?)${placeholder}(.*?)\\1`, 'g'), (m, q, pre, post) => `${q}${pre}${dirPath}${post}${q}`)
|
|
121
|
+
res = res.replace(new RegExp(`(?<=^|\\s)([^\\s"']*?)${placeholder}([^\\s"']*?)(?=\\s|$)`, 'g'), (m, pre, post) => {
|
|
122
|
+
const full = pre + dirPath + post
|
|
123
|
+
return full.includes(' ') ? `"${full}"` : full
|
|
124
|
+
})
|
|
125
|
+
return res
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let resolvedCmd = trimmedCmd
|
|
129
|
+
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{candidateDir}', candidateDir)
|
|
130
|
+
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{baseDir}', cwd)
|
|
131
|
+
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{stageDir}', executionDir)
|
|
101
132
|
|
|
102
133
|
let exitCode = 0
|
|
103
134
|
let stdout = ''
|
|
@@ -114,18 +145,165 @@ export async function runCandidateTestGate({
|
|
|
114
145
|
stdout = customRes?.stdout || ''
|
|
115
146
|
stderr = customRes?.stderr || ''
|
|
116
147
|
} else {
|
|
117
|
-
const
|
|
148
|
+
const allowedRoots = [executionDir, cwd, ...(candidateDir && candidateDir !== executionDir ? [candidateDir] : [])]
|
|
149
|
+
const cmdTokens = resolvedCmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []
|
|
150
|
+
|
|
151
|
+
// 1. Check command tokens for absolute path leaks
|
|
152
|
+
for (const rawToken of cmdTokens) {
|
|
153
|
+
const token = rawToken.replace(/^["']|["']$/g, '')
|
|
154
|
+
if (path.isAbsolute(token) || token.startsWith('..')) {
|
|
155
|
+
if (/^\/(usr\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\/|$)/.test(token) && !/\.(txt|json|env|npmrc|key|ssh|md)/i.test(token)) {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
const resolved = path.resolve(executionDir, token)
|
|
159
|
+
const allowed = allowedRoots.some((r) => r && (resolved === r || resolved.startsWith(r + path.sep)))
|
|
160
|
+
if (!allowed) {
|
|
161
|
+
const durationMs = Date.now() - startTime
|
|
162
|
+
return {
|
|
163
|
+
candidateIndex,
|
|
164
|
+
passed: false,
|
|
165
|
+
exitCode: 1,
|
|
166
|
+
durationMs,
|
|
167
|
+
command: resolvedCmd,
|
|
168
|
+
output: `Access denied: test command specifies path outside workspace sandbox (${token})`,
|
|
169
|
+
summary: `FAIL (code 1, ${durationMs}ms)`,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 2. Scan script files referenced by command or present in candidate workspace (#161)
|
|
176
|
+
const candFiles = fsSync.existsSync(executionDir) ? fsSync.readdirSync(executionDir) : []
|
|
177
|
+
const scriptTokens = new Set([
|
|
178
|
+
...cmdTokens.map((t) => t.replace(/^["']|["']$/g, '')),
|
|
179
|
+
...candFiles,
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
for (const scriptName of scriptTokens) {
|
|
183
|
+
const scriptPath = path.isAbsolute(scriptName) ? scriptName : path.join(executionDir, scriptName)
|
|
184
|
+
if (fsSync.existsSync(scriptPath) && !fsSync.statSync(scriptPath).isDirectory()) {
|
|
185
|
+
try {
|
|
186
|
+
const scriptText = fsSync.readFileSync(scriptPath, 'utf8')
|
|
187
|
+
const leaked = scanPathTokensForLeak(scriptText, allowedRoots, executionDir)
|
|
188
|
+
if (leaked) {
|
|
189
|
+
const durationMs = Date.now() - startTime
|
|
190
|
+
return {
|
|
191
|
+
candidateIndex,
|
|
192
|
+
passed: false,
|
|
193
|
+
exitCode: 1,
|
|
194
|
+
durationMs,
|
|
195
|
+
command: resolvedCmd,
|
|
196
|
+
output: `Access denied: test script specifies path outside workspace sandbox (${leaked})`,
|
|
197
|
+
summary: `FAIL (code 1, ${durationMs}ms)`,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. Scan package.json scripts if package.json exists in executionDir (#161, #206)
|
|
205
|
+
const candPkgFile = path.join(executionDir, 'package.json')
|
|
206
|
+
if (fsSync.existsSync(candPkgFile)) {
|
|
207
|
+
try {
|
|
208
|
+
const pkgObj = JSON.parse(fsSync.readFileSync(candPkgFile, 'utf8'))
|
|
209
|
+
const scriptsContent = Object.values(pkgObj.scripts || {}).join('\n')
|
|
210
|
+
const leaked = scanPathTokensForLeak(scriptsContent, allowedRoots, executionDir)
|
|
211
|
+
if (leaked) {
|
|
212
|
+
const durationMs = Date.now() - startTime
|
|
213
|
+
return {
|
|
214
|
+
candidateIndex,
|
|
215
|
+
passed: false,
|
|
216
|
+
exitCode: 1,
|
|
217
|
+
durationMs,
|
|
218
|
+
command: resolvedCmd,
|
|
219
|
+
output: `Access denied: package.json test script specifies path outside workspace sandbox (${leaked})`,
|
|
220
|
+
summary: `FAIL (code 1, ${durationMs}ms)`,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Setup isolated HOME & node roots
|
|
227
|
+
const rawHome = process.env.HOME
|
|
228
|
+
const isCustomHome = rawHome && (rawHome.startsWith(cwd) || rawHome.startsWith(executionDir) || rawHome.includes('fake-home') || rawHome.startsWith(os.tmpdir()))
|
|
229
|
+
let effectiveHome = isCustomHome ? rawHome : path.join(executionDir, '.moa-home')
|
|
230
|
+
if (!fsSync.existsSync(effectiveHome)) {
|
|
231
|
+
await bestEffort('create candidate test home', () => fs.mkdir(effectiveHome, { recursive: true }))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const nodeRoot = path.resolve(path.dirname(process.execPath), '..')
|
|
235
|
+
const nodeBinDir = path.dirname(process.execPath)
|
|
118
236
|
const allowedReads = [
|
|
119
237
|
executionDir,
|
|
120
238
|
cwd,
|
|
239
|
+
effectiveHome,
|
|
121
240
|
...(candidateDir && candidateDir !== executionDir ? [candidateDir] : []),
|
|
241
|
+
nodeRoot,
|
|
242
|
+
nodeBinDir,
|
|
122
243
|
...(process.platform === 'win32'
|
|
123
|
-
? [process.env.SYSTEMROOT || 'C:\\Windows'
|
|
124
|
-
: ['/usr', '/lib', '/lib64', '/etc'
|
|
244
|
+
? [process.env.SYSTEMROOT || 'C:\\Windows']
|
|
245
|
+
: ['/usr', '/lib', '/lib64', '/etc']),
|
|
125
246
|
].filter(Boolean)
|
|
126
|
-
const permOpts = `--permission ${allowedReads.map((p) => `--allow-fs-read=${p}`).join(' ')} --allow-fs-write=${executionDir} --allow-child-process`
|
|
127
247
|
|
|
248
|
+
// Create security fence module for child process containment (#161, #206)
|
|
249
|
+
fenceFile = path.join(executionDir, `.moa-fence-${Date.now()}.cjs`)
|
|
250
|
+
const fenceCode = `
|
|
251
|
+
const cp = require('node:child_process');
|
|
252
|
+
const path = require('node:path');
|
|
253
|
+
const allowed = ${JSON.stringify(allowedReads)};
|
|
254
|
+
function checkPath(p) {
|
|
255
|
+
if (!p || typeof p !== 'string') return true;
|
|
256
|
+
if (/^\\/(usr\\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\\/|$)/.test(p) && !/\\.(txt|json|env|npmrc|key|ssh|md)/i.test(p)) return true;
|
|
257
|
+
const res = path.resolve(process.cwd(), p);
|
|
258
|
+
return allowed.some(root => root && (res === root || res.startsWith(root + path.sep)));
|
|
259
|
+
}
|
|
260
|
+
function guardArgs(cmd, args) {
|
|
261
|
+
if (cmd && typeof cmd === 'string' && !checkPath(cmd)) {
|
|
262
|
+
throw new Error('ERR_ACCESS_DENIED: child process binary outside sandbox: ' + cmd);
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(args)) {
|
|
265
|
+
for (const a of args) {
|
|
266
|
+
if (!checkPath(a)) throw new Error('ERR_ACCESS_DENIED: child process argument outside sandbox: ' + a);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const origSpawn = cp.spawn;
|
|
271
|
+
cp.spawn = function(cmd, args, opts) {
|
|
272
|
+
guardArgs(cmd, args);
|
|
273
|
+
return origSpawn.apply(this, arguments);
|
|
274
|
+
};
|
|
275
|
+
const origSpawnSync = cp.spawnSync;
|
|
276
|
+
cp.spawnSync = function(cmd, args, opts) {
|
|
277
|
+
guardArgs(cmd, args);
|
|
278
|
+
return origSpawnSync.apply(this, arguments);
|
|
279
|
+
};
|
|
280
|
+
const origExecFile = cp.execFile;
|
|
281
|
+
cp.execFile = function(file, args, opts, cb) {
|
|
282
|
+
guardArgs(file, args);
|
|
283
|
+
return origExecFile.apply(this, arguments);
|
|
284
|
+
};
|
|
285
|
+
const origExecFileSync = cp.execFileSync;
|
|
286
|
+
cp.execFileSync = function(file, args, opts) {
|
|
287
|
+
guardArgs(file, args);
|
|
288
|
+
return origExecFileSync.apply(this, arguments);
|
|
289
|
+
};
|
|
290
|
+
const origExec = cp.exec;
|
|
291
|
+
cp.exec = function(cmd, opts, cb) {
|
|
292
|
+
guardArgs(cmd, [cmd]);
|
|
293
|
+
return origExec.apply(this, arguments);
|
|
294
|
+
};
|
|
295
|
+
const origExecSync = cp.execSync;
|
|
296
|
+
cp.execSync = function(cmd, opts) {
|
|
297
|
+
guardArgs(cmd, [cmd]);
|
|
298
|
+
return origExecSync.apply(this, arguments);
|
|
299
|
+
};
|
|
300
|
+
`
|
|
301
|
+
fsSync.writeFileSync(fenceFile, fenceCode, 'utf8')
|
|
302
|
+
|
|
303
|
+
const permOpts = `--permission ${allowedReads.map((p) => `--allow-fs-read=${p}`).join(' ')} --allow-fs-write=${executionDir} --allow-fs-write=${effectiveHome} --allow-child-process --require ${fenceFile}`
|
|
304
|
+
const nodePath = path.join(cwd, 'node_modules')
|
|
128
305
|
const env = getScrubbedEnv({
|
|
306
|
+
HOME: effectiveHome,
|
|
129
307
|
MOA_CANDIDATE_INDEX: String(candidateIndex),
|
|
130
308
|
MOA_CANDIDATE_DIR: candidateDir,
|
|
131
309
|
MOA_BASE_DIR: cwd,
|
|
@@ -243,6 +421,9 @@ export async function runCandidateTestGate({
|
|
|
243
421
|
summary: `ERROR (${err.message || 'unknown'})`,
|
|
244
422
|
}
|
|
245
423
|
} finally {
|
|
424
|
+
if (fenceFile) {
|
|
425
|
+
await bestEffort('moa-test-gate cleanup fenceFile', () => fs.rm(fenceFile, { force: true }))
|
|
426
|
+
}
|
|
246
427
|
if (stageDir) {
|
|
247
428
|
await bestEffort('moa-test-gate cleanup stageDir', () =>
|
|
248
429
|
fs.rm(stageDir, { recursive: true, force: true })
|
package/lib/routes.js
CHANGED
|
@@ -2,13 +2,9 @@
|
|
|
2
2
|
* DeepSeek Harness Mixture of Agents (MoA) — HTTP Route Handlers
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
getMoaRunById,
|
|
9
|
-
exportMoaHistory,
|
|
10
|
-
} from './history.js'
|
|
11
|
-
import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath } from './file-workspace.js'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { getMoaHistory, getMoaLeaderboard, getMoaRunById, exportMoaHistory } from './history.js'
|
|
7
|
+
import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath, isLoopbackUrl } from './file-workspace.js'
|
|
12
8
|
import { isSafeWriteRequest } from './updater.js'
|
|
13
9
|
import { runMoAPipeline } from './moa-runner.js'
|
|
14
10
|
import { generateMoABenchmarkReport } from './moa-report.js'
|
|
@@ -18,10 +14,7 @@ let _modelsCache = { timestamp: 0, models: [] }
|
|
|
18
14
|
const MODELS_CACHE_TTL = 60 * 1000
|
|
19
15
|
|
|
20
16
|
function writeJson(res, status, body) {
|
|
21
|
-
res.writeHead(status, {
|
|
22
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
23
|
-
'Cache-Control': 'no-store',
|
|
24
|
-
})
|
|
17
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
25
18
|
res.end(JSON.stringify(body))
|
|
26
19
|
}
|
|
27
20
|
|
|
@@ -30,10 +23,7 @@ function readBody(req) {
|
|
|
30
23
|
let acc = ''
|
|
31
24
|
req.on('data', (chunk) => {
|
|
32
25
|
acc += chunk
|
|
33
|
-
if (acc.length > 5 * 1024 * 1024) {
|
|
34
|
-
req.destroy()
|
|
35
|
-
reject(new Error('Payload Too Large'))
|
|
36
|
-
}
|
|
26
|
+
if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
|
|
37
27
|
})
|
|
38
28
|
req.on('end', () => resolve(acc))
|
|
39
29
|
req.on('error', reject)
|
|
@@ -134,19 +124,11 @@ ctx.effect(() => {
|
|
|
134
124
|
// Query settings for configured providers
|
|
135
125
|
try {
|
|
136
126
|
let s = null
|
|
137
|
-
try {
|
|
138
|
-
|
|
139
|
-
} catch {
|
|
140
|
-
s = null
|
|
141
|
-
}
|
|
142
|
-
const getSetting = (k) => {
|
|
143
|
-
try { return s?.get?.(k) } catch { return undefined }
|
|
144
|
-
}
|
|
127
|
+
try { s = ctx.get ? ctx.get('settings') : null } catch { s = null }
|
|
128
|
+
const getSetting = (k) => { try { return s?.get?.(k) } catch { return undefined } }
|
|
145
129
|
|
|
146
130
|
const def = getSetting('agent-default-model')
|
|
147
|
-
if (def?.provider && def?.model)
|
|
148
|
-
add(def.provider, def.model)
|
|
149
|
-
}
|
|
131
|
+
if (def?.provider && def?.model) add(def.provider, def.model)
|
|
150
132
|
|
|
151
133
|
const piAi = getSetting('llm-pi-ai')
|
|
152
134
|
for (const [pName, pData] of Object.entries(piAi?.providers || {})) {
|
|
@@ -220,12 +202,8 @@ ctx.effect(() => {
|
|
|
220
202
|
|
|
221
203
|
// Persist via saveConfig (in-memory and DSH profile settings)
|
|
222
204
|
if (typeof saveConfig === 'function') {
|
|
223
|
-
try {
|
|
224
|
-
|
|
225
|
-
} catch (perr) {
|
|
226
|
-
writeJson(res, 500, { ok: false, error: 'Settings persistence failed: ' + (perr?.message || String(perr)) })
|
|
227
|
-
return
|
|
228
|
-
}
|
|
205
|
+
try { await saveConfig(validated) }
|
|
206
|
+
catch (perr) { return writeJson(res, 500, { ok: false, error: 'Settings persistence failed: ' + (perr?.message || String(perr)) }) }
|
|
229
207
|
}
|
|
230
208
|
|
|
231
209
|
const unwrapped = typeof plainConfig === 'function' ? plainConfig(validated) : validated
|
|
@@ -258,10 +236,8 @@ ctx.effect(() => {
|
|
|
258
236
|
}
|
|
259
237
|
try {
|
|
260
238
|
const u = new URL(req.url, 'http://localhost')
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
const limit = Math.max(1, Math.min(100, Number.isFinite(rawLimit) ? rawLimit : 20))
|
|
264
|
-
const offset = Math.max(0, Number.isFinite(rawOffset) ? rawOffset : 0)
|
|
239
|
+
const limit = Math.max(1, Math.min(100, parseInt(u.searchParams.get('limit') || '20', 10) || 20))
|
|
240
|
+
const offset = Math.max(0, parseInt(u.searchParams.get('offset') || '0', 10) || 0)
|
|
265
241
|
const data = getMoaHistory(limit, offset)
|
|
266
242
|
writeJson(res, 200, { ok: true, ...data })
|
|
267
243
|
} catch (err) {
|
|
@@ -329,6 +305,13 @@ ctx.effect(() => {
|
|
|
329
305
|
}
|
|
330
306
|
const cwd = body?.cwd || process.cwd()
|
|
331
307
|
const runId = body?.runId
|
|
308
|
+
if (runId) {
|
|
309
|
+
const recordedRun = getMoaRunById(runId)
|
|
310
|
+
if (recordedRun && body?.cwd && path.resolve(body.cwd) !== path.resolve(recordedRun.cwd)) {
|
|
311
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: cwd does not match recorded run cwd' })
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
}
|
|
332
315
|
const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
|
|
333
316
|
runId,
|
|
334
317
|
keepMoa: true,
|
|
@@ -357,10 +340,23 @@ ctx.effect(() => {
|
|
|
357
340
|
try {
|
|
358
341
|
const u = new URL(req.url, 'http://localhost')
|
|
359
342
|
const rawRunId = u.searchParams.get('runId')
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
343
|
+
const queryCwd = u.searchParams.get('cwd')
|
|
344
|
+
let run = null
|
|
345
|
+
if (rawRunId && rawRunId !== 'latest' && rawRunId !== 'active') {
|
|
346
|
+
run = getMoaRunById(rawRunId)
|
|
347
|
+
if (!run) {
|
|
348
|
+
writeJson(res, 404, { ok: false, error: `Run '${rawRunId}' not found` })
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
run = getMoaHistory(1, 0).runs[0] || null
|
|
353
|
+
}
|
|
354
|
+
if (run && queryCwd && path.resolve(queryCwd) !== path.resolve(run.cwd)) {
|
|
355
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: cwd does not match recorded run cwd' })
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
const runId = run?.id
|
|
359
|
+
const cwd = run?.cwd || queryCwd || process.cwd()
|
|
364
360
|
const sanitizeTarget = (val, def) => {
|
|
365
361
|
const clean = String(val || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
|
|
366
362
|
return clean || def
|
|
@@ -421,18 +417,10 @@ ctx.effect(() => {
|
|
|
421
417
|
const format = url.searchParams.get('format') || 'json'
|
|
422
418
|
const data = exportMoaHistory(undefined, { format, preset })
|
|
423
419
|
|
|
424
|
-
|
|
425
|
-
res.writeHead(200, {
|
|
426
|
-
'Content-Type': 'text/csv; charset=utf-8',
|
|
427
|
-
'Content-Disposition': 'attachment; filename="moa-history.csv"',
|
|
428
|
-
})
|
|
429
|
-
res.end(data)
|
|
430
|
-
return
|
|
431
|
-
}
|
|
432
|
-
|
|
420
|
+
const isCsv = format === 'csv'
|
|
433
421
|
res.writeHead(200, {
|
|
434
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
435
|
-
'Content-Disposition':
|
|
422
|
+
'Content-Type': isCsv ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8',
|
|
423
|
+
'Content-Disposition': `attachment; filename="moa-history.${isCsv ? 'csv' : 'json'}"`,
|
|
436
424
|
})
|
|
437
425
|
res.end(data)
|
|
438
426
|
} catch (err) {
|
|
@@ -570,6 +558,10 @@ ctx.effect(() => {
|
|
|
570
558
|
writeJson(res, 400, { ok: false, error: 'MoA is disabled in settings' })
|
|
571
559
|
return
|
|
572
560
|
}
|
|
561
|
+
if (payload.timeMachineUrl && !isLoopbackUrl(payload.timeMachineUrl)) {
|
|
562
|
+
writeJson(res, 400, { ok: false, error: 'Invalid timeMachineUrl: loopback host required' })
|
|
563
|
+
return
|
|
564
|
+
}
|
|
573
565
|
const presetName = payload.preset || cfg.default_preset || 'default'
|
|
574
566
|
const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
|
|
575
567
|
|
|
@@ -579,13 +571,17 @@ ctx.effect(() => {
|
|
|
579
571
|
webServerPort: webServerPort || ctx.webServer?.port,
|
|
580
572
|
timeMachineEngine: timeMachineEngine || ctx.reflect?.get?.('timeMachineEngine', false),
|
|
581
573
|
timeMachineUrl: payload.timeMachineUrl, force: payload.force,
|
|
582
|
-
checkpoint: payload.checkpoint,
|
|
574
|
+
checkpoint: payload.checkpoint,
|
|
583
575
|
runId: payload.runId,
|
|
584
576
|
})
|
|
585
577
|
|
|
586
578
|
if (result?.skippedFiles > 0) {
|
|
587
579
|
ctx.logger?.warn?.(`[dsh-moa] Workspace scan skipped ${result.skippedFiles} file(s): ${result.skippedList.join(', ')}`)
|
|
588
580
|
}
|
|
581
|
+
if (result?.error) {
|
|
582
|
+
writeJson(res, 500, { ok: false, error: result.error?.message || String(result.error) })
|
|
583
|
+
return
|
|
584
|
+
}
|
|
589
585
|
|
|
590
586
|
writeJson(res, 200, { ok: true, ...result })
|
|
591
587
|
} catch (err) {
|