opencastle 0.27.0 → 0.27.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/bin/cli.mjs +6 -0
- package/dist/cli/agents.d.ts +3 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +161 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/baselines.d.ts +3 -0
- package/dist/cli/baselines.d.ts.map +1 -0
- package/dist/cli/baselines.js +128 -0
- package/dist/cli/baselines.js.map +1 -0
- package/dist/cli/convoy/dashboard-types.d.ts +146 -0
- package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
- package/dist/cli/convoy/dashboard-types.js +2 -0
- package/dist/cli/convoy/dashboard-types.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +67 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2036 -28
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1659 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/event-schemas.d.ts +9 -0
- package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
- package/dist/cli/convoy/event-schemas.js +185 -0
- package/dist/cli/convoy/event-schemas.js.map +1 -0
- package/dist/cli/convoy/events.d.ts +12 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +186 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +325 -28
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/expertise.d.ts +16 -0
- package/dist/cli/convoy/expertise.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.js +121 -0
- package/dist/cli/convoy/expertise.js.map +1 -0
- package/dist/cli/convoy/expertise.test.d.ts +2 -0
- package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.test.js +96 -0
- package/dist/cli/convoy/expertise.test.js.map +1 -0
- package/dist/cli/convoy/export.test.js +1 -0
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/formula.d.ts +19 -0
- package/dist/cli/convoy/formula.d.ts.map +1 -0
- package/dist/cli/convoy/formula.js +142 -0
- package/dist/cli/convoy/formula.js.map +1 -0
- package/dist/cli/convoy/formula.test.d.ts +2 -0
- package/dist/cli/convoy/formula.test.d.ts.map +1 -0
- package/dist/cli/convoy/formula.test.js +342 -0
- package/dist/cli/convoy/formula.test.js.map +1 -0
- package/dist/cli/convoy/gates.d.ts +128 -0
- package/dist/cli/convoy/gates.d.ts.map +1 -0
- package/dist/cli/convoy/gates.js +606 -0
- package/dist/cli/convoy/gates.js.map +1 -0
- package/dist/cli/convoy/gates.test.d.ts +2 -0
- package/dist/cli/convoy/gates.test.d.ts.map +1 -0
- package/dist/cli/convoy/gates.test.js +976 -0
- package/dist/cli/convoy/gates.test.js.map +1 -0
- package/dist/cli/convoy/health.d.ts +11 -0
- package/dist/cli/convoy/health.d.ts.map +1 -1
- package/dist/cli/convoy/health.js +54 -0
- package/dist/cli/convoy/health.js.map +1 -1
- package/dist/cli/convoy/health.test.js +56 -1
- package/dist/cli/convoy/health.test.js.map +1 -1
- package/dist/cli/convoy/issues.d.ts +8 -0
- package/dist/cli/convoy/issues.d.ts.map +1 -0
- package/dist/cli/convoy/issues.js +98 -0
- package/dist/cli/convoy/issues.js.map +1 -0
- package/dist/cli/convoy/issues.test.d.ts +2 -0
- package/dist/cli/convoy/issues.test.d.ts.map +1 -0
- package/dist/cli/convoy/issues.test.js +107 -0
- package/dist/cli/convoy/issues.test.js.map +1 -0
- package/dist/cli/convoy/knowledge.d.ts +5 -0
- package/dist/cli/convoy/knowledge.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.js +116 -0
- package/dist/cli/convoy/knowledge.js.map +1 -0
- package/dist/cli/convoy/knowledge.test.d.ts +2 -0
- package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.test.js +87 -0
- package/dist/cli/convoy/knowledge.test.js.map +1 -0
- package/dist/cli/convoy/lessons.d.ts +17 -0
- package/dist/cli/convoy/lessons.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.js +149 -0
- package/dist/cli/convoy/lessons.js.map +1 -0
- package/dist/cli/convoy/lessons.test.d.ts +2 -0
- package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.test.js +135 -0
- package/dist/cli/convoy/lessons.test.js.map +1 -0
- package/dist/cli/convoy/lock.d.ts +13 -0
- package/dist/cli/convoy/lock.d.ts.map +1 -0
- package/dist/cli/convoy/lock.js +88 -0
- package/dist/cli/convoy/lock.js.map +1 -0
- package/dist/cli/convoy/lock.test.d.ts +2 -0
- package/dist/cli/convoy/lock.test.d.ts.map +1 -0
- package/dist/cli/convoy/lock.test.js +136 -0
- package/dist/cli/convoy/lock.test.js.map +1 -0
- package/dist/cli/convoy/log-merge.test.d.ts +2 -0
- package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
- package/dist/cli/convoy/log-merge.test.js +147 -0
- package/dist/cli/convoy/log-merge.test.js.map +1 -0
- package/dist/cli/convoy/merge.d.ts +4 -0
- package/dist/cli/convoy/merge.d.ts.map +1 -1
- package/dist/cli/convoy/merge.js +18 -1
- package/dist/cli/convoy/merge.js.map +1 -1
- package/dist/cli/convoy/merge.test.js +6 -7
- package/dist/cli/convoy/merge.test.js.map +1 -1
- package/dist/cli/convoy/partition.d.ts +51 -0
- package/dist/cli/convoy/partition.d.ts.map +1 -0
- package/dist/cli/convoy/partition.js +186 -0
- package/dist/cli/convoy/partition.js.map +1 -0
- package/dist/cli/convoy/partition.test.d.ts +2 -0
- package/dist/cli/convoy/partition.test.d.ts.map +1 -0
- package/dist/cli/convoy/partition.test.js +315 -0
- package/dist/cli/convoy/partition.test.js.map +1 -0
- package/dist/cli/convoy/pipeline.test.js +6 -0
- package/dist/cli/convoy/pipeline.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +99 -7
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +764 -31
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1810 -18
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +427 -5
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/convoy/types.js +42 -1
- package/dist/cli/convoy/types.js.map +1 -1
- package/dist/cli/log.d.ts +11 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +114 -2
- package/dist/cli/log.js.map +1 -1
- package/dist/cli/run/adapters/claude.d.ts +2 -0
- package/dist/cli/run/adapters/claude.d.ts.map +1 -1
- package/dist/cli/run/adapters/claude.js +89 -49
- package/dist/cli/run/adapters/claude.js.map +1 -1
- package/dist/cli/run/adapters/claude.test.d.ts +2 -0
- package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude.test.js +205 -0
- package/dist/cli/run/adapters/claude.test.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +1 -0
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +84 -46
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
- package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/copilot.test.js +195 -0
- package/dist/cli/run/adapters/copilot.test.js.map +1 -0
- package/dist/cli/run/adapters/cursor.d.ts +1 -0
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/run/adapters/cursor.js +83 -47
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
- package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/cursor.test.js +129 -0
- package/dist/cli/run/adapters/cursor.test.js.map +1 -0
- package/dist/cli/run/adapters/opencode.d.ts +1 -0
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/run/adapters/opencode.js +81 -47
- package/dist/cli/run/adapters/opencode.js.map +1 -1
- package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
- package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/opencode.test.js +119 -0
- package/dist/cli/run/adapters/opencode.test.js.map +1 -0
- package/dist/cli/run/executor.js +1 -1
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +245 -4
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +669 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +362 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +85 -2
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js.map +1 -1
- package/dist/cli/watch.d.ts +15 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +279 -0
- package/dist/cli/watch.js.map +1 -0
- package/package.json +5 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/TELEMETRY.md +203 -0
- package/src/cli/convoy/dashboard-types.ts +141 -0
- package/src/cli/convoy/engine.test.ts +1937 -70
- package/src/cli/convoy/engine.ts +2350 -40
- package/src/cli/convoy/event-schemas.ts +195 -0
- package/src/cli/convoy/events.test.ts +384 -39
- package/src/cli/convoy/events.ts +202 -16
- package/src/cli/convoy/expertise.test.ts +128 -0
- package/src/cli/convoy/expertise.ts +163 -0
- package/src/cli/convoy/export.test.ts +1 -0
- package/src/cli/convoy/formula.test.ts +405 -0
- package/src/cli/convoy/formula.ts +174 -0
- package/src/cli/convoy/gates.test.ts +1169 -0
- package/src/cli/convoy/gates.ts +774 -0
- package/src/cli/convoy/health.test.ts +64 -2
- package/src/cli/convoy/health.ts +80 -2
- package/src/cli/convoy/issues.test.ts +143 -0
- package/src/cli/convoy/issues.ts +136 -0
- package/src/cli/convoy/knowledge.test.ts +101 -0
- package/src/cli/convoy/knowledge.ts +132 -0
- package/src/cli/convoy/lessons.test.ts +188 -0
- package/src/cli/convoy/lessons.ts +164 -0
- package/src/cli/convoy/lock.test.ts +181 -0
- package/src/cli/convoy/lock.ts +103 -0
- package/src/cli/convoy/log-merge.test.ts +179 -0
- package/src/cli/convoy/merge.test.ts +6 -7
- package/src/cli/convoy/merge.ts +19 -1
- package/src/cli/convoy/partition.test.ts +423 -0
- package/src/cli/convoy/partition.ts +232 -0
- package/src/cli/convoy/pipeline.test.ts +6 -0
- package/src/cli/convoy/store.test.ts +2041 -20
- package/src/cli/convoy/store.ts +945 -46
- package/src/cli/convoy/types.ts +278 -4
- package/src/cli/log.ts +120 -2
- package/src/cli/run/adapters/claude.test.ts +234 -0
- package/src/cli/run/adapters/claude.ts +45 -5
- package/src/cli/run/adapters/copilot.test.ts +224 -0
- package/src/cli/run/adapters/copilot.ts +34 -4
- package/src/cli/run/adapters/cursor.test.ts +144 -0
- package/src/cli/run/adapters/cursor.ts +33 -2
- package/src/cli/run/adapters/opencode.test.ts +135 -0
- package/src/cli/run/adapters/opencode.ts +30 -2
- package/src/cli/run/executor.ts +1 -1
- package/src/cli/run/schema.test.ts +758 -0
- package/src/cli/run/schema.ts +300 -25
- package/src/cli/run.ts +341 -21
- package/src/cli/types.ts +86 -1
- package/src/cli/watch.ts +298 -0
- package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
- package/src/dashboard/dist/data/.gitkeep +0 -0
- package/src/dashboard/dist/data/convoy-list.json +1 -0
- package/src/dashboard/dist/data/overall-stats.json +24 -0
- package/src/dashboard/dist/index.html +701 -3
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/public/data/.gitkeep +0 -0
- package/src/dashboard/public/data/convoy-list.json +1 -0
- package/src/dashboard/public/data/overall-stats.json +24 -0
- package/src/dashboard/scripts/etl.test.ts +210 -0
- package/src/dashboard/scripts/etl.ts +108 -0
- package/src/dashboard/scripts/integration-test.ts +504 -0
- package/src/dashboard/src/pages/index.astro +854 -15
- package/src/dashboard/src/styles/dashboard.css +557 -1
- package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { scanForSecrets } from './gates.js'
|
|
4
|
+
|
|
5
|
+
const KNOWLEDGE_PATH = '.opencastle/KNOWLEDGE-GRAPH.md'
|
|
6
|
+
const TABLE_HEADER =
|
|
7
|
+
'| source | target | relationship | date | convoy_id |\n' +
|
|
8
|
+
'|--------|--------|--------------|------|-----------|\n'
|
|
9
|
+
|
|
10
|
+
function extractChangedFiles(diffOutput: string): string[] {
|
|
11
|
+
const files: string[] = []
|
|
12
|
+
const re = /^diff --git a\/.+ b\/(.+)$/gm
|
|
13
|
+
let m: RegExpExecArray | null
|
|
14
|
+
while ((m = re.exec(diffOutput)) !== null) {
|
|
15
|
+
const fileName = m[1]
|
|
16
|
+
if (isTargetFile(fileName)) files.push(fileName)
|
|
17
|
+
}
|
|
18
|
+
return files
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isTargetFile(fileName: string): boolean {
|
|
22
|
+
if (!/\.(ts|js)$/.test(fileName)) return false
|
|
23
|
+
if (/\.(test|spec)\.(ts|js)$/.test(fileName)) return false
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractFileDiff(diffOutput: string, fileName: string): string {
|
|
28
|
+
const escaped = fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
29
|
+
const re = new RegExp(
|
|
30
|
+
'diff --git a/' + escaped + ' b/' + escaped + '([\\s\\S]*?)(?=diff --git |$)',
|
|
31
|
+
)
|
|
32
|
+
const m = diffOutput.match(re)
|
|
33
|
+
return m ? m[0] : ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractImports(diffContent: string): Array<{ source: string; target: string }> {
|
|
37
|
+
const imports: Array<{ source: string; target: string }> = []
|
|
38
|
+
const headerMatch = diffContent.match(/^diff --git a\/(.+) b\//)
|
|
39
|
+
if (!headerMatch) return []
|
|
40
|
+
const sourceFile = headerMatch[1]
|
|
41
|
+
|
|
42
|
+
for (const line of diffContent.split('\n')) {
|
|
43
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue
|
|
44
|
+
const content = line.slice(1)
|
|
45
|
+
|
|
46
|
+
const esmMatch = content.match(/import\s+.*\s+from\s+['"](\.[^'"]+)['"]/)
|
|
47
|
+
if (esmMatch) {
|
|
48
|
+
imports.push({ source: sourceFile, target: esmMatch[1] })
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
const reqMatch = content.match(/require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/)
|
|
52
|
+
if (reqMatch) {
|
|
53
|
+
imports.push({ source: sourceFile, target: reqMatch[1] })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return imports
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseExistingRowKeys(content: string): Set<string> {
|
|
61
|
+
const keys = new Set<string>()
|
|
62
|
+
for (const line of content.split('\n')) {
|
|
63
|
+
const parts = line.split('|').map(p => p.trim()).filter(Boolean)
|
|
64
|
+
if (parts.length >= 2 && parts[0] !== 'source') {
|
|
65
|
+
keys.add(parts[0] + '|' + parts[1])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return keys
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildKnowledgeGraph(
|
|
72
|
+
diffOutput: string,
|
|
73
|
+
convoyId: string,
|
|
74
|
+
basePath?: string,
|
|
75
|
+
): { added: number; skipped: number } {
|
|
76
|
+
const base = basePath ?? process.cwd()
|
|
77
|
+
const filePath = join(base, KNOWLEDGE_PATH)
|
|
78
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
79
|
+
|
|
80
|
+
const changedFiles = extractChangedFiles(diffOutput)
|
|
81
|
+
const allImports: Array<{ source: string; target: string }> = []
|
|
82
|
+
for (const file of changedFiles) {
|
|
83
|
+
allImports.push(...extractImports(extractFileDiff(diffOutput, file)))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (allImports.length === 0) return { added: 0, skipped: 0 }
|
|
87
|
+
|
|
88
|
+
const existingContent = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
|
|
89
|
+
const existingKeys = parseExistingRowKeys(existingContent)
|
|
90
|
+
|
|
91
|
+
const newRows: string[] = []
|
|
92
|
+
let skipped = 0
|
|
93
|
+
|
|
94
|
+
for (const { source, target } of allImports) {
|
|
95
|
+
const key = source + '|' + target
|
|
96
|
+
if (existingKeys.has(key)) {
|
|
97
|
+
skipped++
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const row = '| ' + source + ' | ' + target + ' | imports | ' + date + ' | ' + convoyId + ' |'
|
|
101
|
+
const scan = scanForSecrets(row, 'knowledge-graph')
|
|
102
|
+
if (!scan.clean) {
|
|
103
|
+
skipped++
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
newRows.push(row)
|
|
107
|
+
existingKeys.add(key)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (newRows.length === 0) return { added: 0, skipped }
|
|
111
|
+
|
|
112
|
+
let fileContent: string
|
|
113
|
+
if (!existsSync(filePath)) {
|
|
114
|
+
fileContent =
|
|
115
|
+
'# Knowledge Graph\n\nFile dependency relationships discovered during convoy runs.\n\n' +
|
|
116
|
+
TABLE_HEADER +
|
|
117
|
+
newRows.join('\n') +
|
|
118
|
+
'\n'
|
|
119
|
+
} else {
|
|
120
|
+
const hasHeader = existingContent.includes('| source | target |')
|
|
121
|
+
fileContent =
|
|
122
|
+
(hasHeader
|
|
123
|
+
? existingContent.trimEnd()
|
|
124
|
+
: existingContent.trimEnd() + '\n\n' + TABLE_HEADER.trimEnd()) +
|
|
125
|
+
'\n' +
|
|
126
|
+
newRows.join('\n') +
|
|
127
|
+
'\n'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
writeFileSync(filePath, fileContent, 'utf8')
|
|
131
|
+
return { added: newRows.length, skipped }
|
|
132
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, realpathSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { readLessons, captureLessons, consolidateLessons } from './lessons.js'
|
|
6
|
+
|
|
7
|
+
vi.mock('./gates.js', () => ({
|
|
8
|
+
scanForSecrets: vi.fn(() => ({ clean: true, findings: [] })),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const LESSONS_REL = '.opencastle/LESSONS-LEARNED.md'
|
|
12
|
+
|
|
13
|
+
function makeBase(): string {
|
|
14
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'lessons-test-')))
|
|
15
|
+
mkdirSync(join(dir, '.opencastle'), { recursive: true })
|
|
16
|
+
return dir
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let tmpDir: string
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
tmpDir = makeBase()
|
|
23
|
+
vi.clearAllMocks()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('readLessons', () => {
|
|
31
|
+
it('returns empty array when file does not exist', () => {
|
|
32
|
+
const result = readLessons('developer', [], tmpDir)
|
|
33
|
+
expect(result).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns empty array when file has no LES entries', () => {
|
|
37
|
+
writeFileSync(join(tmpDir, LESSONS_REL), '# Lessons Learned\n\nNo entries yet.\n')
|
|
38
|
+
const result = readLessons('developer', [], tmpDir)
|
|
39
|
+
expect(result).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('filters by agent name', () => {
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(tmpDir, LESSONS_REL),
|
|
45
|
+
'# Lessons\n\n' +
|
|
46
|
+
'### LES-001: Dev tip\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\n| **Agent** | developer |\nText about developer agent.\n\n' +
|
|
47
|
+
'### LES-002: Other tip\n| **Category** | `git` |\n| **Added** | 2026-01-02 |\n| **Agent** | reviewer |\nText about reviewer agent.\n',
|
|
48
|
+
)
|
|
49
|
+
const result = readLessons('developer', [], tmpDir)
|
|
50
|
+
expect(result).toHaveLength(1)
|
|
51
|
+
expect(result[0]).toContain('Dev tip')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('filters by file paths', () => {
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(tmpDir, LESSONS_REL),
|
|
57
|
+
'# Lessons\n\n' +
|
|
58
|
+
'### LES-001: File tip\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\nText about src/cli/engine.ts file.\n\n' +
|
|
59
|
+
'### LES-002: Unrelated\n| **Category** | `git` |\n| **Added** | 2026-01-02 |\nSome other content.\n',
|
|
60
|
+
)
|
|
61
|
+
const result = readLessons('unknown', ['src/cli/engine.ts'], tmpDir)
|
|
62
|
+
expect(result).toHaveLength(1)
|
|
63
|
+
expect(result[0]).toContain('File tip')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns maximum 5 entries', () => {
|
|
67
|
+
let content = '# Lessons\n\n'
|
|
68
|
+
for (let i = 1; i <= 8; i++) {
|
|
69
|
+
content += `### LES-00${i}: Tip ${i}\n| **Category** | \`general\` |\n| **Added** | 2026-01-0${i} |\nText about developer agent.\n\n`
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(join(tmpDir, LESSONS_REL), content)
|
|
72
|
+
const result = readLessons('developer', [], tmpDir)
|
|
73
|
+
expect(result.length).toBeLessThanOrEqual(5)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('prioritizes agent+file matches over agent-only matches', () => {
|
|
77
|
+
writeFileSync(
|
|
78
|
+
join(tmpDir, LESSONS_REL),
|
|
79
|
+
'# Lessons\n\n' +
|
|
80
|
+
'### LES-001: Agent only\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\nContent about developer.\n\n' +
|
|
81
|
+
'### LES-002: Agent and file\n| **Category** | `general` |\n| **Added** | 2026-01-02 |\nContent about developer and src/app.ts.\n',
|
|
82
|
+
)
|
|
83
|
+
const result = readLessons('developer', ['src/app.ts'], tmpDir)
|
|
84
|
+
expect(result[0]).toContain('Agent and file')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('captureLessons', () => {
|
|
89
|
+
it('appends an entry with correct format', () => {
|
|
90
|
+
const result = captureLessons(
|
|
91
|
+
{ title: 'Test lesson', category: 'general', agent: 'developer', problem: 'A problem', solution: 'A solution' },
|
|
92
|
+
tmpDir,
|
|
93
|
+
)
|
|
94
|
+
expect(result.captured).toBe(true)
|
|
95
|
+
const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
|
|
96
|
+
expect(content).toContain('### LES-001: Test lesson')
|
|
97
|
+
expect(content).toContain('**Problem:** A problem')
|
|
98
|
+
expect(content).toContain('**Correct approach:** A solution')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('creates file if it does not exist', () => {
|
|
102
|
+
captureLessons(
|
|
103
|
+
{ title: 'First', category: 'general', agent: 'x', problem: 'p', solution: 's' },
|
|
104
|
+
tmpDir,
|
|
105
|
+
)
|
|
106
|
+
expect(readFileSync(join(tmpDir, LESSONS_REL), 'utf8')).toContain('### LES-001: First')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('auto-increments LES number', () => {
|
|
110
|
+
writeFileSync(join(tmpDir, LESSONS_REL), '# Lessons\n\n### LES-007: Existing\n')
|
|
111
|
+
captureLessons(
|
|
112
|
+
{ title: 'New', category: 'general', agent: 'x', problem: 'p', solution: 's' },
|
|
113
|
+
tmpDir,
|
|
114
|
+
)
|
|
115
|
+
const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
|
|
116
|
+
expect(content).toContain('### LES-008: New')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('includes files note when files provided', () => {
|
|
120
|
+
captureLessons(
|
|
121
|
+
{ title: 'With files', category: 'general', agent: 'x', problem: 'p', solution: 's', files: ['foo.ts', 'bar.ts'] },
|
|
122
|
+
tmpDir,
|
|
123
|
+
)
|
|
124
|
+
const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
|
|
125
|
+
expect(content).toContain('foo.ts')
|
|
126
|
+
expect(content).toContain('bar.ts')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('rejects content with secrets', async () => {
|
|
130
|
+
const { scanForSecrets } = await import('./gates.js')
|
|
131
|
+
vi.mocked(scanForSecrets).mockReturnValueOnce({
|
|
132
|
+
clean: false,
|
|
133
|
+
findings: [{ pattern: 'Generic Secret', file: '', line: 1, snippet: 'x' }],
|
|
134
|
+
})
|
|
135
|
+
const result = captureLessons(
|
|
136
|
+
{ title: 'Bad', category: 'general', agent: 'x', problem: 'p', solution: 's' },
|
|
137
|
+
tmpDir,
|
|
138
|
+
)
|
|
139
|
+
expect(result.captured).toBe(false)
|
|
140
|
+
expect(result.reason).toBe('secrets_detected')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('consolidateLessons', () => {
|
|
145
|
+
it('returns zeros when file does not exist', () => {
|
|
146
|
+
const result = consolidateLessons(tmpDir)
|
|
147
|
+
expect(result).toEqual({ merged: 0, remaining: 0 })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('merges duplicate entries with same category and similar title', () => {
|
|
151
|
+
writeFileSync(
|
|
152
|
+
join(tmpDir, LESSONS_REL),
|
|
153
|
+
'# Lessons\n\n' +
|
|
154
|
+
'### LES-001: Git push rejected error\n| **Category** | `git` |\n| **Added** | 2026-01-01 |\nOld content.\n\n' +
|
|
155
|
+
'### LES-002: Git push rejected error fix\n| **Category** | `git` |\n| **Added** | 2026-01-05 |\nNewer content.\n',
|
|
156
|
+
)
|
|
157
|
+
const result = consolidateLessons(tmpDir)
|
|
158
|
+
expect(result.merged).toBe(1)
|
|
159
|
+
expect(result.remaining).toBe(1)
|
|
160
|
+
const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
|
|
161
|
+
expect(content).toContain('2026-01-05')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('keeps the most recent entry when merging', () => {
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(tmpDir, LESSONS_REL),
|
|
167
|
+
'# Lessons\n\n' +
|
|
168
|
+
'### LES-001: Same title same\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\nOld.\n\n' +
|
|
169
|
+
'### LES-002: Same title same\n| **Category** | `general` |\n| **Added** | 2026-03-01 |\nNew.\n',
|
|
170
|
+
)
|
|
171
|
+
consolidateLessons(tmpDir)
|
|
172
|
+
const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
|
|
173
|
+
expect(content).toContain('New.')
|
|
174
|
+
expect(content).not.toContain('Old.')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('does not merge entries with different categories', () => {
|
|
178
|
+
writeFileSync(
|
|
179
|
+
join(tmpDir, LESSONS_REL),
|
|
180
|
+
'# Lessons\n\n' +
|
|
181
|
+
'### LES-001: Same title same\n| **Category** | `git` |\n| **Added** | 2026-01-01 |\nGit.\n\n' +
|
|
182
|
+
'### LES-002: Same title same\n| **Category** | `general` |\n| **Added** | 2026-01-02 |\nGeneral.\n',
|
|
183
|
+
)
|
|
184
|
+
const result = consolidateLessons(tmpDir)
|
|
185
|
+
expect(result.remaining).toBe(2)
|
|
186
|
+
expect(result.merged).toBe(0)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { scanForSecrets } from './gates.js'
|
|
4
|
+
|
|
5
|
+
const LESSONS_PATH = '.opencastle/LESSONS-LEARNED.md'
|
|
6
|
+
|
|
7
|
+
function parseLessonEntries(content: string): string[] {
|
|
8
|
+
const parts = content.split(/(?=### LES-\d+:)/)
|
|
9
|
+
return parts.filter(p => p.trim().startsWith('### LES-'))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getNextLessonNumber(entries: string[]): number {
|
|
13
|
+
let max = 0
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const m = entry.match(/### LES-(\d+):/)
|
|
16
|
+
if (m) {
|
|
17
|
+
const n = parseInt(m[1], 10)
|
|
18
|
+
if (n > max) max = n
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return max + 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readLessons(agentName: string, filePaths: string[], basePath?: string): string[] {
|
|
25
|
+
const base = basePath ?? process.cwd()
|
|
26
|
+
const filePath = join(base, LESSONS_PATH)
|
|
27
|
+
if (!existsSync(filePath)) return []
|
|
28
|
+
const content = readFileSync(filePath, 'utf8')
|
|
29
|
+
const entries = parseLessonEntries(content)
|
|
30
|
+
if (entries.length === 0) return []
|
|
31
|
+
const agentLower = agentName.toLowerCase()
|
|
32
|
+
const scored: Array<{ entry: string; score: number }> = []
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const entryLower = entry.toLowerCase()
|
|
35
|
+
const matchesAgent = entryLower.includes(agentLower)
|
|
36
|
+
const matchesFiles =
|
|
37
|
+
filePaths.length > 0 && filePaths.some(fp => entryLower.includes(fp.toLowerCase()))
|
|
38
|
+
if (matchesAgent && matchesFiles) {
|
|
39
|
+
scored.push({ entry, score: 2 })
|
|
40
|
+
} else if (matchesAgent) {
|
|
41
|
+
scored.push({ entry, score: 1 })
|
|
42
|
+
} else if (matchesFiles) {
|
|
43
|
+
scored.push({ entry, score: 0.5 })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
scored.sort((a, b) => b.score - a.score)
|
|
47
|
+
return scored.slice(0, 5).map(s => s.entry.trim())
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function captureLessons(
|
|
51
|
+
lesson: {
|
|
52
|
+
title: string
|
|
53
|
+
category: string
|
|
54
|
+
agent: string
|
|
55
|
+
problem: string
|
|
56
|
+
solution: string
|
|
57
|
+
files?: string[]
|
|
58
|
+
},
|
|
59
|
+
basePath?: string,
|
|
60
|
+
): { captured: boolean; reason?: string } {
|
|
61
|
+
const base = basePath ?? process.cwd()
|
|
62
|
+
const filePath = join(base, LESSONS_PATH)
|
|
63
|
+
const existingContent = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
|
|
64
|
+
const entries = parseLessonEntries(existingContent)
|
|
65
|
+
const nextNum = getNextLessonNumber(entries)
|
|
66
|
+
const lesNum = String(nextNum).padStart(3, '0')
|
|
67
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
68
|
+
const filesNote =
|
|
69
|
+
lesson.files && lesson.files.length > 0
|
|
70
|
+
? `\n**Files:** ${lesson.files.join(', ')}`
|
|
71
|
+
: ''
|
|
72
|
+
const entry =
|
|
73
|
+
`\n### LES-${lesNum}: ${lesson.title}\n\n` +
|
|
74
|
+
'| Field | Value |\n|-------|-------|\n' +
|
|
75
|
+
`| **Category** | \`${lesson.category}\` |\n` +
|
|
76
|
+
`| **Added** | ${date} |\n` +
|
|
77
|
+
`| **Agent** | ${lesson.agent} |\n` +
|
|
78
|
+
'| **Severity** | `medium` |\n\n' +
|
|
79
|
+
`**Problem:** ${lesson.problem}\n\n` +
|
|
80
|
+
`**Correct approach:** ${lesson.solution}${filesNote}\n`
|
|
81
|
+
const scanResult = scanForSecrets(entry, 'lessons')
|
|
82
|
+
if (!scanResult.clean) {
|
|
83
|
+
return { captured: false, reason: 'secrets_detected' }
|
|
84
|
+
}
|
|
85
|
+
if (!existsSync(filePath)) {
|
|
86
|
+
writeFileSync(
|
|
87
|
+
filePath,
|
|
88
|
+
'# Lessons Learned\n\nStructured log of pitfalls and correct approaches.\n\n## Lessons\n',
|
|
89
|
+
'utf8',
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
appendFileSync(filePath, entry, 'utf8')
|
|
93
|
+
return { captured: true }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractDate(entry: string): string {
|
|
97
|
+
const m = entry.match(/\*\*Added\*\*\s*\|\s*(\d{4}-\d{2}-\d{2})/)
|
|
98
|
+
return m ? m[1] : '0000-00-00'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractCategory(entry: string): string {
|
|
102
|
+
const m = entry.match(/\*\*Category\*\*\s*\|\s*`([^`]+)`/)
|
|
103
|
+
return m ? m[1] : ''
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractTitle(entry: string): string {
|
|
107
|
+
const m = entry.match(/### LES-\d+:\s*(.+)/)
|
|
108
|
+
return m ? m[1].trim() : ''
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeTitle(title: string): string {
|
|
112
|
+
return title.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function wordOverlap(a: string, b: string): number {
|
|
116
|
+
const wordsA = new Set(a.split(/\s+/).filter(Boolean))
|
|
117
|
+
const wordsB = new Set(b.split(/\s+/).filter(Boolean))
|
|
118
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0
|
|
119
|
+
let overlap = 0
|
|
120
|
+
for (const w of wordsA) {
|
|
121
|
+
if (wordsB.has(w)) overlap++
|
|
122
|
+
}
|
|
123
|
+
return overlap / Math.max(wordsA.size, wordsB.size)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function consolidateLessons(basePath?: string): { merged: number; remaining: number } {
|
|
127
|
+
const base = basePath ?? process.cwd()
|
|
128
|
+
const filePath = join(base, LESSONS_PATH)
|
|
129
|
+
if (!existsSync(filePath)) return { merged: 0, remaining: 0 }
|
|
130
|
+
const content = readFileSync(filePath, 'utf8')
|
|
131
|
+
const entries = parseLessonEntries(content)
|
|
132
|
+
if (entries.length === 0) return { merged: 0, remaining: 0 }
|
|
133
|
+
const firstLessonIdx = content.indexOf('### LES-')
|
|
134
|
+
const header = firstLessonIdx > 0 ? content.slice(0, firstLessonIdx) : ''
|
|
135
|
+
const kept: string[] = []
|
|
136
|
+
let mergedCount = 0
|
|
137
|
+
const processed = new Set<number>()
|
|
138
|
+
for (let i = 0; i < entries.length; i++) {
|
|
139
|
+
if (processed.has(i)) continue
|
|
140
|
+
const catI = extractCategory(entries[i])
|
|
141
|
+
const titleI = normalizeTitle(extractTitle(entries[i]))
|
|
142
|
+
let bestIdx = i
|
|
143
|
+
let bestDate = extractDate(entries[i])
|
|
144
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
145
|
+
if (processed.has(j)) continue
|
|
146
|
+
const catJ = extractCategory(entries[j])
|
|
147
|
+
if (catI !== catJ) continue
|
|
148
|
+
const titleJ = normalizeTitle(extractTitle(entries[j]))
|
|
149
|
+
if (wordOverlap(titleI, titleJ) >= 0.8) {
|
|
150
|
+
const dateJ = extractDate(entries[j])
|
|
151
|
+
if (dateJ > bestDate) {
|
|
152
|
+
bestDate = dateJ
|
|
153
|
+
bestIdx = j
|
|
154
|
+
}
|
|
155
|
+
processed.add(j)
|
|
156
|
+
mergedCount++
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
processed.add(i)
|
|
160
|
+
kept.push(entries[bestIdx])
|
|
161
|
+
}
|
|
162
|
+
writeFileSync(filePath, header + kept.join('\n'), 'utf8')
|
|
163
|
+
return { merged: mergedCount, remaining: kept.length }
|
|
164
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
import { tmpdir, hostname } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
acquireEngineLock,
|
|
9
|
+
EngineAlreadyRunningError,
|
|
10
|
+
isLockStale,
|
|
11
|
+
releaseEngineLock,
|
|
12
|
+
} from './lock.js'
|
|
13
|
+
|
|
14
|
+
const LOCK_TABLE_SQL = `
|
|
15
|
+
CREATE TABLE IF NOT EXISTS engine_lock (
|
|
16
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
17
|
+
pid INTEGER NOT NULL,
|
|
18
|
+
hostname TEXT NOT NULL,
|
|
19
|
+
started_at TEXT NOT NULL,
|
|
20
|
+
last_heartbeat TEXT NOT NULL
|
|
21
|
+
)
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
let tmpDir: string
|
|
25
|
+
let dbPath: string
|
|
26
|
+
let db: DatabaseSync
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'lock-test-')))
|
|
30
|
+
dbPath = join(tmpDir, 'test.db')
|
|
31
|
+
db = new DatabaseSync(dbPath)
|
|
32
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
33
|
+
db.exec(LOCK_TABLE_SQL)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
try {
|
|
38
|
+
db.close()
|
|
39
|
+
} catch {
|
|
40
|
+
// already closed
|
|
41
|
+
}
|
|
42
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('engine lock', () => {
|
|
46
|
+
it('takes over a stale lock when heartbeat is expired and PID is dead', () => {
|
|
47
|
+
const staleTime = new Date(Date.now() - 60_000).toISOString()
|
|
48
|
+
const deadPid = 999999
|
|
49
|
+
|
|
50
|
+
// Verify the PID is actually dead on this machine
|
|
51
|
+
expect(() => process.kill(deadPid, 0)).toThrow()
|
|
52
|
+
|
|
53
|
+
db.prepare(
|
|
54
|
+
'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
|
|
55
|
+
).run(deadPid, hostname(), staleTime, staleTime)
|
|
56
|
+
|
|
57
|
+
const lock = acquireEngineLock(db, dbPath)
|
|
58
|
+
const row = db
|
|
59
|
+
.prepare('SELECT pid FROM engine_lock WHERE id = 1')
|
|
60
|
+
.get() as { pid: number }
|
|
61
|
+
expect(row.pid).toBe(process.pid)
|
|
62
|
+
lock.release()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('throws EngineAlreadyRunningError when lock is held by a live process', () => {
|
|
66
|
+
const now = new Date().toISOString()
|
|
67
|
+
db.prepare(
|
|
68
|
+
'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
|
|
69
|
+
).run(process.pid, hostname(), now, now)
|
|
70
|
+
|
|
71
|
+
expect(() => acquireEngineLock(db, dbPath)).toThrow(EngineAlreadyRunningError)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('takes over when hostname differs (treated as stale regardless of PID)', () => {
|
|
75
|
+
const staleTime = new Date(Date.now() - 60_000).toISOString()
|
|
76
|
+
db.prepare(
|
|
77
|
+
'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
|
|
78
|
+
).run(process.pid, 'other-host.example.com', staleTime, staleTime)
|
|
79
|
+
|
|
80
|
+
const lock = acquireEngineLock(db, dbPath)
|
|
81
|
+
const row = db
|
|
82
|
+
.prepare('SELECT hostname FROM engine_lock WHERE id = 1')
|
|
83
|
+
.get() as { hostname: string }
|
|
84
|
+
expect(row.hostname).toBe(hostname())
|
|
85
|
+
lock.release()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('release deletes the lock row', () => {
|
|
89
|
+
const lock = acquireEngineLock(db, dbPath)
|
|
90
|
+
lock.release()
|
|
91
|
+
const row = db.prepare('SELECT * FROM engine_lock WHERE id = 1').get()
|
|
92
|
+
expect(row).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('startHeartbeat updates last_heartbeat after 10 seconds', () => {
|
|
96
|
+
vi.useFakeTimers()
|
|
97
|
+
try {
|
|
98
|
+
const lock = acquireEngineLock(db, dbPath)
|
|
99
|
+
const before = (
|
|
100
|
+
db
|
|
101
|
+
.prepare('SELECT last_heartbeat FROM engine_lock WHERE id = 1')
|
|
102
|
+
.get() as { last_heartbeat: string }
|
|
103
|
+
).last_heartbeat
|
|
104
|
+
|
|
105
|
+
lock.startHeartbeat()
|
|
106
|
+
vi.advanceTimersByTime(10_000)
|
|
107
|
+
|
|
108
|
+
const after = (
|
|
109
|
+
db
|
|
110
|
+
.prepare('SELECT last_heartbeat FROM engine_lock WHERE id = 1')
|
|
111
|
+
.get() as { last_heartbeat: string }
|
|
112
|
+
).last_heartbeat
|
|
113
|
+
|
|
114
|
+
expect(after).not.toBe(before)
|
|
115
|
+
lock.release()
|
|
116
|
+
} finally {
|
|
117
|
+
vi.useRealTimers()
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('throws EngineAlreadyRunningError when SQLITE_BUSY from a concurrent write lock', () => {
|
|
122
|
+
// Hold a BEGIN IMMEDIATE transaction on a second connection so the first
|
|
123
|
+
// connection's BEGIN IMMEDIATE will return SQLITE_BUSY.
|
|
124
|
+
const db2 = new DatabaseSync(dbPath)
|
|
125
|
+
db2.exec('PRAGMA journal_mode = WAL')
|
|
126
|
+
db2.exec(LOCK_TABLE_SQL)
|
|
127
|
+
db2.exec('BEGIN IMMEDIATE')
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
expect(() => acquireEngineLock(db, dbPath)).toThrow(EngineAlreadyRunningError)
|
|
131
|
+
} finally {
|
|
132
|
+
db2.exec('ROLLBACK')
|
|
133
|
+
db2.close()
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('takes over lock from different hostname when heartbeat expired', () => {
|
|
138
|
+
const staleTime = new Date(Date.now() - 60_000).toISOString()
|
|
139
|
+
db.prepare(
|
|
140
|
+
'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
|
|
141
|
+
).run(process.pid, 'ci-runner-42.example.com', staleTime, staleTime)
|
|
142
|
+
|
|
143
|
+
const lock = acquireEngineLock(db, dbPath)
|
|
144
|
+
const row = db
|
|
145
|
+
.prepare('SELECT hostname, pid FROM engine_lock WHERE id = 1')
|
|
146
|
+
.get() as { hostname: string; pid: number }
|
|
147
|
+
expect(row.hostname).toBe(hostname())
|
|
148
|
+
expect(row.pid).toBe(process.pid)
|
|
149
|
+
lock.release()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('does NOT take over lock from different hostname when heartbeat is fresh', () => {
|
|
153
|
+
const freshTime = new Date().toISOString()
|
|
154
|
+
db.prepare(
|
|
155
|
+
'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
|
|
156
|
+
).run(12345, 'other-host.example.com', freshTime, freshTime)
|
|
157
|
+
|
|
158
|
+
expect(() => acquireEngineLock(db, dbPath)).toThrow(EngineAlreadyRunningError)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('isLockStale returns true when no lock exists', () => {
|
|
162
|
+
expect(isLockStale(db)).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('isLockStale returns false for fresh lock on same host', () => {
|
|
166
|
+
const now = new Date().toISOString()
|
|
167
|
+
db.prepare(
|
|
168
|
+
'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
|
|
169
|
+
).run(process.pid, hostname(), now, now)
|
|
170
|
+
expect(isLockStale(db)).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('isLockStale returns true for expired lock with dead PID on same host', () => {
|
|
174
|
+
const staleTime = new Date(Date.now() - 60_000).toISOString()
|
|
175
|
+
const deadPid = 999999
|
|
176
|
+
db.prepare(
|
|
177
|
+
'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
|
|
178
|
+
).run(deadPid, hostname(), staleTime, staleTime)
|
|
179
|
+
expect(isLockStale(db)).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
})
|