@lythos/cold-pool 0.9.31 → 0.9.32
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/README.md +7 -5
- package/package.json +1 -1
- package/src/index.ts +10 -0
- package/src/reconcile-plan.test.ts +236 -0
- package/src/reconcile-plan.ts +217 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Cold pool service layer for the lythoskill ecosystem.
|
|
4
4
|
|
|
5
|
-
> Status:
|
|
5
|
+
> Status: 0.9.32 — public API. Reconciliation (reconcile plan) is functional; execute convergence WIP.
|
|
6
6
|
|
|
7
7
|
## What this is
|
|
8
8
|
|
|
@@ -16,12 +16,14 @@ deck / curator / arena consume it instead of running `git clone` themselves.
|
|
|
16
16
|
Three layers, sharing the project's `intent → plan → execute` pattern
|
|
17
17
|
(`cortex/wiki/01-patterns/2026-05-04-intent-plan-execute-fractal-architecture-pattern.md`):
|
|
18
18
|
|
|
19
|
-
- **Resource layer** — `ColdPool` class holds path, metadata index,
|
|
20
|
-
entry. Read-only accessors
|
|
19
|
+
- **Resource layer** — `ColdPool` class holds path, metadata index (MetadataDB),
|
|
20
|
+
reconcile entry. Read-only accessors: `resolveDir()`, `has()`, `list()`.
|
|
21
21
|
- **Plan layer** — `buildFetchPlan(coldPool, locator) → FetchPlan`,
|
|
22
|
-
`buildValidationPlan(coldPool, locator) → ValidationReport
|
|
22
|
+
`buildValidationPlan(coldPool, locator) → ValidationReport`,
|
|
23
|
+
`buildReconcilePlan(coldPool, desired) → ReconcilePlan`. Pure data,
|
|
23
24
|
no side effects, dry-run printable.
|
|
24
|
-
- **Execute layer** — `executeFetchPlan(plan, io: FetchIO)
|
|
25
|
+
- **Execute layer** — `executeFetchPlan(plan, io: FetchIO)`,
|
|
26
|
+
`executeReconcilePlan(plan, io: ReconcileIO)`. IO is
|
|
25
27
|
injectable; defaults to real git operations. Tests swap mocks.
|
|
26
28
|
|
|
27
29
|
## Locator
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.32",
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -38,3 +38,13 @@ export { gitClone, gitPull, detectGitRoot } from './git-io.js'
|
|
|
38
38
|
export { buildFetchPlan, executeFetchPlan } from './fetch-plan.js'
|
|
39
39
|
|
|
40
40
|
export { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd } from './git-hash.js'
|
|
41
|
+
|
|
42
|
+
export type {
|
|
43
|
+
DesiredSkill,
|
|
44
|
+
ReconcileDesiredState,
|
|
45
|
+
ReconcileEntry,
|
|
46
|
+
ReconcilePlan,
|
|
47
|
+
ReconcileResult,
|
|
48
|
+
ReconcileIO,
|
|
49
|
+
} from './reconcile-plan.js'
|
|
50
|
+
export { buildReconcilePlan, executeReconcilePlan } from './reconcile-plan.js'
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { ColdPool } from './cold-pool'
|
|
6
|
+
import { buildReconcilePlan, executeReconcilePlan, type ReconcileDesiredState } from './reconcile-plan'
|
|
7
|
+
|
|
8
|
+
function makePool(): { root: string; pool: ColdPool } {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'reconcile-test-'))
|
|
10
|
+
const pool = new ColdPool(root)
|
|
11
|
+
return { root, pool }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function seedRepo(root: string, host: string, owner: string, repo: string) {
|
|
15
|
+
const dir = join(root, host, owner, repo)
|
|
16
|
+
mkdirSync(dir, { recursive: true })
|
|
17
|
+
writeFileSync(join(dir, 'SKILL.md'), '# Test Skill')
|
|
18
|
+
// Seed metadata with a fake HEAD ref
|
|
19
|
+
return dir
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('buildReconcilePlan — pure classification', () => {
|
|
23
|
+
let root: string
|
|
24
|
+
let pool: ColdPool
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
const p = makePool()
|
|
28
|
+
root = p.root
|
|
29
|
+
pool = p.pool
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
pool.metadata.close()
|
|
34
|
+
rmSync(root, { recursive: true, force: true })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('empty desired + empty cold pool → all empty', () => {
|
|
38
|
+
const desired: ReconcileDesiredState = { deckPath: '/project/skill-deck.toml', skills: [] }
|
|
39
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
40
|
+
expect(plan.missing).toEqual([])
|
|
41
|
+
expect(plan.behind).toEqual([])
|
|
42
|
+
expect(plan.extra).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('desired skill missing from cold pool → reported as missing', () => {
|
|
46
|
+
const desired: ReconcileDesiredState = {
|
|
47
|
+
deckPath: '/project/skill-deck.toml',
|
|
48
|
+
skills: [{ locator: 'github.com/lythos-labs/tdd', alias: 'tdd' }],
|
|
49
|
+
}
|
|
50
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
51
|
+
expect(plan.missing.length).toBe(1)
|
|
52
|
+
expect(plan.missing[0].host).toBe('github.com')
|
|
53
|
+
expect(plan.missing[0].owner).toBe('lythos-labs')
|
|
54
|
+
expect(plan.missing[0].repo).toBe('tdd')
|
|
55
|
+
expect(plan.missing[0].aliases).toEqual(['tdd'])
|
|
56
|
+
expect(plan.behind).toEqual([])
|
|
57
|
+
expect(plan.extra).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('desired skill exists in cold pool with metadata → reported as behind', () => {
|
|
61
|
+
seedRepo(root, 'github.com', 'owner', 'repo-a')
|
|
62
|
+
pool.metadata.recordRepoRef('github.com', 'owner', 'repo-a', 'abc123def')
|
|
63
|
+
|
|
64
|
+
const desired: ReconcileDesiredState = {
|
|
65
|
+
deckPath: '/project/skill-deck.toml',
|
|
66
|
+
skills: [{ locator: 'github.com/owner/repo-a', alias: 'skill-a' }],
|
|
67
|
+
}
|
|
68
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
69
|
+
expect(plan.missing).toEqual([])
|
|
70
|
+
expect(plan.behind.length).toBe(1)
|
|
71
|
+
expect(plan.behind[0].repo).toBe('repo-a')
|
|
72
|
+
expect(plan.behind[0].reason).toContain('abc123d')
|
|
73
|
+
expect(plan.extra).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('desired skill exists but no metadata → not flagged (manual clone)', () => {
|
|
77
|
+
seedRepo(root, 'github.com', 'owner', 'repo-b')
|
|
78
|
+
|
|
79
|
+
const desired: ReconcileDesiredState = {
|
|
80
|
+
deckPath: '/project/skill-deck.toml',
|
|
81
|
+
skills: [{ locator: 'github.com/owner/repo-b', alias: 'skill-b' }],
|
|
82
|
+
}
|
|
83
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
84
|
+
expect(plan.missing).toEqual([])
|
|
85
|
+
expect(plan.behind).toEqual([])
|
|
86
|
+
expect(plan.extra).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('monorepo skill path → extracts correct repo root', () => {
|
|
90
|
+
seedRepo(root, 'github.com', 'owner', 'monorepo')
|
|
91
|
+
pool.metadata.recordRepoRef('github.com', 'owner', 'monorepo', 'def456')
|
|
92
|
+
|
|
93
|
+
const desired: ReconcileDesiredState = {
|
|
94
|
+
deckPath: '/project/skill-deck.toml',
|
|
95
|
+
skills: [{ locator: 'github.com/owner/monorepo/skills/foo', alias: 'foo' }],
|
|
96
|
+
}
|
|
97
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
98
|
+
expect(plan.missing).toEqual([])
|
|
99
|
+
expect(plan.behind.length).toBe(1)
|
|
100
|
+
expect(plan.behind[0].repo).toBe('monorepo')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('extra repo in cold pool not in desired → reported as extra', () => {
|
|
104
|
+
seedRepo(root, 'github.com', 'someone', 'orphan-repo')
|
|
105
|
+
|
|
106
|
+
const desired: ReconcileDesiredState = {
|
|
107
|
+
deckPath: '/project/skill-deck.toml',
|
|
108
|
+
skills: [{ locator: 'github.com/owner/repo-a', alias: 'a' }],
|
|
109
|
+
}
|
|
110
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
111
|
+
expect(plan.missing.length).toBe(1) // repo-a is missing
|
|
112
|
+
expect(plan.extra.length).toBe(1)
|
|
113
|
+
expect(plan.extra[0].repo).toBe('orphan-repo')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('multiple skills in same repo → single repo entry', () => {
|
|
117
|
+
seedRepo(root, 'github.com', 'owner', 'shared-repo')
|
|
118
|
+
pool.metadata.recordRepoRef('github.com', 'owner', 'shared-repo', 'ghi789')
|
|
119
|
+
|
|
120
|
+
const desired: ReconcileDesiredState = {
|
|
121
|
+
deckPath: '/project/skill-deck.toml',
|
|
122
|
+
skills: [
|
|
123
|
+
{ locator: 'github.com/owner/shared-repo/skills/a', alias: 'alpha' },
|
|
124
|
+
{ locator: 'github.com/owner/shared-repo/skills/b', alias: 'beta' },
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
128
|
+
expect(plan.missing).toEqual([])
|
|
129
|
+
expect(plan.behind.length).toBe(1)
|
|
130
|
+
expect(plan.behind[0].aliases).toEqual(['alpha', 'beta'])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('localhost skill in desired, exists in pool', () => {
|
|
134
|
+
seedRepo(root, 'localhost', 'me', 'local-skill')
|
|
135
|
+
|
|
136
|
+
const desired: ReconcileDesiredState = {
|
|
137
|
+
deckPath: '/project/skill-deck.toml',
|
|
138
|
+
skills: [{ locator: 'localhost/me/local-skill', alias: 'local' }],
|
|
139
|
+
}
|
|
140
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
141
|
+
// localhost: metadata not auto-recorded (user-managed), so no behind
|
|
142
|
+
expect(plan.missing).toEqual([])
|
|
143
|
+
expect(plan.behind).toEqual([])
|
|
144
|
+
expect(plan.extra).toEqual([])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('unrecognized locator format → reported as missing', () => {
|
|
148
|
+
const desired: ReconcileDesiredState = {
|
|
149
|
+
deckPath: '/project/skill-deck.toml',
|
|
150
|
+
skills: [{ locator: 'bare-name', alias: 'bare' }],
|
|
151
|
+
}
|
|
152
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
153
|
+
expect(plan.missing.length).toBe(1)
|
|
154
|
+
expect(plan.missing[0].reason).toContain('Unrecognized')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('mixed: missing + behind + extra all populated', () => {
|
|
158
|
+
// Seed two repos that exist
|
|
159
|
+
seedRepo(root, 'github.com', 'owner', 'exists-behind')
|
|
160
|
+
pool.metadata.recordRepoRef('github.com', 'owner', 'exists-behind', 'abc')
|
|
161
|
+
seedRepo(root, 'github.com', 'owner', 'exists-clean')
|
|
162
|
+
|
|
163
|
+
// Seed an orphan
|
|
164
|
+
seedRepo(root, 'github.com', 'someone', 'orphan')
|
|
165
|
+
|
|
166
|
+
const desired: ReconcileDesiredState = {
|
|
167
|
+
deckPath: '/project/skill-deck.toml',
|
|
168
|
+
skills: [
|
|
169
|
+
{ locator: 'github.com/owner/exists-behind', alias: 'behind' },
|
|
170
|
+
{ locator: 'github.com/owner/exists-clean', alias: 'clean' },
|
|
171
|
+
{ locator: 'github.com/owner/missing-repo', alias: 'missing' },
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
const plan = buildReconcilePlan(pool, desired)
|
|
175
|
+
|
|
176
|
+
expect(plan.missing.length).toBe(1)
|
|
177
|
+
expect(plan.missing[0].repo).toBe('missing-repo')
|
|
178
|
+
|
|
179
|
+
expect(plan.behind.length).toBe(1)
|
|
180
|
+
expect(plan.behind[0].repo).toBe('exists-behind')
|
|
181
|
+
|
|
182
|
+
expect(plan.extra.length).toBe(1)
|
|
183
|
+
expect(plan.extra[0].repo).toBe('orphan')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('executeReconcilePlan — plan-first reporting', () => {
|
|
188
|
+
test('empty plan → all zero count', () => {
|
|
189
|
+
const results = executeReconcilePlan({ missing: [], behind: [], extra: [] })
|
|
190
|
+
expect(results).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('missing entry → failed status', () => {
|
|
194
|
+
const plan = {
|
|
195
|
+
missing: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'Not found', aliases: ['r'] }],
|
|
196
|
+
behind: [],
|
|
197
|
+
extra: [],
|
|
198
|
+
}
|
|
199
|
+
const results = executeReconcilePlan(plan)
|
|
200
|
+
expect(results.length).toBe(1)
|
|
201
|
+
expect(results[0].status).toBe('failed')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('behind entry → skipped status', () => {
|
|
205
|
+
const plan = {
|
|
206
|
+
missing: [],
|
|
207
|
+
behind: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'HEAD mismatch', aliases: ['r'] }],
|
|
208
|
+
extra: [],
|
|
209
|
+
}
|
|
210
|
+
const results = executeReconcilePlan(plan)
|
|
211
|
+
expect(results[0].status).toBe('skipped')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('extra entry → extra-reported status', () => {
|
|
215
|
+
const plan = {
|
|
216
|
+
missing: [],
|
|
217
|
+
behind: [],
|
|
218
|
+
extra: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'Orphan', aliases: [] }],
|
|
219
|
+
}
|
|
220
|
+
const results = executeReconcilePlan(plan)
|
|
221
|
+
expect(results[0].status).toBe('extra-reported')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('io.log captures output', () => {
|
|
225
|
+
const lines: string[] = []
|
|
226
|
+
const plan = {
|
|
227
|
+
missing: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/p/github.com/o/r', reason: 'X', aliases: ['r'] }],
|
|
228
|
+
behind: [],
|
|
229
|
+
extra: [],
|
|
230
|
+
}
|
|
231
|
+
executeReconcilePlan(plan, { log: (m) => lines.push(m) })
|
|
232
|
+
const report = lines.join('\n')
|
|
233
|
+
expect(report).toContain('Missing: 1')
|
|
234
|
+
expect(report).toContain('❌ Missing')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile plan: k8s-style desired vs actual convergence.
|
|
3
|
+
*
|
|
4
|
+
* Per ADR-20260507021957847 and EPIC-20260507191713917.
|
|
5
|
+
* Plan builders are pure (read-only, no mutation). Executors inject IO.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { basename, join, relative } from 'node:path'
|
|
10
|
+
import type { ColdPool } from './cold-pool.js'
|
|
11
|
+
import { parseLocator } from './parse-locator.js'
|
|
12
|
+
|
|
13
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface DesiredSkill {
|
|
16
|
+
locator: string
|
|
17
|
+
alias: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReconcileDesiredState {
|
|
21
|
+
deckPath: string
|
|
22
|
+
skills: DesiredSkill[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReconcileEntry {
|
|
26
|
+
host: string
|
|
27
|
+
owner: string
|
|
28
|
+
repo: string
|
|
29
|
+
repoPath: string
|
|
30
|
+
reason: string
|
|
31
|
+
/** The affected skill aliases, if any. */
|
|
32
|
+
aliases: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReconcilePlan {
|
|
36
|
+
/** Skills whose repo dir doesn't exist in the cold pool. */
|
|
37
|
+
missing: ReconcileEntry[]
|
|
38
|
+
/** Skills whose repo exists but git HEAD doesn't match the metadata record. */
|
|
39
|
+
behind: ReconcileEntry[]
|
|
40
|
+
/** Repos in the cold pool not referenced by any skill in the desired state. */
|
|
41
|
+
extra: ReconcileEntry[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
interface RepoKey {
|
|
47
|
+
host: string
|
|
48
|
+
owner: string
|
|
49
|
+
repo: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function repoKey(host: string, owner: string, repo: string): string {
|
|
53
|
+
return `${host}/${owner}/${repo}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract (host, owner, repo) from a source path relative to cold-pool root.
|
|
58
|
+
* Uses parseLocator — the source path is an FQ locator whose first 3 segments
|
|
59
|
+
* give the canonical repo directory.
|
|
60
|
+
*/
|
|
61
|
+
function extractRepo(source: string): RepoKey | null {
|
|
62
|
+
const loc = parseLocator(source)
|
|
63
|
+
if (!loc) return null
|
|
64
|
+
return { host: loc.host, owner: loc.owner, repo: loc.repo }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Plan builder ───────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export function buildReconcilePlan(
|
|
70
|
+
coldPool: ColdPool,
|
|
71
|
+
desired: ReconcileDesiredState,
|
|
72
|
+
): ReconcilePlan {
|
|
73
|
+
// 1. Index desired skills by repo
|
|
74
|
+
const desiredRepos = new Map<string, { key: RepoKey; aliases: string[] }>()
|
|
75
|
+
const unrecognized: ReconcileEntry[] = []
|
|
76
|
+
|
|
77
|
+
for (const skill of desired.skills) {
|
|
78
|
+
const repo = extractRepo(skill.locator)
|
|
79
|
+
if (!repo) {
|
|
80
|
+
unrecognized.push({
|
|
81
|
+
host: '', owner: '', repo: skill.locator, repoPath: '',
|
|
82
|
+
reason: `Unrecognized locator format: "${skill.locator}"`,
|
|
83
|
+
aliases: [skill.alias],
|
|
84
|
+
})
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const k = repoKey(repo.host, repo.owner, repo.repo)
|
|
88
|
+
const existing = desiredRepos.get(k)
|
|
89
|
+
if (existing) {
|
|
90
|
+
existing.aliases.push(skill.alias)
|
|
91
|
+
} else {
|
|
92
|
+
desiredRepos.set(k, { key: repo, aliases: [skill.alias] })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Enumerate cold pool actual state
|
|
97
|
+
const coldPoolDirs = coldPool.list() // absolute paths
|
|
98
|
+
const coldPoolRepos = new Map<string, string>() // repoKey → absolute path
|
|
99
|
+
for (const dir of coldPoolDirs) {
|
|
100
|
+
const rel = relative(coldPool.path, dir)
|
|
101
|
+
const parts = rel.split('/')
|
|
102
|
+
if (parts.length >= 3) {
|
|
103
|
+
const k = repoKey(parts[0], parts[1], parts[2])
|
|
104
|
+
coldPoolRepos.set(k, dir)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 3. Compare: missing + behind
|
|
109
|
+
const missing: ReconcileEntry[] = [...unrecognized]
|
|
110
|
+
const behind: ReconcileEntry[] = []
|
|
111
|
+
|
|
112
|
+
for (const [k, { key, aliases }] of desiredRepos) {
|
|
113
|
+
const dir = coldPoolRepos.get(k)
|
|
114
|
+
if (!dir || !existsSync(dir)) {
|
|
115
|
+
missing.push({
|
|
116
|
+
host: key.host, owner: key.owner, repo: key.repo,
|
|
117
|
+
repoPath: coldPool.resolveDir({ raw: '', host: key.host, owner: key.owner, repo: key.repo, skill: null, isLocalhost: key.host === 'localhost' }),
|
|
118
|
+
reason: 'Repo directory not found in cold pool',
|
|
119
|
+
aliases,
|
|
120
|
+
})
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check metadata DB for recorded HEAD ref
|
|
125
|
+
const recordedRef = coldPool.metadata.getRepoRef(key.host, key.owner, key.repo)
|
|
126
|
+
if (recordedRef) {
|
|
127
|
+
// Metadata has a record — repo was previously fetched via deck add.
|
|
128
|
+
// We can't check current HEAD here because that requires async git ops.
|
|
129
|
+
// Mark as "needs verification" — the async executor will check.
|
|
130
|
+
behind.push({
|
|
131
|
+
host: key.host, owner: key.owner, repo: key.repo,
|
|
132
|
+
repoPath: dir,
|
|
133
|
+
reason: `Recorded HEAD: ${recordedRef.slice(0, 8)} — verify against current`,
|
|
134
|
+
aliases,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
// If no metadata record, the repo exists but wasn't recorded via deck add.
|
|
138
|
+
// This is fine (e.g. manually cloned). Don't flag.
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 4. Compare: extra (in cold pool but not referenced by desired state)
|
|
142
|
+
const extra: ReconcileEntry[] = []
|
|
143
|
+
for (const [k, dir] of coldPoolRepos) {
|
|
144
|
+
if (!desiredRepos.has(k)) {
|
|
145
|
+
const parts = k.split('/')
|
|
146
|
+
extra.push({
|
|
147
|
+
host: parts[0], owner: parts[1], repo: parts[2],
|
|
148
|
+
repoPath: dir,
|
|
149
|
+
reason: 'Not referenced by any skill in the desired state',
|
|
150
|
+
aliases: [],
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { missing, behind, extra }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Execution (IO layer, injectable for testing) ───────────────────────────
|
|
159
|
+
|
|
160
|
+
export interface ReconcileResult {
|
|
161
|
+
host: string
|
|
162
|
+
owner: string
|
|
163
|
+
repo: string
|
|
164
|
+
status: 'restored' | 'updated' | 'skipped' | 'failed' | 'extra-reported'
|
|
165
|
+
message: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface ReconcileIO {
|
|
169
|
+
gitClone?: (url: string, dir: string, opts?: { depth?: number }) => void
|
|
170
|
+
log?: (msg: string) => void
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function executeReconcilePlan(
|
|
174
|
+
plan: ReconcilePlan,
|
|
175
|
+
_io?: ReconcileIO,
|
|
176
|
+
): ReconcileResult[] {
|
|
177
|
+
const log = _io?.log ?? (() => {})
|
|
178
|
+
const results: ReconcileResult[] = []
|
|
179
|
+
|
|
180
|
+
// Report phase — plan-first: show what would happen
|
|
181
|
+
log('\n🔍 Reconcile Plan')
|
|
182
|
+
log(` Missing: ${plan.missing.length} | Behind: ${plan.behind.length} | Extra: ${plan.extra.length}`)
|
|
183
|
+
|
|
184
|
+
for (const entry of plan.missing) {
|
|
185
|
+
const repo = `${entry.host}/${entry.owner}/${entry.repo}`
|
|
186
|
+
log(` ❌ Missing: ${repo} — ${entry.reason}`)
|
|
187
|
+
log(` Affected: ${entry.aliases.join(', ')}`)
|
|
188
|
+
results.push({
|
|
189
|
+
host: entry.host, owner: entry.owner, repo: entry.repo,
|
|
190
|
+
status: 'failed',
|
|
191
|
+
message: `Missing: ${entry.reason}`,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const entry of plan.behind) {
|
|
196
|
+
const repo = `${entry.host}/${entry.owner}/${entry.repo}`
|
|
197
|
+
log(` ⚠️ Behind: ${repo}`)
|
|
198
|
+
log(` ${entry.reason}`)
|
|
199
|
+
results.push({
|
|
200
|
+
host: entry.host, owner: entry.owner, repo: entry.repo,
|
|
201
|
+
status: 'skipped',
|
|
202
|
+
message: entry.reason,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const entry of plan.extra) {
|
|
207
|
+
const repo = `${entry.host}/${entry.owner}/${entry.repo}`
|
|
208
|
+
log(` 📦 Extra: ${repo} — ${entry.reason}`)
|
|
209
|
+
results.push({
|
|
210
|
+
host: entry.host, owner: entry.owner, repo: entry.repo,
|
|
211
|
+
status: 'extra-reported',
|
|
212
|
+
message: entry.reason,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return results
|
|
217
|
+
}
|