@haus-tech/haus-workflow 5.12.1 → 6.1.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.
@@ -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
+ })