@tduyng/pi-harness-loop 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/check.ts +57 -7
- package/src/diversity.ts +1 -1
- package/src/install.ts +14 -3
- package/src/store.ts +10 -4
package/package.json
CHANGED
package/src/check.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { createLocalBashOperations } from '@earendil-works/pi-coding-agent'
|
|
4
|
+
import { createLocalBashOperations, truncateTail } from '@earendil-works/pi-coding-agent'
|
|
5
5
|
import { Validator } from 'ata-validator'
|
|
6
6
|
import { t } from 'ata-validator/t'
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
import { runCheck as runSharedCheck, type ExecFn } from '../../pi-goal/src/check.js'
|
|
10
|
-
|
|
11
8
|
export interface CheckResult {
|
|
12
9
|
command: string
|
|
13
10
|
ok: boolean
|
|
@@ -22,6 +19,13 @@ const PackageJson = t.object({
|
|
|
22
19
|
})
|
|
23
20
|
const isPackageJson = new Validator(PackageJson).isValidObject
|
|
24
21
|
|
|
22
|
+
const TEST_BY_LOCKFILE = [
|
|
23
|
+
['bun.lock', 'bun run test'],
|
|
24
|
+
['bun.lockb', 'bun run test'],
|
|
25
|
+
['pnpm-lock.yaml', 'pnpm test'],
|
|
26
|
+
['yarn.lock', 'yarn test']
|
|
27
|
+
] as const satisfies readonly (readonly [string, string])[]
|
|
28
|
+
|
|
25
29
|
function packageTest(cwd: string): string {
|
|
26
30
|
const path = join(cwd, 'package.json')
|
|
27
31
|
if (!existsSync(path)) return ''
|
|
@@ -33,7 +37,8 @@ function packageTest(cwd: string): string {
|
|
|
33
37
|
}
|
|
34
38
|
if (!isPackageJson(parsed)) return ''
|
|
35
39
|
if (parsed.scripts?.['test'] === undefined) return ''
|
|
36
|
-
|
|
40
|
+
const lockfile = TEST_BY_LOCKFILE.find(([file]) => existsSync(join(cwd, file)))
|
|
41
|
+
return lockfile?.[1] ?? 'npm test'
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
function justTest(cwd: string): string {
|
|
@@ -54,14 +59,59 @@ export function failedCount(output: string): number | null {
|
|
|
54
59
|
return generic?.[1] === undefined ? null : Number(generic[1])
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
export function failureNames(output: string): string[] | null {
|
|
63
|
+
const names = [...output.matchAll(/^\s*(?:\(fail\)|✕|×|✗)\s+(.+)$/gm)]
|
|
64
|
+
.map((match) => (match[1] ?? '').trim())
|
|
65
|
+
.filter((name) => name !== '')
|
|
66
|
+
if (names.length > 0) return names.toSorted()
|
|
67
|
+
return failedCount(output) === 0 ? [] : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MAX_CHECK_OUTPUT_BYTES = 1500
|
|
71
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 300_000
|
|
72
|
+
|
|
73
|
+
async function runSharedCheck(
|
|
74
|
+
cwd: string,
|
|
75
|
+
command: string,
|
|
76
|
+
options: { timeoutMs?: number } = {}
|
|
77
|
+
): Promise<{ ok: boolean; exitCode: number | null; durationMs: number; output: string }> {
|
|
78
|
+
const timeoutMs = options.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_CHECK_TIMEOUT_MS
|
|
79
|
+
let captured = Buffer.alloc(0)
|
|
80
|
+
const started = Date.now()
|
|
81
|
+
const finish = (exitCode: number | null, error?: string) => {
|
|
82
|
+
const durationMs = Date.now() - started
|
|
83
|
+
const output = captured.toString('utf8')
|
|
84
|
+
const body = error ? `${output}${output ? '\n' : ''}${error}` : output
|
|
85
|
+
const tail = truncateTail(body, { maxBytes: MAX_CHECK_OUTPUT_BYTES })
|
|
86
|
+
return {
|
|
87
|
+
ok: exitCode === 0,
|
|
88
|
+
exitCode,
|
|
89
|
+
durationMs,
|
|
90
|
+
output: tail.content.trim()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let exitCode: number | null
|
|
94
|
+
try {
|
|
95
|
+
;({ exitCode } = await createLocalBashOperations().exec(command, cwd, {
|
|
96
|
+
onData: (data) => {
|
|
97
|
+
const combined = Buffer.concat([captured, data])
|
|
98
|
+
captured = combined.subarray(Math.max(0, combined.length - MAX_CHECK_OUTPUT_BYTES))
|
|
99
|
+
},
|
|
100
|
+
timeout: timeoutMs / 1000
|
|
101
|
+
}))
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
104
|
+
return finish(null, message)
|
|
105
|
+
}
|
|
106
|
+
return finish(exitCode)
|
|
107
|
+
}
|
|
58
108
|
|
|
59
109
|
export async function runCheck(
|
|
60
110
|
cwd: string,
|
|
61
111
|
command: string,
|
|
62
112
|
options: { timeoutMs?: number } = {}
|
|
63
113
|
): Promise<CheckResult> {
|
|
64
|
-
const result = await runSharedCheck(
|
|
114
|
+
const result = await runSharedCheck(cwd, command, options)
|
|
65
115
|
return {
|
|
66
116
|
command,
|
|
67
117
|
ok: result.ok,
|
package/src/diversity.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface ProposalText {
|
|
|
9
9
|
export function proposalTexts(harnessDir: string): ProposalText[] {
|
|
10
10
|
if (!existsSync(harnessDir)) return []
|
|
11
11
|
const out: ProposalText[] = []
|
|
12
|
-
for (const name of readdirSync(harnessDir)) {
|
|
12
|
+
for (const name of readdirSync(harnessDir).toSorted()) {
|
|
13
13
|
const dir = join(harnessDir, name)
|
|
14
14
|
let entries: string[]
|
|
15
15
|
try {
|
package/src/install.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
import { applyProposal, readProposal, revertApplied } from './apply.js'
|
|
6
6
|
import { readScan } from './auto.js'
|
|
7
|
-
import { discoverCheck, runCheck } from './check.js'
|
|
7
|
+
import { discoverCheck, failureNames, runCheck } from './check.js'
|
|
8
8
|
import { runReplay } from './replay.js'
|
|
9
9
|
import { acquireLock, appendInstall, readInstalls, readSettings } from './store.js'
|
|
10
10
|
import { loadHarness } from './tree.js'
|
|
@@ -270,15 +270,26 @@ async function installLocked(
|
|
|
270
270
|
key: options.key,
|
|
271
271
|
cost: advertisedChars(applied)
|
|
272
272
|
}
|
|
273
|
-
if (check === '')
|
|
273
|
+
if (check === '')
|
|
274
|
+
return finalize(cwd, dir, install, context, `${refusal}, installed without a check`, options.command)
|
|
274
275
|
|
|
275
276
|
const result = await runCheck(cwd, check)
|
|
276
277
|
if (result.ok) return finalize(cwd, dir, install, context, refusal, options.command)
|
|
277
278
|
|
|
278
279
|
revertApplied(applied, installRoots(context), true)
|
|
279
280
|
const baseline = await runCheck(cwd, check)
|
|
281
|
+
const resultNames = failureNames(result.output)
|
|
282
|
+
const baselineNames = failureNames(baseline.output)
|
|
283
|
+
const sameFailure =
|
|
284
|
+
result.output === baseline.output ||
|
|
285
|
+
(resultNames !== null && baselineNames !== null && resultNames.every((name) => baselineNames.includes(name)))
|
|
280
286
|
const innocent =
|
|
281
|
-
!baseline.ok &&
|
|
287
|
+
!baseline.ok &&
|
|
288
|
+
result.failures !== null &&
|
|
289
|
+
result.failures > 0 &&
|
|
290
|
+
baseline.failures !== null &&
|
|
291
|
+
result.failures <= baseline.failures &&
|
|
292
|
+
sameFailure
|
|
282
293
|
if (innocent) {
|
|
283
294
|
for (const file of applied) {
|
|
284
295
|
mkdirSync(dirname(file.to), { recursive: true })
|
package/src/store.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
2
3
|
import { dirname, join } from 'node:path'
|
|
3
4
|
|
|
4
5
|
import { Validator } from 'ata-validator'
|
|
5
6
|
import { t } from 'ata-validator/t'
|
|
6
7
|
|
|
7
|
-
|
|
8
8
|
import { writeFileAtomic } from './atomic.js'
|
|
9
9
|
import type { HarnessSettings, Install, Lesson } from './types.js'
|
|
10
10
|
|
|
@@ -243,9 +243,10 @@ const LOCK_STALE_MS = 5 * 60_000
|
|
|
243
243
|
export function acquireLock(cwd: string): (() => void) | null {
|
|
244
244
|
const path = join(harnessDir(cwd), 'lock')
|
|
245
245
|
mkdirSync(dirname(path), { recursive: true })
|
|
246
|
+
const token = `${process.pid} ${Date.now()} ${randomUUID()}\n`
|
|
246
247
|
const take = (): boolean => {
|
|
247
248
|
try {
|
|
248
|
-
writeFileSync(path,
|
|
249
|
+
writeFileSync(path, token, { encoding: 'utf8', flag: 'wx' })
|
|
249
250
|
return true
|
|
250
251
|
} catch {
|
|
251
252
|
return false
|
|
@@ -253,12 +254,17 @@ export function acquireLock(cwd: string): (() => void) | null {
|
|
|
253
254
|
}
|
|
254
255
|
if (!take()) {
|
|
255
256
|
try {
|
|
256
|
-
const
|
|
257
|
+
const existing = readFileSync(path, 'utf8')
|
|
258
|
+
const at = Number(existing.split(' ')[1])
|
|
257
259
|
if (Number.isFinite(at) && Date.now() - at > LOCK_STALE_MS) rmSync(path, { force: true })
|
|
258
260
|
} catch {}
|
|
259
261
|
if (!take()) return null
|
|
260
262
|
}
|
|
261
|
-
return () =>
|
|
263
|
+
return () => {
|
|
264
|
+
try {
|
|
265
|
+
if (readFileSync(path, 'utf8') === token) rmSync(path, { force: true })
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
262
268
|
}
|
|
263
269
|
|
|
264
270
|
export function recordLessonUse(cwd: string, id: string): void {
|