@haus-tech/haus-workflow 5.12.1 → 6.0.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/CHANGELOG.md +55 -0
- package/README.md +7 -4
- package/dist/cli.js +7183 -4310
- package/library/catalog/manifest.json +126 -9
- package/library/global/agents/request-design-reviewer.md +54 -0
- package/library/global/agents/request-designer.md +113 -0
- package/library/global/skills/haus-workflow/SKILL.md +7 -3
- package/library/global/skills/haus-workflow/references/init.md +7 -4
- package/library/global/skills/request/SKILL.md +487 -104
- package/library/global/skills/request/scripts/archive.mjs +697 -48
- package/library/global/skills/request/scripts/bedom.mjs +651 -0
- package/library/global/skills/request/scripts/korning.mjs +911 -70
- package/library/global/skills/request/scripts/lib.mjs +328 -3
- package/library/global/skills/request/scripts/precheck.mjs +134 -34
- package/library/global/skills/request/scripts/svep.mjs +44 -14
- package/library/global/skills/request-assess-impact/SKILL.md +1 -4
- package/library/global/skills/request-sync/SKILL.md +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Startaren för produktionens prisning (ADR-0155 beslut 1, 3, 4 och 5):
|
|
4
|
+
* `bedöm`:s steg 3 till 7 körs som EN `korning.mjs`-process per post i
|
|
5
|
+
* produktionsläget (`--post`), startad härifrån, så huvudkonversationen
|
|
6
|
+
* dispatchar ingen agent för prisbenet. Huvudkonversationen behåller grinden,
|
|
7
|
+
* formkontrollen, BLUF:en, tavelskrivningen och avslutet; det här scriptet
|
|
8
|
+
* gör bokföringen runt körningen och skriver en tabell.
|
|
9
|
+
*
|
|
10
|
+
* Användning:
|
|
11
|
+
* node bedom.mjs --repo-rot <abs> [--arkiv <abs>] --post <gid|gid/del-N> [--post ...]...
|
|
12
|
+
* [--parallellt 2] [--max-usd <n>] [--claude <kommando>] [--om]
|
|
13
|
+
* [--sprak <svenska|engelska>] [--design-mode <draft|intervju>]
|
|
14
|
+
*
|
|
15
|
+
* `--post` är relativ arkivroten: `--arkiv` när den ges (ARKIVROT: från
|
|
16
|
+
* `archive.mjs rot`), annars härledd ur --repo-rot:s git common dir
|
|
17
|
+
* (ADR-0154). `--repo-rot` är utcheckningen vars kod läses och får vara en
|
|
18
|
+
* worktree.
|
|
19
|
+
*
|
|
20
|
+
* Per post, i ordning: förkontroll (state.json läsbar, ingen experimentkatalog,
|
|
21
|
+
* ingen splitrot med `del-N/` under sig, `approval` satt, inget stående
|
|
22
|
+
* `stopp`, inte redan `priced`/`guard_run` utan `--om`; annars raden `grind`
|
|
23
|
+
* och inget startas), taket, körningskatalogen
|
|
24
|
+
* `<post>/korningar/<YYYYMMDDTHHMMSS>/`, kvittot `archive.mjs korning-start`
|
|
25
|
+
* (en levande pid ger raden `pågår` och den tomma katalogen tas bort),
|
|
26
|
+
* `korning.mjs` med `stderr.log` och `matning.json` i körningskatalogen och
|
|
27
|
+
* `--startare <egen pid>` (dör startaren hårt avbryter körningen sig själv),
|
|
28
|
+
* `korning.json`:s `total_cost_usd`, `archive.mjs sparr` och statusen. Kvittot
|
|
29
|
+
* rensas alltid med `archive.mjs korning-slut` i en finally.
|
|
30
|
+
*
|
|
31
|
+
* Taket (beslut 4): `max_usd_per_batch` i `.haus-workflow/request.json`
|
|
32
|
+
* (`<arkiv>/../request.json`), 100 USD när nyckeln saknas, `--max-usd` vinner
|
|
33
|
+
* för en körning. Före varje poststart jämförs summan mot taket: de klara
|
|
34
|
+
* posterna med sin USD plus varje post i flykt till de klaras snittkostnad
|
|
35
|
+
* (0 så länge ingen är klar). När summan nått taket startas inga fler poster
|
|
36
|
+
* (raden `tak nått`), och ingen pågående körning dödas. Två poster som startar
|
|
37
|
+
* samtidigt innan någon är klar passerar båda kontrollen: det är accepterat,
|
|
38
|
+
* taket stoppar nästa start och aldrig en körning som pågår.
|
|
39
|
+
*
|
|
40
|
+
* stdout under körningen: `START <post>`, `KLAR <post> <status>`,
|
|
41
|
+
* `GRIND <post>: <orsak>`, `PÅGÅR <post> (pid N)`, `TAK <post>`; till sist
|
|
42
|
+
* tabellen `| post | status | poäng | utfall | wall | USD |` och raden
|
|
43
|
+
* `tak: <n> USD, använt: <m.mm> USD`.
|
|
44
|
+
*
|
|
45
|
+
* Exit 0 = varje rad är `priced`, `stopp` eller `väntar på dom`; 1 = minst en
|
|
46
|
+
* rad är `ofärdig`, `pågår`, `grind` eller `tak nått`; 2 = användningsfel.
|
|
47
|
+
* SIGINT och SIGTERM skickar SIGTERM till varje pågående körning, skriver
|
|
48
|
+
* matning.json med signalen, rensar varje kvitto med korning-slut och avslutar
|
|
49
|
+
* med 130. Ett oväntat kast i huvudloopen skrivs på stderr och ger exit 2.
|
|
50
|
+
*/
|
|
51
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, realpathSync } from 'node:fs'
|
|
52
|
+
import { spawnSync } from 'node:child_process'
|
|
53
|
+
import path from 'node:path'
|
|
54
|
+
import { fileURLToPath } from 'node:url'
|
|
55
|
+
import { korInre, losArkiv, schemalagg, skrivMatning, usd } from './svep.mjs'
|
|
56
|
+
|
|
57
|
+
const HAR = path.dirname(fileURLToPath(import.meta.url))
|
|
58
|
+
const ARCHIVE = path.join(HAR, 'archive.mjs')
|
|
59
|
+
const KORNING = path.join(HAR, 'korning.mjs')
|
|
60
|
+
|
|
61
|
+
export const STANDARD_TAK_USD = 100
|
|
62
|
+
const STANDARD_PARALLELLT = 2
|
|
63
|
+
const SIGNAL_EXIT = 130
|
|
64
|
+
/** Signalerna som avbryter batchen; samma hanterare för båda. */
|
|
65
|
+
export const AVBROTTSSIGNALER = ['SIGINT', 'SIGTERM']
|
|
66
|
+
const SPRAKEN = ['svenska', 'engelska']
|
|
67
|
+
const DESIGNLAGEN = ['draft', 'intervju']
|
|
68
|
+
const VARDEFLAGGOR = [
|
|
69
|
+
'repo-rot',
|
|
70
|
+
'arkiv',
|
|
71
|
+
'post',
|
|
72
|
+
'parallellt',
|
|
73
|
+
'max-usd',
|
|
74
|
+
'claude',
|
|
75
|
+
'sprak',
|
|
76
|
+
'design-mode',
|
|
77
|
+
]
|
|
78
|
+
const BOOLFLAGGOR = ['om']
|
|
79
|
+
/** Statusar som är ett resultat: exit 0 när varje rad bär en av dem. */
|
|
80
|
+
const RESULTAT = ['priced', 'stopp', 'väntar på dom']
|
|
81
|
+
|
|
82
|
+
export const ANVANDNING = [
|
|
83
|
+
'användning: bedom.mjs --repo-rot <abs> [--arkiv <abs>] --post <gid|gid/del-N> [--post ...]...',
|
|
84
|
+
` [--parallellt ${STANDARD_PARALLELLT}] [--max-usd <n>] [--claude <kommando>] [--om]`,
|
|
85
|
+
` [--sprak <${SPRAKEN.join('|')}>] [--design-mode <${DESIGNLAGEN.join('|')}>]`,
|
|
86
|
+
].join('\n')
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Rena hjälpare (testas in-process i tests/request-bedom.test.js)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** Argumenttolkning, fail-closed som svep.mjs: `{ fel }` vid användningsfel
|
|
93
|
+
* (exit 2 i main), annars de tolkade valen. */
|
|
94
|
+
export function tolkaArgv(argv) {
|
|
95
|
+
const poster = []
|
|
96
|
+
const enkla = {}
|
|
97
|
+
let om = false
|
|
98
|
+
for (let i = 0; i < argv.length; i++) {
|
|
99
|
+
const a = argv[i]
|
|
100
|
+
if (!a.startsWith('--')) return { fel: `oväntat argument: ${a}` }
|
|
101
|
+
const namn = a.slice(2)
|
|
102
|
+
if (BOOLFLAGGOR.includes(namn)) {
|
|
103
|
+
om = true
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (!VARDEFLAGGOR.includes(namn)) return { fel: `okänd flagga: --${namn}` }
|
|
107
|
+
const v = argv[i + 1]
|
|
108
|
+
if (v === undefined || v.startsWith('--')) return { fel: `--${namn} kräver ett värde` }
|
|
109
|
+
i++
|
|
110
|
+
if (namn === 'post') poster.push(v)
|
|
111
|
+
else enkla[namn] = v
|
|
112
|
+
}
|
|
113
|
+
const saknas = [
|
|
114
|
+
...(enkla['repo-rot'] === undefined ? ['--repo-rot'] : []),
|
|
115
|
+
...(poster.length === 0 ? ['--post'] : []),
|
|
116
|
+
]
|
|
117
|
+
if (saknas.length > 0) return { fel: `saknar ${saknas.join(', ')}` }
|
|
118
|
+
if (!path.isAbsolute(enkla['repo-rot']))
|
|
119
|
+
return { fel: `--repo-rot måste vara en absolut sökväg (fick "${enkla['repo-rot']}")` }
|
|
120
|
+
if (enkla.arkiv !== undefined && !path.isAbsolute(enkla.arkiv))
|
|
121
|
+
return { fel: `--arkiv måste vara en absolut sökväg (fick "${enkla.arkiv}")` }
|
|
122
|
+
// `123` och `123/` är samma post: två körningar på den skulle vägras av
|
|
123
|
+
// kvittot ändå, men en dubblett i argv är ett skrivfel och stoppar före start.
|
|
124
|
+
const normerade = poster.map((p) => p.replace(/\/+$/, ''))
|
|
125
|
+
const dubblett = normerade.find((p, i) => normerade.indexOf(p) !== i)
|
|
126
|
+
if (dubblett !== undefined) return { fel: `--post ${dubblett} står två gånger` }
|
|
127
|
+
const parText = enkla.parallellt ?? String(STANDARD_PARALLELLT)
|
|
128
|
+
if (!/^\d+$/.test(parText) || Number(parText) < 1)
|
|
129
|
+
return { fel: `--parallellt måste vara ett heltal >= 1 (fick "${parText}")` }
|
|
130
|
+
let maxUsd = null
|
|
131
|
+
if (enkla['max-usd'] !== undefined) {
|
|
132
|
+
maxUsd = Number(enkla['max-usd'])
|
|
133
|
+
if (enkla['max-usd'].trim() === '' || !Number.isFinite(maxUsd) || maxUsd < 0)
|
|
134
|
+
return { fel: `--max-usd måste vara ett tal >= 0 (fick "${enkla['max-usd']}")` }
|
|
135
|
+
}
|
|
136
|
+
if (enkla.sprak !== undefined && !SPRAKEN.includes(enkla.sprak))
|
|
137
|
+
return { fel: `--sprak måste vara ${SPRAKEN.join(' eller ')} (fick "${enkla.sprak}")` }
|
|
138
|
+
if (enkla['design-mode'] !== undefined && !DESIGNLAGEN.includes(enkla['design-mode']))
|
|
139
|
+
return {
|
|
140
|
+
fel: `--design-mode måste vara ${DESIGNLAGEN.join(' eller ')} (fick "${enkla['design-mode']}")`,
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
repoRot: enkla['repo-rot'],
|
|
144
|
+
arkiv: enkla.arkiv ?? null,
|
|
145
|
+
poster: normerade,
|
|
146
|
+
parallellt: Number(parText),
|
|
147
|
+
maxUsd,
|
|
148
|
+
claude: enkla.claude ?? 'claude',
|
|
149
|
+
om,
|
|
150
|
+
sprak: enkla.sprak ?? null,
|
|
151
|
+
designMode: enkla['design-mode'] ?? null,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** En post namnges relativt arkivroten (`<gid>` eller `<gid>/del-N`), aldrig
|
|
156
|
+
* som absolut sökväg och aldrig utanför roten. `{ dir }` eller `{ fel }`. */
|
|
157
|
+
export function losPost(post, arkiv) {
|
|
158
|
+
if (path.isAbsolute(post))
|
|
159
|
+
return { fel: `--post ${post}: ange posten relativ arkivroten (<gid> eller <gid>/del-N)` }
|
|
160
|
+
const dir = path.join(arkiv, post)
|
|
161
|
+
const rel = path.relative(arkiv, dir)
|
|
162
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`))
|
|
163
|
+
return { fel: `--post ${post}: pekar utanför arkivroten` }
|
|
164
|
+
return { dir }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Taket för körningen: flaggan vinner, sedan `max_usd_per_batch` i
|
|
168
|
+
* `<arkiv>/../request.json` (`.haus-workflow/request.json`), sedan 100. En
|
|
169
|
+
* saknad eller trasig fil är defaulten, aldrig ett fel. */
|
|
170
|
+
export function lasTak({ arkiv, maxUsd = null }) {
|
|
171
|
+
if (maxUsd !== null && maxUsd !== undefined) return maxUsd
|
|
172
|
+
const v = lasRequestJson(arkiv)?.max_usd_per_batch
|
|
173
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v
|
|
174
|
+
return STANDARD_TAK_USD
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `<arkiv>/../request.json` som objekt, eller null när den saknas eller är
|
|
178
|
+
* trasig (defaulten gäller då, aldrig ett fel). */
|
|
179
|
+
function lasRequestJson(arkiv) {
|
|
180
|
+
try {
|
|
181
|
+
const cfg = JSON.parse(readFileSync(path.join(arkiv, '..', 'request.json'), 'utf8'))
|
|
182
|
+
return cfg !== null && typeof cfg === 'object' && !Array.isArray(cfg) ? cfg : null
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Designläget (ADR-0156): flaggan vinner, sedan `design_mode` i request.json
|
|
189
|
+
* (`draft`, `intervju`; `interview` är `intervju`), annars `intervju`. Vägrar
|
|
190
|
+
* aldrig: ett okänt värde i filen varnar (VARNING) och ger `intervju`, en
|
|
191
|
+
* saknad nyckel eller en oläsbar fil är tyst `intervju`. */
|
|
192
|
+
export function lasDesignlage({ arkiv, designMode = null }) {
|
|
193
|
+
if (designMode !== null && designMode !== undefined) return designMode
|
|
194
|
+
const v = lasRequestJson(arkiv)?.design_mode
|
|
195
|
+
if (v === undefined) return 'intervju'
|
|
196
|
+
if (v === 'draft') return 'draft'
|
|
197
|
+
if (v === 'intervju' || v === 'interview') return 'intervju'
|
|
198
|
+
console.error(`VARNING: design_mode i request.json okänt värde "${v}", intervju gäller`)
|
|
199
|
+
return 'intervju'
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const lasState = (dir) => {
|
|
203
|
+
try {
|
|
204
|
+
const s = JSON.parse(readFileSync(path.join(dir, 'state.json'), 'utf8'))
|
|
205
|
+
return s !== null && typeof s === 'object' && !Array.isArray(s) ? s : null
|
|
206
|
+
} catch {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** En splitrot har sina delar som `del-N/`-kataloger under sig; delarna
|
|
212
|
+
* prissätts var för sig och föräldern flyttas när den sista är klar. */
|
|
213
|
+
const arSplitrot = (dir) => {
|
|
214
|
+
try {
|
|
215
|
+
return readdirSync(dir, { withFileTypes: true }).some(
|
|
216
|
+
(e) => e.isDirectory() && /^del-\d+$/.test(e.name),
|
|
217
|
+
)
|
|
218
|
+
} catch {
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** De billiga förutsättningarna, samma som korning.mjs:s
|
|
224
|
+
* lasUnderlagProduktion prövar först, plus vägran att prissätta om utan
|
|
225
|
+
* `--om`. `null` när posten får starta, annars orsaken. */
|
|
226
|
+
export function forkontroll(dir, { om = false } = {}) {
|
|
227
|
+
const state = lasState(dir)
|
|
228
|
+
if (state === null) return 'state.json saknas eller trasig'
|
|
229
|
+
if (state.experiment === true) return 'experimentkatalog'
|
|
230
|
+
if (arSplitrot(dir)) return `delad post: prissätt delarna ${path.basename(dir)}/del-N`
|
|
231
|
+
if (
|
|
232
|
+
state.approval === null ||
|
|
233
|
+
typeof state.approval !== 'object' ||
|
|
234
|
+
Array.isArray(state.approval)
|
|
235
|
+
)
|
|
236
|
+
return 'approval saknas: kör bedöm-grinden'
|
|
237
|
+
// Ett stående stopp (granskarblock.md, ett designkvitto, en analys) köper
|
|
238
|
+
// aldrig en analys och två scorers; inte heller med --om.
|
|
239
|
+
if (state.stopp !== null && typeof state.stopp === 'object' && !Array.isArray(state.stopp))
|
|
240
|
+
return `stopp står (${state.stopp.utfall}, ${state.stopp.kalla}): respec eller designa först`
|
|
241
|
+
if (!om && (state.status === 'priced' || state.status === 'guard_run'))
|
|
242
|
+
return 'redan prissatt (--om kör om)'
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const stampel = (d) => d.toISOString().replace(/[-:]/g, '').slice(0, 15)
|
|
247
|
+
|
|
248
|
+
/** Skapar `<post>/korningar/<YYYYMMDDTHHMMSS>` (UTC) och returnerar den;
|
|
249
|
+
* finns stämpeln redan prövas `-2`, `-3` och så vidare. */
|
|
250
|
+
export function korningsKatalog(post, now = new Date()) {
|
|
251
|
+
const rot = path.join(post, 'korningar')
|
|
252
|
+
mkdirSync(rot, { recursive: true })
|
|
253
|
+
const bas = stampel(now)
|
|
254
|
+
for (let n = 1; ; n++) {
|
|
255
|
+
const dir = path.join(rot, n === 1 ? bas : `${bas}-${n}`)
|
|
256
|
+
try {
|
|
257
|
+
mkdirSync(dir)
|
|
258
|
+
return dir
|
|
259
|
+
} catch (e) {
|
|
260
|
+
if (e?.code !== 'EEXIST') throw e
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** En designgranskning som stoppade utkastet (ADR-0156 beslut 3): posten
|
|
266
|
+
* står på designed med state.design.review.verdict `stopp`. */
|
|
267
|
+
const granskningenStoppade = (state) =>
|
|
268
|
+
state?.status === 'designed' && state?.design?.review?.verdict === 'stopp'
|
|
269
|
+
|
|
270
|
+
/** Statusen för en körd post. En process som föll eller dödades är
|
|
271
|
+
* `ofärdig` oavsett vad state.json säger; annars stopp (state.stopp, eller
|
|
272
|
+
* en designgranskning som stoppade på status designed), spärr (OLIKA eller
|
|
273
|
+
* annat som väntar på en dom), priced, och allt annat är en avvikelse. */
|
|
274
|
+
export function avgorStatus({ exitCode, signal = null, state, sparrExit }) {
|
|
275
|
+
if (signal || exitCode !== 0) {
|
|
276
|
+
const orsak =
|
|
277
|
+
exitCode === null || exitCode === undefined
|
|
278
|
+
? `processen dödades (${signal ?? 'okänd signal'})`
|
|
279
|
+
: `exit ${exitCode}`
|
|
280
|
+
return { status: 'ofärdig', orsak }
|
|
281
|
+
}
|
|
282
|
+
// Stoppets orsak följer med till tabellen (intervju krävs: …), så raden
|
|
283
|
+
// routas utan att någon läser state.json.
|
|
284
|
+
if (state?.stopp)
|
|
285
|
+
return typeof state.stopp.orsak === 'string' && state.stopp.orsak !== ''
|
|
286
|
+
? { status: 'stopp', orsak: state.stopp.orsak }
|
|
287
|
+
: { status: 'stopp' }
|
|
288
|
+
// P4-21: en granskning som stoppade utkastet står i state.design.review,
|
|
289
|
+
// inte i state.stopp; utan den här raden blev den `ofärdig` i tabellen.
|
|
290
|
+
if (granskningenStoppade(state))
|
|
291
|
+
return { status: 'stopp', orsak: 'designgranskningen stoppade (design-review.md)' }
|
|
292
|
+
if (sparrExit === 1) return { status: 'väntar på dom' }
|
|
293
|
+
if (state?.status === 'priced') return { status: 'priced' }
|
|
294
|
+
return { status: 'ofärdig', orsak: `status ${state?.status ?? 'okänd'} utan stopp eller spärr` }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Har summan (de klara posterna plus de i flykt till snittet) nått taket? */
|
|
298
|
+
export const kapNatt = (summaUsd, tak) => summaUsd >= tak
|
|
299
|
+
|
|
300
|
+
/** Standardlöparen för archive.mjs: synkron, cwd i posten. */
|
|
301
|
+
export const arkivStandard = (args, cwd) =>
|
|
302
|
+
spawnSync(process.execPath, [ARCHIVE, ...args], { cwd, encoding: 'utf8' })
|
|
303
|
+
|
|
304
|
+
const felText = (r) => (r.stderr || r.stdout || '').trim() || `exit ${r.status}`
|
|
305
|
+
|
|
306
|
+
/** Kvittot (beslut 3): `archive.mjs korning-start`. `{ ok: true }`,
|
|
307
|
+
* `{ pagar: true, pid }` när en levande pid redan håller posten, annars
|
|
308
|
+
* `{ fel }`. En övertagen död pid är ok; varningen skickas vidare. */
|
|
309
|
+
export function startaKorning(dir, pid, runDir = null, { arkivKor = arkivStandard } = {}) {
|
|
310
|
+
const r = arkivKor(
|
|
311
|
+
['korning-start', dir, '--pid', String(pid), ...(runDir ? ['--katalog-korning', runDir] : [])],
|
|
312
|
+
dir,
|
|
313
|
+
)
|
|
314
|
+
if (r.status === 0) {
|
|
315
|
+
const varning = /VARNING: .*/.exec(r.stderr ?? '')?.[0]
|
|
316
|
+
return varning ? { ok: true, varning } : { ok: true }
|
|
317
|
+
}
|
|
318
|
+
const pagar = /pågår \(pid (\d+)\)/.exec(r.stderr ?? '')
|
|
319
|
+
if (r.status === 2 && pagar) return { pagar: true, pid: Number(pagar[1]) }
|
|
320
|
+
return { fel: `korning-start: ${felText(r)}` }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Rensar kvittot med `archive.mjs korning-slut`. */
|
|
324
|
+
export function avslutaKorning(dir, { arkivKor = arkivStandard } = {}) {
|
|
325
|
+
const r = arkivKor(['korning-slut', dir], dir)
|
|
326
|
+
return r.status === 0 ? { ok: true } : { ok: false, fel: `korning-slut: ${felText(r)}` }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Poäng och utfall ur postens gällande huvudprisfil
|
|
330
|
+
* `score-pricing-v<maxN>.json` (utan suffix, samma fil som bluf läser), tomt
|
|
331
|
+
* vid ett stopp och `-` när ingen prisfil finns. */
|
|
332
|
+
export function lasPoang(dir) {
|
|
333
|
+
// En approximation: den högsta prisfilen på disk står för bluf:s upplösning
|
|
334
|
+
// av den gällande filen; BLUF:en i steg 8 kommer alltid ur bluf, aldrig härifrån.
|
|
335
|
+
const state = lasState(dir)
|
|
336
|
+
if (state?.stopp || granskningenStoppade(state)) return { poang: '', utfall: '' }
|
|
337
|
+
let maxN = 0
|
|
338
|
+
try {
|
|
339
|
+
for (const f of readdirSync(dir)) {
|
|
340
|
+
const m = /^score-pricing-v(\d+)\.json$/.exec(f)
|
|
341
|
+
if (m && Number(m[1]) > maxN) maxN = Number(m[1])
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
return { poang: '-', utfall: '-' }
|
|
345
|
+
}
|
|
346
|
+
if (maxN === 0) return { poang: '-', utfall: '-' }
|
|
347
|
+
try {
|
|
348
|
+
const data = JSON.parse(readFileSync(path.join(dir, `score-pricing-v${maxN}.json`), 'utf8'))
|
|
349
|
+
return { poang: data?.score ?? '-', utfall: data?.outcome ?? '-' }
|
|
350
|
+
} catch {
|
|
351
|
+
return { poang: '-', utfall: '-' }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Sluttabellen: en rad per post i argv-ordning, USD med två decimaler. */
|
|
356
|
+
export function renderaTabell(rader) {
|
|
357
|
+
const wall = (ms) => (Number.isFinite(ms) ? `${ms}ms` : '-')
|
|
358
|
+
const cell = (v) => String(v ?? '-').replace(/\|/g, '\\|')
|
|
359
|
+
return [
|
|
360
|
+
'| post | status | poäng | utfall | wall | USD |',
|
|
361
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
362
|
+
...rader.map(
|
|
363
|
+
(r) =>
|
|
364
|
+
`| ${cell(r.post)} | ${cell(`${r.status}${r.orsak ? ` (${r.orsak})` : ''}`)} | ${cell(r.poang)} | ${cell(r.utfall)} | ${wall(r.wall_ms)} | ${usd(r.usd, 2)} |`,
|
|
365
|
+
),
|
|
366
|
+
].join('\n')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Batchen (löparna injiceras i testerna; standard är archive.mjs och korning.mjs)
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/** Argumenten till korning.mjs i produktionsläget (efter scriptets sökväg).
|
|
374
|
+
* `--startare` är startarens pid: korning.mjs vaktar den och avbryter sig
|
|
375
|
+
* själv om startaren dör utan att städa (SIGKILL). */
|
|
376
|
+
export const korningArgv = ({
|
|
377
|
+
runDir,
|
|
378
|
+
dir,
|
|
379
|
+
repoRot,
|
|
380
|
+
claude,
|
|
381
|
+
sprak,
|
|
382
|
+
designMode = 'intervju',
|
|
383
|
+
startare,
|
|
384
|
+
}) => [
|
|
385
|
+
'--katalog',
|
|
386
|
+
runDir,
|
|
387
|
+
'--post',
|
|
388
|
+
dir,
|
|
389
|
+
'--repo-rot',
|
|
390
|
+
repoRot,
|
|
391
|
+
'--modell',
|
|
392
|
+
'default',
|
|
393
|
+
'--claude',
|
|
394
|
+
claude,
|
|
395
|
+
'--startare',
|
|
396
|
+
String(startare),
|
|
397
|
+
'--design-mode',
|
|
398
|
+
designMode,
|
|
399
|
+
...(sprak ? ['--sprak', sprak] : []),
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
/** korning.mjs i produktionsläget via svep.mjs:s korInre (stderr.log och
|
|
403
|
+
* matning.json i körningskatalogen). */
|
|
404
|
+
const korningStandard = ({ runDir, dir, repoRot, claude, sprak, designMode, pagaende, startare }) =>
|
|
405
|
+
korInre({
|
|
406
|
+
katalog: runDir,
|
|
407
|
+
repoRot,
|
|
408
|
+
pagaende,
|
|
409
|
+
args: [KORNING, ...korningArgv({ runDir, dir, repoRot, claude, sprak, designMode, startare })],
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
const lasUsd = (runDir) => {
|
|
413
|
+
try {
|
|
414
|
+
const k = JSON.parse(readFileSync(path.join(runDir, 'korning.json'), 'utf8'))
|
|
415
|
+
return typeof k?.total_cost_usd === 'number' ? k.total_cost_usd : null
|
|
416
|
+
} catch {
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** En post, hela ordningen ur ADR-0155. Kastar aldrig medvetet; ett kast
|
|
422
|
+
* blir en `ofärdig`-rad i korBatch. */
|
|
423
|
+
async function korPost(post, ctx) {
|
|
424
|
+
const { ut, arkivKor, korKorning, kvitton, summa, tak, konto } = ctx
|
|
425
|
+
const rad = (status, extra = {}) => ({ post, status, ...extra })
|
|
426
|
+
const los = losPost(post, ctx.arkiv)
|
|
427
|
+
if (los.fel) {
|
|
428
|
+
ut(`GRIND ${post}: ${los.fel}`)
|
|
429
|
+
return rad('grind', { orsak: los.fel })
|
|
430
|
+
}
|
|
431
|
+
const { dir } = los
|
|
432
|
+
if (!existsSync(dir)) {
|
|
433
|
+
const orsak = `katalogen saknas: ${dir}`
|
|
434
|
+
ut(`GRIND ${post}: ${orsak}`)
|
|
435
|
+
return rad('grind', { orsak })
|
|
436
|
+
}
|
|
437
|
+
const grind = forkontroll(dir, { om: ctx.om })
|
|
438
|
+
if (grind) {
|
|
439
|
+
ut(`GRIND ${post}: ${grind}`)
|
|
440
|
+
return rad('grind', { orsak: grind, ...lasPoang(dir) })
|
|
441
|
+
}
|
|
442
|
+
if (kapNatt(summa(), tak)) {
|
|
443
|
+
ut(`TAK ${post}`)
|
|
444
|
+
return rad('tak nått')
|
|
445
|
+
}
|
|
446
|
+
const runDir = korningsKatalog(dir, ctx.now ? ctx.now() : new Date())
|
|
447
|
+
const start = startaKorning(dir, ctx.pid ?? process.pid, runDir, { arkivKor })
|
|
448
|
+
if (start.pagar) {
|
|
449
|
+
rmdirSync(runDir)
|
|
450
|
+
ut(`PÅGÅR ${post} (pid ${start.pid})`)
|
|
451
|
+
return rad('pågår', { orsak: `pid ${start.pid}` })
|
|
452
|
+
}
|
|
453
|
+
if (start.fel) {
|
|
454
|
+
rmdirSync(runDir)
|
|
455
|
+
ut(`KLAR ${post} ofärdig`)
|
|
456
|
+
return rad('ofärdig', { orsak: start.fel })
|
|
457
|
+
}
|
|
458
|
+
if (start.varning) console.error(`${post}: ${start.varning}`)
|
|
459
|
+
kvitton.add(dir)
|
|
460
|
+
konto.iFlykt++
|
|
461
|
+
let belopp = null
|
|
462
|
+
try {
|
|
463
|
+
ut(`START ${post}`)
|
|
464
|
+
const k = await korKorning({
|
|
465
|
+
runDir,
|
|
466
|
+
dir,
|
|
467
|
+
repoRot: ctx.repoRot,
|
|
468
|
+
claude: ctx.claude,
|
|
469
|
+
sprak: ctx.sprak,
|
|
470
|
+
designMode: ctx.designMode,
|
|
471
|
+
pagaende: ctx.pagaende,
|
|
472
|
+
startare: ctx.pid ?? process.pid,
|
|
473
|
+
})
|
|
474
|
+
belopp = lasUsd(runDir)
|
|
475
|
+
const avg = k?.spawnfel
|
|
476
|
+
? { status: 'ofärdig', orsak: `kunde inte starta korning.mjs: ${k.spawnfel}` }
|
|
477
|
+
: avgorStatus({
|
|
478
|
+
exitCode: k?.exitCode ?? null,
|
|
479
|
+
signal: k?.signal ?? null,
|
|
480
|
+
state: lasState(dir),
|
|
481
|
+
sparrExit: arkivKor(['sparr', dir], dir).status,
|
|
482
|
+
})
|
|
483
|
+
ut(`KLAR ${post} ${avg.status}`)
|
|
484
|
+
return rad(avg.status, {
|
|
485
|
+
...(avg.orsak ? { orsak: avg.orsak } : {}),
|
|
486
|
+
...lasPoang(dir),
|
|
487
|
+
wall_ms: k?.wall_ms ?? null,
|
|
488
|
+
usd: belopp,
|
|
489
|
+
})
|
|
490
|
+
} finally {
|
|
491
|
+
// Posten är klar för taket: ur flykten, in bland de klara med sin USD
|
|
492
|
+
// (en post utan korning.json räknas som 0).
|
|
493
|
+
konto.iFlykt--
|
|
494
|
+
konto.klara++
|
|
495
|
+
if (Number.isFinite(belopp)) konto.anvant += belopp
|
|
496
|
+
const slut = avslutaKorning(dir, { arkivKor })
|
|
497
|
+
if (!slut.ok) console.error(`VARNING ${post}: ${slut.fel}`)
|
|
498
|
+
kvitton.delete(dir)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Summan taket prövas mot: de klara posternas USD plus varje post i flykt
|
|
503
|
+
* till de klaras snittkostnad (0 så länge ingen är klar). */
|
|
504
|
+
export const taksumma = ({ anvant, klara, iFlykt }) =>
|
|
505
|
+
anvant + (klara > 0 ? iFlykt * (anvant / klara) : 0)
|
|
506
|
+
|
|
507
|
+
/** Kör posterna med högst `parallellt` i flykt. Taket prövas vid varje
|
|
508
|
+
* poststart mot `taksumma` (en post utan korning.json räknas som 0).
|
|
509
|
+
* Returnerar raderna i argv-ordning och de klara posternas summa. */
|
|
510
|
+
export async function korBatch({
|
|
511
|
+
poster,
|
|
512
|
+
arkiv,
|
|
513
|
+
repoRot,
|
|
514
|
+
parallellt,
|
|
515
|
+
tak,
|
|
516
|
+
om,
|
|
517
|
+
claude,
|
|
518
|
+
sprak,
|
|
519
|
+
designMode = 'intervju',
|
|
520
|
+
ut,
|
|
521
|
+
arkivKor = arkivStandard,
|
|
522
|
+
korKorning = korningStandard,
|
|
523
|
+
pagaende = new Map(),
|
|
524
|
+
kvitton = new Set(),
|
|
525
|
+
pid = process.pid,
|
|
526
|
+
now,
|
|
527
|
+
}) {
|
|
528
|
+
const konto = { anvant: 0, klara: 0, iFlykt: 0 }
|
|
529
|
+
const ctx = {
|
|
530
|
+
arkiv,
|
|
531
|
+
repoRot,
|
|
532
|
+
om,
|
|
533
|
+
claude,
|
|
534
|
+
sprak,
|
|
535
|
+
designMode,
|
|
536
|
+
tak,
|
|
537
|
+
ut,
|
|
538
|
+
arkivKor,
|
|
539
|
+
korKorning,
|
|
540
|
+
pagaende,
|
|
541
|
+
kvitton,
|
|
542
|
+
pid,
|
|
543
|
+
now,
|
|
544
|
+
konto,
|
|
545
|
+
summa: () => taksumma(konto),
|
|
546
|
+
}
|
|
547
|
+
const resultat = await schemalagg(poster, parallellt, (post) => korPost(post, ctx))
|
|
548
|
+
const rader = resultat.map((r, i) =>
|
|
549
|
+
r?.fel !== undefined ? { post: poster[i], status: 'ofärdig', orsak: r.fel } : r,
|
|
550
|
+
)
|
|
551
|
+
return { rader, anvant: konto.anvant }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Avbrottet (SIGINT eller SIGTERM): SIGTERM till varje körning,
|
|
555
|
+
* matning.json med signalen, och varje kvitto rensas, så nasta-steg inte
|
|
556
|
+
* visar en körning som inte längre finns. Exit 130. */
|
|
557
|
+
export const avbrottsHanterare =
|
|
558
|
+
({
|
|
559
|
+
pagaende,
|
|
560
|
+
kvitton,
|
|
561
|
+
avsluta = avslutaKorning,
|
|
562
|
+
exit = process.exit,
|
|
563
|
+
logg = (rad) => console.error(rad),
|
|
564
|
+
}) =>
|
|
565
|
+
(signal) => {
|
|
566
|
+
for (const [barn, info] of pagaende) {
|
|
567
|
+
barn.kill('SIGTERM')
|
|
568
|
+
const avslutad = new Date()
|
|
569
|
+
skrivMatning(info.katalog, {
|
|
570
|
+
startad: info.startad.toISOString(),
|
|
571
|
+
avslutad: avslutad.toISOString(),
|
|
572
|
+
wall_ms: avslutad.getTime() - info.startad.getTime(),
|
|
573
|
+
exit_code: null,
|
|
574
|
+
signal,
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
for (const dir of kvitton) avsluta(dir)
|
|
578
|
+
logg(
|
|
579
|
+
`avbrutet (${signal}): ${pagaende.size} körning(ar) dödade, ${kvitton.size} kvitto(n) rensade`,
|
|
580
|
+
)
|
|
581
|
+
exit(SIGNAL_EXIT)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Samma hanterare för varje signal i AVBROTTSSIGNALER. */
|
|
585
|
+
export function registreraAvbrott(proc, hanterare) {
|
|
586
|
+
for (const signal of AVBROTTSSIGNALER) proc.on(signal, () => hanterare(signal))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function main(argv) {
|
|
590
|
+
const val = tolkaArgv(argv)
|
|
591
|
+
if (val.fel) {
|
|
592
|
+
console.error(val.fel)
|
|
593
|
+
console.error(ANVANDNING)
|
|
594
|
+
return 2
|
|
595
|
+
}
|
|
596
|
+
if (!existsSync(val.repoRot)) {
|
|
597
|
+
console.error(`--repo-rot finns inte: ${val.repoRot}`)
|
|
598
|
+
return 2
|
|
599
|
+
}
|
|
600
|
+
const arkiv = val.arkiv ?? losArkiv(val.repoRot)
|
|
601
|
+
const felPoster = val.poster.map((p) => losPost(p, arkiv).fel).filter(Boolean)
|
|
602
|
+
if (felPoster.length > 0) {
|
|
603
|
+
for (const f of felPoster) console.error(f)
|
|
604
|
+
console.error(ANVANDNING)
|
|
605
|
+
return 2
|
|
606
|
+
}
|
|
607
|
+
const tak = lasTak({ arkiv, maxUsd: val.maxUsd })
|
|
608
|
+
const designMode = lasDesignlage({ arkiv, designMode: val.designMode })
|
|
609
|
+
const ut = (rad) => process.stdout.write(rad + '\n')
|
|
610
|
+
const pagaende = new Map()
|
|
611
|
+
const kvitton = new Set()
|
|
612
|
+
|
|
613
|
+
registreraAvbrott(process, avbrottsHanterare({ pagaende, kvitton }))
|
|
614
|
+
|
|
615
|
+
const { rader, anvant } = await korBatch({
|
|
616
|
+
poster: val.poster,
|
|
617
|
+
arkiv,
|
|
618
|
+
repoRot: val.repoRot,
|
|
619
|
+
parallellt: val.parallellt,
|
|
620
|
+
tak,
|
|
621
|
+
om: val.om,
|
|
622
|
+
claude: val.claude,
|
|
623
|
+
sprak: val.sprak,
|
|
624
|
+
designMode,
|
|
625
|
+
ut,
|
|
626
|
+
pagaende,
|
|
627
|
+
kvitton,
|
|
628
|
+
})
|
|
629
|
+
ut('')
|
|
630
|
+
ut(renderaTabell(rader))
|
|
631
|
+
ut(`tak: ${tak} USD, använt: ${anvant.toFixed(2)} USD`)
|
|
632
|
+
return rader.every((r) => RESULTAT.includes(r.status)) ? 0 : 1
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Huvudloopen körs bara när scriptet startas direkt; en import (testerna)
|
|
636
|
+
* exekverar ingenting. */
|
|
637
|
+
const arDirektKord = () => {
|
|
638
|
+
try {
|
|
639
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
640
|
+
} catch {
|
|
641
|
+
return false
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (arDirektKord())
|
|
646
|
+
main(process.argv.slice(2))
|
|
647
|
+
.then((kod) => process.exit(kod))
|
|
648
|
+
.catch((e) => {
|
|
649
|
+
console.error(e?.stack ?? String(e))
|
|
650
|
+
process.exit(2)
|
|
651
|
+
})
|