@lythos/cold-pool 0.9.26 → 0.9.28
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 +4 -1
- package/src/cold-pool.test.ts +20 -1
- package/src/cold-pool.ts +3 -0
- package/src/git-hash.test.ts +89 -0
- package/src/git-hash.ts +27 -0
- package/src/index.ts +5 -0
- package/src/metadata-db.test.ts +158 -0
- package/src/metadata-db.ts +236 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.28",
|
|
4
4
|
"description": "Cold pool service layer — dedicated resource holder for skill repositories with intent/plan/execute primitives. Single owner of git side-effects; consumed by deck/curator/arena.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
"url": "https://github.com/lythos-labs/lythoskill/issues"
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"simple-git": "^3.36.0"
|
|
38
|
+
},
|
|
36
39
|
"engines": {
|
|
37
40
|
"bun": ">=1.0.0"
|
|
38
41
|
}
|
package/src/cold-pool.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool'
|
|
@@ -99,3 +99,22 @@ describe('ColdPool — fs-backed read accessors', () => {
|
|
|
99
99
|
expect(empty.list()).toEqual([])
|
|
100
100
|
})
|
|
101
101
|
})
|
|
102
|
+
|
|
103
|
+
describe('ColdPool.metadata — MetadataDB integration', () => {
|
|
104
|
+
const root = mkdtempSync(join(tmpdir(), 'cold-pool-meta-test-'))
|
|
105
|
+
const pool = new ColdPool(root)
|
|
106
|
+
|
|
107
|
+
test('metadata is auto-created on first access (lazy-open)', () => {
|
|
108
|
+
expect(pool.metadata).toBeDefined()
|
|
109
|
+
// Lazy-open: DB file is NOT created until first method call.
|
|
110
|
+
expect(existsSync(join(root, '.cold-pool-meta.db'))).toBe(false)
|
|
111
|
+
// Trigger open with a no-op read.
|
|
112
|
+
pool.metadata.getRepoRef('github.com', 'nonexistent', 'repo')
|
|
113
|
+
expect(existsSync(join(root, '.cold-pool-meta.db'))).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('metadata records survive round-trip', () => {
|
|
117
|
+
pool.metadata.recordRepoRef('github.com', 'lythos-labs', 'lythoskill', '9645fdb')
|
|
118
|
+
expect(pool.metadata.getRepoRef('github.com', 'lythos-labs', 'lythoskill')).toBe('9645fdb')
|
|
119
|
+
})
|
|
120
|
+
})
|
package/src/cold-pool.ts
CHANGED
|
@@ -10,15 +10,18 @@ import { existsSync, readdirSync } from 'node:fs'
|
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import type { Locator } from './types.js'
|
|
13
|
+
import { MetadataDB } from './metadata-db.js'
|
|
13
14
|
|
|
14
15
|
export const DEFAULT_COLD_POOL_PATH = process.env.LYTHOS_COLD_POOL
|
|
15
16
|
?? join(homedir(), '.agents/skill-repos')
|
|
16
17
|
|
|
17
18
|
export class ColdPool {
|
|
18
19
|
readonly path: string
|
|
20
|
+
readonly metadata: MetadataDB
|
|
19
21
|
|
|
20
22
|
constructor(coldPoolPath?: string) {
|
|
21
23
|
this.path = coldPoolPath ?? DEFAULT_COLD_POOL_PATH
|
|
24
|
+
this.metadata = new MetadataDB(join(this.path, '.cold-pool-meta.db'))
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { simpleGit } from 'simple-git'
|
|
6
|
+
import { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd } from './git-hash.js'
|
|
7
|
+
|
|
8
|
+
let repoDir: string
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
repoDir = mkdtempSync(join(tmpdir(), 'lythos-git-hash-test-'))
|
|
12
|
+
const git = simpleGit(repoDir)
|
|
13
|
+
await git.init(['--initial-branch=main'])
|
|
14
|
+
writeFileSync(join(repoDir, 'SKILL.md'), '# Test Skill\n')
|
|
15
|
+
mkdirSync(join(repoDir, 'skills', 'pdf'), { recursive: true })
|
|
16
|
+
writeFileSync(join(repoDir, 'skills', 'pdf', 'SKILL.md'), '# PDF Skill\n> renders PDF\n')
|
|
17
|
+
await git.add('.')
|
|
18
|
+
await git.commit('initial')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
rmSync(repoDir, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('hashSkillMd (SHA-256)', () => {
|
|
26
|
+
it('computes SHA-256 of SKILL.md', () => {
|
|
27
|
+
const result = hashSkillMd(join(repoDir, 'SKILL.md'))
|
|
28
|
+
expect(result).toBeString()
|
|
29
|
+
expect(result.length).toBe(64)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('produces deterministic output', () => {
|
|
33
|
+
const a = hashSkillMd(join(repoDir, 'SKILL.md'))
|
|
34
|
+
const b = hashSkillMd(join(repoDir, 'SKILL.md'))
|
|
35
|
+
expect(a).toBe(b)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('produces different hash for different content', () => {
|
|
39
|
+
const root = hashSkillMd(join(repoDir, 'SKILL.md'))
|
|
40
|
+
const nested = hashSkillMd(join(repoDir, 'skills', 'pdf', 'SKILL.md'))
|
|
41
|
+
expect(root).not.toBe(nested)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('getRepoHeadRef', () => {
|
|
46
|
+
it('returns HEAD commit hash', async () => {
|
|
47
|
+
const head = await getRepoHeadRef(repoDir)
|
|
48
|
+
expect(head).toBeString()
|
|
49
|
+
expect(head.length).toBe(40)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('matches simple-git log output', async () => {
|
|
53
|
+
const head = await getRepoHeadRef(repoDir)
|
|
54
|
+
const log = await simpleGit(repoDir).log()
|
|
55
|
+
expect(head).toBe(log.latest!.hash)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('getSkillBlobHash', () => {
|
|
60
|
+
it('hashes SKILL.md in repo root', async () => {
|
|
61
|
+
const hash = await getSkillBlobHash(repoDir, '')
|
|
62
|
+
expect(hash).toBeString()
|
|
63
|
+
expect(hash.length).toBe(40)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('hashes SKILL.md in nested subpath', async () => {
|
|
67
|
+
const hash = await getSkillBlobHash(repoDir, 'skills/pdf')
|
|
68
|
+
expect(hash).toBeString()
|
|
69
|
+
expect(hash.length).toBe(40)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('produces different blob hashes for different files', async () => {
|
|
73
|
+
const root = await getSkillBlobHash(repoDir, '')
|
|
74
|
+
const nested = await getSkillBlobHash(repoDir, 'skills/pdf')
|
|
75
|
+
expect(root).not.toBe(nested)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('getSkillTreeHash', () => {
|
|
80
|
+
it('returns tree hash for subdirectory', async () => {
|
|
81
|
+
const hash = await getSkillTreeHash(repoDir, 'skills/pdf')
|
|
82
|
+
expect(hash).toBeString()
|
|
83
|
+
expect(hash.length).toBe(40)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('throws on bad path', async () => {
|
|
87
|
+
await expect(getSkillTreeHash(repoDir, 'nonexistent')).rejects.toThrow()
|
|
88
|
+
})
|
|
89
|
+
})
|
package/src/git-hash.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { simpleGit, type SimpleGit } from 'simple-git'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
function git(repoDir: string): SimpleGit {
|
|
6
|
+
return simpleGit(repoDir)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function getRepoHeadRef(repoDir: string): Promise<string> {
|
|
10
|
+
return (await git(repoDir).revparse(['HEAD'])).trim()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function getSkillBlobHash(repoDir: string, skillSubpath: string): Promise<string> {
|
|
14
|
+
const path = skillSubpath ? `${skillSubpath}/SKILL.md` : 'SKILL.md'
|
|
15
|
+
return (await git(repoDir).raw(['hash-object', path])).trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hashSkillMd(skillMdPath: string): string {
|
|
19
|
+
return createHash('sha256').update(readFileSync(skillMdPath, 'utf-8')).digest('hex')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function getSkillTreeHash(repoDir: string, skillSubpath: string): Promise<string> {
|
|
23
|
+
const output = (await git(repoDir).raw(['ls-tree', 'HEAD', skillSubpath || '.'])).trim()
|
|
24
|
+
const hash = output.split(/\s+/)[2]
|
|
25
|
+
if (!hash) throw new Error(`Could not parse tree hash from: ${output}`)
|
|
26
|
+
return hash
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export type {
|
|
|
18
18
|
export { parseLocator, formatLocator } from './parse-locator.js'
|
|
19
19
|
export { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool.js'
|
|
20
20
|
|
|
21
|
+
export type { RepoRef, SkillHash, DeckReference } from './metadata-db.js'
|
|
22
|
+
export { MetadataDB } from './metadata-db.js'
|
|
23
|
+
|
|
21
24
|
export type { TreeEntry, TreeResponse, FetchFn } from './github-tree.js'
|
|
22
25
|
export { fetchRepoTree } from './github-tree.js'
|
|
23
26
|
|
|
@@ -31,3 +34,5 @@ export type { GitCloneOptions, GitPullResult, GitRootResult } from './git-io.js'
|
|
|
31
34
|
export { gitClone, gitPull, detectGitRoot } from './git-io.js'
|
|
32
35
|
|
|
33
36
|
export { buildFetchPlan, executeFetchPlan } from './fetch-plan.js'
|
|
37
|
+
|
|
38
|
+
export { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd } from './git-hash.js'
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { MetadataDB } from './metadata-db.js'
|
|
5
|
+
|
|
6
|
+
function tempDbPath(): string {
|
|
7
|
+
return join(tmpdir(), `cold-pool-meta-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('MetadataDB', () => {
|
|
11
|
+
let db: MetadataDB
|
|
12
|
+
let dbPath: string
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
dbPath = tempDbPath()
|
|
16
|
+
db = new MetadataDB(dbPath)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
db.close()
|
|
21
|
+
try { Bun.file(dbPath).delete() } catch {}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('repos', () => {
|
|
25
|
+
it('records and retrieves a repo HEAD ref', () => {
|
|
26
|
+
db.recordRepoRef('github.com', 'lythos-labs', 'lythoskill', '9645fdb')
|
|
27
|
+
expect(db.getRepoRef('github.com', 'lythos-labs', 'lythoskill')).toBe('9645fdb')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns null for unknown repo', () => {
|
|
31
|
+
expect(db.getRepoRef('github.com', 'unknown', 'repo')).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('overwrites on duplicate key', () => {
|
|
35
|
+
db.recordRepoRef('github.com', 'lythos-labs', 'lythoskill', '9645fdb')
|
|
36
|
+
db.recordRepoRef('github.com', 'lythos-labs', 'lythoskill', 'a1b2c3d')
|
|
37
|
+
expect(db.getRepoRef('github.com', 'lythos-labs', 'lythoskill')).toBe('a1b2c3d')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('handles localhost host', () => {
|
|
41
|
+
db.recordRepoRef('localhost', 'me', 'my-skill', 'abc1234')
|
|
42
|
+
expect(db.getRepoRef('localhost', 'me', 'my-skill')).toBe('abc1234')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('skills', () => {
|
|
47
|
+
it('records and retrieves a skill hash', () => {
|
|
48
|
+
db.recordSkillHash('github.com', 'lythos-labs', 'lythoskill', 'skills/lythoskill-deck', 'sha256-e3b0c44', 'git-blob-e3b0c44', '9645fdb')
|
|
49
|
+
expect(db.getSkillHash('github.com', 'lythos-labs', 'lythoskill', 'skills/lythoskill-deck')).toBe('sha256-e3b0c44')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('handles standalone skill (empty subpath)', () => {
|
|
53
|
+
db.recordSkillHash('github.com', 'garrytan', 'gstack', '', 'sha256-abc', 'git-blob-abc', 'deadbeef')
|
|
54
|
+
expect(db.getSkillHash('github.com', 'garrytan', 'gstack', '')).toBe('sha256-abc')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns null for unknown skill', () => {
|
|
58
|
+
expect(db.getSkillHash('github.com', 'unknown', 'repo', 'skill')).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('overwrites on duplicate key', () => {
|
|
62
|
+
db.recordSkillHash('github.com', 'lythos-labs', 'lythoskill', 'skills/lythoskill-deck', 'oldhash', 'git-old', '9645fdb')
|
|
63
|
+
db.recordSkillHash('github.com', 'lythos-labs', 'lythoskill', 'skills/lythoskill-deck', 'newhash', 'git-new', 'a1b2c3d')
|
|
64
|
+
expect(db.getSkillHash('github.com', 'lythos-labs', 'lythoskill', 'skills/lythoskill-deck')).toBe('newhash')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('deck_refs', () => {
|
|
69
|
+
it('adds and retrieves a reference', () => {
|
|
70
|
+
db.addReference('github.com/lythos-labs/lythoskill/skills/lythoskill-deck', '/project-a/skill-deck.toml', 'lythoskill-deck')
|
|
71
|
+
const refs = db.getReferencingDecks('github.com/lythos-labs/lythoskill/skills/lythoskill-deck')
|
|
72
|
+
expect(refs).toHaveLength(1)
|
|
73
|
+
expect(refs[0].deckPath).toBe('/project-a/skill-deck.toml')
|
|
74
|
+
expect(refs[0].alias).toBe('lythoskill-deck')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns empty array for unreferenced skill', () => {
|
|
78
|
+
expect(db.getReferencingDecks('github.com/unknown/repo/skill')).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('supports multiple decks referencing same skill', () => {
|
|
82
|
+
db.addReference('github.com/owner/repo/skill', '/project-a/skill-deck.toml', 'skill-a')
|
|
83
|
+
db.addReference('github.com/owner/repo/skill', '/project-b/skill-deck.toml', 'skill-b')
|
|
84
|
+
const refs = db.getReferencingDecks('github.com/owner/repo/skill')
|
|
85
|
+
expect(refs).toHaveLength(2)
|
|
86
|
+
const paths = refs.map((r) => r.deckPath).sort()
|
|
87
|
+
expect(paths).toEqual(['/project-a/skill-deck.toml', '/project-b/skill-deck.toml'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('removes a specific reference', () => {
|
|
91
|
+
db.addReference('github.com/owner/repo/skill', '/project-a/skill-deck.toml', 'skill-a')
|
|
92
|
+
db.addReference('github.com/owner/repo/skill', '/project-b/skill-deck.toml', 'skill-b')
|
|
93
|
+
db.removeReference('github.com/owner/repo/skill', '/project-a/skill-deck.toml')
|
|
94
|
+
const refs = db.getReferencingDecks('github.com/owner/repo/skill')
|
|
95
|
+
expect(refs).toHaveLength(1)
|
|
96
|
+
expect(refs[0].deckPath).toBe('/project-b/skill-deck.toml')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('removes all references for a deck', () => {
|
|
100
|
+
db.addReference('github.com/owner/repo/skill-a', '/project-a/skill-deck.toml', 'skill-a')
|
|
101
|
+
db.addReference('github.com/owner/repo/skill-b', '/project-a/skill-deck.toml', 'skill-b')
|
|
102
|
+
db.removeAllReferencesForDeck('/project-a/skill-deck.toml')
|
|
103
|
+
expect(db.getReferencingDecks('github.com/owner/repo/skill-a')).toEqual([])
|
|
104
|
+
expect(db.getReferencingDecks('github.com/owner/repo/skill-b')).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('allows null alias', () => {
|
|
108
|
+
db.addReference('github.com/owner/repo/skill', '/project-a/skill-deck.toml', null)
|
|
109
|
+
const refs = db.getReferencingDecks('github.com/owner/repo/skill')
|
|
110
|
+
expect(refs[0].alias).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('reconcileDeckReferences', () => {
|
|
115
|
+
it('replaces all references for a deck atomically', () => {
|
|
116
|
+
// Pre-existing refs
|
|
117
|
+
db.addReference('github.com/old/repo/skill', '/project-a/skill-deck.toml', 'old-skill')
|
|
118
|
+
db.addReference('github.com/owner/repo/skill-a', '/project-a/skill-deck.toml', 'skill-a')
|
|
119
|
+
|
|
120
|
+
// Reconcile with new declared set
|
|
121
|
+
db.reconcileDeckReferences('/project-a/skill-deck.toml', [
|
|
122
|
+
{ locator: 'github.com/owner/repo/skill-a', alias: 'skill-a' },
|
|
123
|
+
{ locator: 'github.com/owner/repo/skill-b', alias: 'skill-b' },
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
// old-skill removed, skill-a kept, skill-b added
|
|
127
|
+
expect(db.getReferencingDecks('github.com/old/repo/skill')).toEqual([])
|
|
128
|
+
expect(db.getReferencingDecks('github.com/owner/repo/skill-a')).toHaveLength(1)
|
|
129
|
+
expect(db.getReferencingDecks('github.com/owner/repo/skill-b')).toHaveLength(1)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('handles empty declared set (deck unlinked)', () => {
|
|
133
|
+
db.addReference('github.com/owner/repo/skill', '/project-a/skill-deck.toml', 'skill')
|
|
134
|
+
db.reconcileDeckReferences('/project-a/skill-deck.toml', [])
|
|
135
|
+
expect(db.getReferencingDecks('github.com/owner/repo/skill')).toEqual([])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('does not affect other decks during reconcile', () => {
|
|
139
|
+
db.addReference('github.com/owner/repo/skill', '/project-a/skill-deck.toml', 'skill-a')
|
|
140
|
+
db.addReference('github.com/owner/repo/skill', '/project-b/skill-deck.toml', 'skill-b')
|
|
141
|
+
|
|
142
|
+
db.reconcileDeckReferences('/project-a/skill-deck.toml', [])
|
|
143
|
+
|
|
144
|
+
expect(db.getReferencingDecks('github.com/owner/repo/skill')).toHaveLength(1)
|
|
145
|
+
expect(db.getReferencingDecks('github.com/owner/repo/skill')[0].deckPath).toBe('/project-b/skill-deck.toml')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('schema idempotency', () => {
|
|
150
|
+
it('can be re-instantiated on same file without error', () => {
|
|
151
|
+
db.close()
|
|
152
|
+
const db2 = new MetadataDB(dbPath)
|
|
153
|
+
db2.recordRepoRef('github.com', 'test', 'repo', 'abc123')
|
|
154
|
+
expect(db2.getRepoRef('github.com', 'test', 'repo')).toBe('abc123')
|
|
155
|
+
db2.close()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold-pool metadata layer — SQLite-backed.
|
|
3
|
+
*
|
|
4
|
+
* Per ADR-20260507143241493: git-native hash, local-only trust, SQLite storage.
|
|
5
|
+
*
|
|
6
|
+
* Three tables:
|
|
7
|
+
* repos — per-repo HEAD ref tracking
|
|
8
|
+
* skills — per-skill content hash (git blob hash of SKILL.md)
|
|
9
|
+
* deck_refs — cross-deck reference index
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database } from 'bun:sqlite'
|
|
13
|
+
import { mkdirSync } from 'node:fs'
|
|
14
|
+
import { dirname } from 'node:path'
|
|
15
|
+
|
|
16
|
+
export interface RepoRef {
|
|
17
|
+
host: string
|
|
18
|
+
owner: string
|
|
19
|
+
repo: string
|
|
20
|
+
headRef: string
|
|
21
|
+
lastPulledAt: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SkillHash {
|
|
25
|
+
host: string
|
|
26
|
+
owner: string
|
|
27
|
+
repo: string
|
|
28
|
+
skillSubpath: string
|
|
29
|
+
contentGitHash: string
|
|
30
|
+
headRefAtRecord: string
|
|
31
|
+
lastSeenAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DeckReference {
|
|
35
|
+
skillLocator: string
|
|
36
|
+
deckPath: string
|
|
37
|
+
declaredAlias: string | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class MetadataDB {
|
|
41
|
+
private dbPath: string
|
|
42
|
+
private _db: Database | null = null
|
|
43
|
+
|
|
44
|
+
constructor(dbPath: string) {
|
|
45
|
+
this.dbPath = dbPath
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Lazy-open: first DB access triggers creation. Allows ColdPool
|
|
49
|
+
* to be instantiated with read-only / non-existent paths (e.g. test
|
|
50
|
+
* fixtures) without failing on construction. */
|
|
51
|
+
private get db(): Database {
|
|
52
|
+
if (this._db == null) {
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(dirname(this.dbPath), { recursive: true })
|
|
55
|
+
} catch {
|
|
56
|
+
// Parent dir may be read-only (e.g. test paths like /cold).
|
|
57
|
+
// SQLite { create: true } will fail below with a clearer error.
|
|
58
|
+
}
|
|
59
|
+
this._db = new Database(this.dbPath, { create: true })
|
|
60
|
+
this.initSchema()
|
|
61
|
+
}
|
|
62
|
+
return this._db
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private initSchema(): void {
|
|
66
|
+
this.exec(`
|
|
67
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
68
|
+
host TEXT NOT NULL,
|
|
69
|
+
owner TEXT NOT NULL,
|
|
70
|
+
repo TEXT NOT NULL,
|
|
71
|
+
head_ref TEXT,
|
|
72
|
+
last_pulled_at TEXT,
|
|
73
|
+
PRIMARY KEY (host, owner, repo)
|
|
74
|
+
)
|
|
75
|
+
`)
|
|
76
|
+
|
|
77
|
+
this.exec(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
79
|
+
host TEXT NOT NULL,
|
|
80
|
+
owner TEXT NOT NULL,
|
|
81
|
+
repo TEXT NOT NULL,
|
|
82
|
+
skill_subpath TEXT NOT NULL DEFAULT '',
|
|
83
|
+
content_sha256 TEXT,
|
|
84
|
+
git_blob_hash TEXT,
|
|
85
|
+
head_ref_at_record TEXT,
|
|
86
|
+
last_seen_at TEXT,
|
|
87
|
+
PRIMARY KEY (host, owner, repo, skill_subpath)
|
|
88
|
+
)
|
|
89
|
+
`)
|
|
90
|
+
|
|
91
|
+
this.exec(`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS deck_refs (
|
|
93
|
+
skill_locator TEXT NOT NULL,
|
|
94
|
+
deck_path TEXT NOT NULL,
|
|
95
|
+
declared_alias TEXT,
|
|
96
|
+
PRIMARY KEY (skill_locator, deck_path)
|
|
97
|
+
)
|
|
98
|
+
`)
|
|
99
|
+
|
|
100
|
+
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_deck ON deck_refs(deck_path)`)
|
|
101
|
+
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_locator ON deck_refs(skill_locator)`)
|
|
102
|
+
this.exec(`CREATE INDEX IF NOT EXISTS idx_skills_repo ON skills(host, owner, repo)`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Small db util wrappers (no ORM, just DRY) ────────────────
|
|
106
|
+
|
|
107
|
+
/** Execute a statement that returns no rows. Auto-finalize. */
|
|
108
|
+
private exec(sql: string, params?: Record<string, unknown>): void {
|
|
109
|
+
const stmt = this.db.query(sql)
|
|
110
|
+
stmt.run(params ?? {})
|
|
111
|
+
stmt.finalize()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Query zero or one row. */
|
|
115
|
+
private queryOne<T>(sql: string, params?: Record<string, unknown>): T | null {
|
|
116
|
+
return this.db.query(sql).get(params ?? {}) as T | null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Query many rows. */
|
|
120
|
+
private queryAll<T>(sql: string, params?: Record<string, unknown>): T[] {
|
|
121
|
+
return this.db.query(sql).all(params ?? {}) as T[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private now(): string {
|
|
125
|
+
return new Date().toISOString()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Repo ─────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
recordRepoRef(host: string, owner: string, repo: string, headRef: string): void {
|
|
131
|
+
this.exec(
|
|
132
|
+
`INSERT OR REPLACE INTO repos (host, owner, repo, head_ref, last_pulled_at)
|
|
133
|
+
VALUES ($host, $owner, $repo, $headRef, $now)`,
|
|
134
|
+
{ $host: host, $owner: owner, $repo: repo, $headRef: headRef, $now: this.now() },
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getRepoRef(host: string, owner: string, repo: string): string | null {
|
|
139
|
+
const row = this.queryOne<{ head_ref: string }>(
|
|
140
|
+
`SELECT head_ref FROM repos WHERE host = $host AND owner = $owner AND repo = $repo`,
|
|
141
|
+
{ $host: host, $owner: owner, $repo: repo },
|
|
142
|
+
)
|
|
143
|
+
return row?.head_ref ?? null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Skill ────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
recordSkillHash(
|
|
149
|
+
host: string,
|
|
150
|
+
owner: string,
|
|
151
|
+
repo: string,
|
|
152
|
+
skillSubpath: string,
|
|
153
|
+
contentSha256: string,
|
|
154
|
+
gitBlobHash: string | null,
|
|
155
|
+
headRefAtRecord: string,
|
|
156
|
+
): void {
|
|
157
|
+
this.exec(
|
|
158
|
+
`INSERT OR REPLACE INTO skills
|
|
159
|
+
(host, owner, repo, skill_subpath, content_sha256, git_blob_hash, head_ref_at_record, last_seen_at)
|
|
160
|
+
VALUES ($host, $owner, $repo, $subpath, $sha256, $blob, $headRef, $now)`,
|
|
161
|
+
{
|
|
162
|
+
$host: host,
|
|
163
|
+
$owner: owner,
|
|
164
|
+
$repo: repo,
|
|
165
|
+
$subpath: skillSubpath,
|
|
166
|
+
$sha256: contentSha256,
|
|
167
|
+
$blob: gitBlobHash,
|
|
168
|
+
$headRef: headRefAtRecord,
|
|
169
|
+
$now: this.now(),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
getSkillHash(host: string, owner: string, repo: string, skillSubpath: string): string | null {
|
|
175
|
+
const row = this.queryOne<{ content_sha256: string }>(
|
|
176
|
+
`SELECT content_sha256 FROM skills
|
|
177
|
+
WHERE host = $host AND owner = $owner AND repo = $repo AND skill_subpath = $subpath`,
|
|
178
|
+
{ $host: host, $owner: owner, $repo: repo, $subpath: skillSubpath },
|
|
179
|
+
)
|
|
180
|
+
return row?.content_sha256 ?? null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Deck References ──────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
addReference(skillLocator: string, deckPath: string, declaredAlias: string | null): void {
|
|
186
|
+
this.exec(
|
|
187
|
+
`INSERT OR REPLACE INTO deck_refs (skill_locator, deck_path, declared_alias)
|
|
188
|
+
VALUES ($locator, $deck, $alias)`,
|
|
189
|
+
{ $locator: skillLocator, $deck: deckPath, $alias: declaredAlias },
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
removeReference(skillLocator: string, deckPath: string): void {
|
|
194
|
+
this.exec(
|
|
195
|
+
`DELETE FROM deck_refs WHERE skill_locator = $locator AND deck_path = $deck`,
|
|
196
|
+
{ $locator: skillLocator, $deck: deckPath },
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
removeAllReferencesForDeck(deckPath: string): void {
|
|
201
|
+
this.exec(`DELETE FROM deck_refs WHERE deck_path = $deck`, { $deck: deckPath })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getReferencingDecks(skillLocator: string): Array<{ deckPath: string; alias: string | null }> {
|
|
205
|
+
const rows = this.queryAll<{ deck_path: string; declared_alias: string | null }>(
|
|
206
|
+
`SELECT deck_path, declared_alias FROM deck_refs WHERE skill_locator = $locator`,
|
|
207
|
+
{ $locator: skillLocator },
|
|
208
|
+
)
|
|
209
|
+
return rows.map((r) => ({ deckPath: r.deck_path, alias: r.declared_alias }))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Reconcile ────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
reconcileDeckReferences(
|
|
215
|
+
deckPath: string,
|
|
216
|
+
declaredSkills: Array<{ locator: string; alias: string | null }>,
|
|
217
|
+
): void {
|
|
218
|
+
this.db.transaction(() => {
|
|
219
|
+
this.exec(`DELETE FROM deck_refs WHERE deck_path = $deck`, { $deck: deckPath })
|
|
220
|
+
|
|
221
|
+
const insert = this.db.query(`
|
|
222
|
+
INSERT INTO deck_refs (skill_locator, deck_path, declared_alias)
|
|
223
|
+
VALUES ($locator, $deck, $alias)
|
|
224
|
+
`)
|
|
225
|
+
for (const skill of declaredSkills) {
|
|
226
|
+
insert.run({ $locator: skill.locator, $deck: deckPath, $alias: skill.alias })
|
|
227
|
+
}
|
|
228
|
+
insert.finalize()
|
|
229
|
+
})()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close(): void {
|
|
233
|
+
this._db?.close()
|
|
234
|
+
this._db = null
|
|
235
|
+
}
|
|
236
|
+
}
|