happyskills 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const crypto = require('crypto')
4
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
5
+ const { ensure_dir, read_json, file_exists } = require('../utils/fs')
6
+ const { hash_directory } = require('../lock/integrity')
7
+ const {
8
+ skill_snapshots_dir,
9
+ snapshot_dir,
10
+ snapshot_manifest_path,
11
+ snapshot_content_dir
12
+ } = require('./paths')
13
+
14
+ const DEFAULT_RETENTION = 10
15
+
16
+ // Combine the directory hash with a deterministic lock-entry serialization so
17
+ // the snapshot_state_hash uniquely identifies the full state being preserved.
18
+ // If two snapshots share the same hash, restoring either produces the same
19
+ // outcome — useful for list/dedup ergonomics.
20
+ const compute_skill_state_hash = (dir_integrity, lock_entry) => {
21
+ const lock_serialized = JSON.stringify(lock_entry || null, Object.keys(lock_entry || {}).sort())
22
+ const h = crypto.createHash('sha256')
23
+ h.update(dir_integrity || '')
24
+ h.update('|')
25
+ h.update(lock_serialized)
26
+ return `sha256-${h.digest('hex')}`
27
+ }
28
+
29
+ const make_snapshot_id = (state_hash) => {
30
+ const ts = new Date().toISOString().replace(/[:.]/g, '').replace('Z', 'Z')
31
+ const short = state_hash.replace(/^sha256-/, '').slice(0, 12)
32
+ return `snap_${ts}_${short}`
33
+ }
34
+
35
+ // Atomic recursive copy: copy into a sibling .tmp then rename into place.
36
+ // rename() is atomic on the same filesystem, so the snapshot is either fully
37
+ // present or absent — no half-copied states.
38
+ const copy_dir = async (src, dst) => {
39
+ await fs.promises.cp(src, dst, { recursive: true, force: true, errorOnExist: false })
40
+ }
41
+
42
+ const create = (options) => catch_errors('Failed to create snapshot', async () => {
43
+ const { skill_dir, workspace, skill, lock_entry, note, is_global, project_root, retention } = options
44
+ if (!workspace || !skill) throw new Error('workspace and skill are required')
45
+ if (!skill_dir) throw new Error('skill_dir is required')
46
+
47
+ const [, dir_present] = await file_exists(skill_dir)
48
+ if (!dir_present) throw new Error(`Cannot snapshot — skill directory does not exist: ${skill_dir}`)
49
+
50
+ const [hash_err, dir_integrity] = await hash_directory(skill_dir)
51
+ if (hash_err) throw e('Failed to hash skill directory', hash_err)
52
+
53
+ const state_hash = compute_skill_state_hash(dir_integrity, lock_entry)
54
+ const snapshot_id = make_snapshot_id(state_hash)
55
+ const dest = snapshot_dir(is_global, project_root, workspace, skill, snapshot_id)
56
+ const content_dest = snapshot_content_dir(dest)
57
+
58
+ const [parent_err] = await ensure_dir(path.dirname(dest))
59
+ if (parent_err) throw e('Failed to ensure snapshots parent dir', parent_err)
60
+
61
+ const tmp_dest = `${dest}.tmp-${process.pid}-${Date.now()}`
62
+ const tmp_content = path.join(tmp_dest, 'content')
63
+ await fs.promises.mkdir(tmp_dest, { recursive: true })
64
+ await copy_dir(skill_dir, tmp_content)
65
+
66
+ const manifest = {
67
+ snapshot_id,
68
+ timestamp: new Date().toISOString(),
69
+ skill_state_hash: state_hash,
70
+ workspace,
71
+ skill,
72
+ dir_integrity,
73
+ note: note || null,
74
+ lock_entry: lock_entry || null
75
+ }
76
+ await fs.promises.writeFile(
77
+ path.join(tmp_dest, 'manifest.json'),
78
+ JSON.stringify(manifest, null, '\t') + '\n',
79
+ 'utf-8'
80
+ )
81
+
82
+ await fs.promises.rename(tmp_dest, dest)
83
+
84
+ const keep = typeof retention === 'number' ? retention : DEFAULT_RETENTION
85
+ const [, prune_result] = await prune({ workspace, skill, is_global, project_root, keep })
86
+
87
+ return {
88
+ snapshot_id,
89
+ path: dest,
90
+ timestamp: manifest.timestamp,
91
+ skill_state_hash: state_hash,
92
+ note: manifest.note,
93
+ pruned: prune_result?.deleted_ids || []
94
+ }
95
+ })
96
+
97
+ const list = (options) => catch_errors('Failed to list snapshots', async () => {
98
+ const { workspace, skill, is_global, project_root } = options
99
+ const skill_dir = skill_snapshots_dir(is_global, project_root, workspace, skill)
100
+ const [, present] = await file_exists(skill_dir)
101
+ if (!present) return { snapshots: [] }
102
+ const entries = await fs.promises.readdir(skill_dir, { withFileTypes: true })
103
+ const snapshots = []
104
+ for (const entry of entries) {
105
+ if (!entry.isDirectory()) continue
106
+ if (!entry.name.startsWith('snap_')) continue
107
+ const dest = path.join(skill_dir, entry.name)
108
+ const [, manifest] = await read_json(snapshot_manifest_path(dest))
109
+ if (!manifest) continue
110
+ snapshots.push({
111
+ snapshot_id: manifest.snapshot_id,
112
+ timestamp: manifest.timestamp,
113
+ skill_state_hash: manifest.skill_state_hash,
114
+ note: manifest.note,
115
+ path: dest
116
+ })
117
+ }
118
+ snapshots.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1))
119
+ return { snapshots }
120
+ })
121
+
122
+ const find_snapshot = (snapshot_id, options) => catch_errors('Failed to find snapshot', async () => {
123
+ const { is_global, project_root } = options
124
+ const { snapshots_root } = require('./paths')
125
+ const root = snapshots_root(is_global, project_root)
126
+ const [, root_present] = await file_exists(root)
127
+ if (!root_present) return null
128
+ const workspaces = await fs.promises.readdir(root, { withFileTypes: true })
129
+ for (const ws of workspaces) {
130
+ if (!ws.isDirectory()) continue
131
+ const ws_dir = path.join(root, ws.name)
132
+ const skills = await fs.promises.readdir(ws_dir, { withFileTypes: true })
133
+ for (const sk of skills) {
134
+ if (!sk.isDirectory()) continue
135
+ const candidate = path.join(ws_dir, sk.name, snapshot_id)
136
+ const [, present] = await file_exists(candidate)
137
+ if (present) {
138
+ const [, manifest] = await read_json(snapshot_manifest_path(candidate))
139
+ if (manifest) return { manifest, path: candidate, workspace: ws.name, skill: sk.name }
140
+ }
141
+ }
142
+ }
143
+ return null
144
+ })
145
+
146
+ // Restore atomically: copy snapshot/content into a sibling .restore-tmp, then
147
+ // swap it with the live skill directory. The previous live directory is moved
148
+ // to a .pre-restore-* path next to it so the operation is fully reversible.
149
+ const restore = (snapshot_id, options) => catch_errors('Failed to restore snapshot', async () => {
150
+ const { skill_dir, is_global, project_root, verify_hash } = options
151
+ if (!skill_dir) throw new Error('skill_dir is required for restore')
152
+
153
+ const [find_err, found] = await find_snapshot(snapshot_id, { is_global, project_root })
154
+ if (find_err) throw e('Failed to look up snapshot', find_err)
155
+ if (!found) throw new Error(`Snapshot not found: ${snapshot_id}`)
156
+
157
+ const content_src = snapshot_content_dir(found.path)
158
+ const [, content_present] = await file_exists(content_src)
159
+ if (!content_present) throw new Error(`Snapshot content missing: ${content_src}`)
160
+
161
+ if (verify_hash !== false && found.manifest.dir_integrity) {
162
+ const [hash_err, current_snap_hash] = await hash_directory(content_src)
163
+ if (!hash_err && current_snap_hash !== found.manifest.dir_integrity) {
164
+ throw new Error(`SNAPSHOT_CORRUPTED: ${snapshot_id} content hash does not match manifest`)
165
+ }
166
+ }
167
+
168
+ const parent = path.dirname(skill_dir)
169
+ await ensure_dir(parent)
170
+ const restore_tmp = `${skill_dir}.restore-tmp-${process.pid}-${Date.now()}`
171
+ await copy_dir(content_src, restore_tmp)
172
+
173
+ const [, live_present] = await file_exists(skill_dir)
174
+ const trash = `${skill_dir}.pre-restore-${process.pid}-${Date.now()}`
175
+ if (live_present) {
176
+ await fs.promises.rename(skill_dir, trash)
177
+ }
178
+ try {
179
+ await fs.promises.rename(restore_tmp, skill_dir)
180
+ } catch (err) {
181
+ // Best-effort rollback if rename of restored content fails.
182
+ if (live_present) {
183
+ await fs.promises.rename(trash, skill_dir).catch(() => {})
184
+ }
185
+ throw e('Failed to swap restored content into place', err)
186
+ }
187
+ if (live_present) {
188
+ await fs.promises.rm(trash, { recursive: true, force: true }).catch(() => {})
189
+ }
190
+
191
+ return {
192
+ restored: true,
193
+ snapshot_id,
194
+ workspace: found.workspace,
195
+ skill: found.skill,
196
+ path: found.path,
197
+ version: found.manifest.lock_entry?.version || null
198
+ }
199
+ })
200
+
201
+ const remove = (snapshot_id, options) => catch_errors('Failed to delete snapshot', async () => {
202
+ const { is_global, project_root } = options
203
+ const [, found] = await find_snapshot(snapshot_id, { is_global, project_root })
204
+ if (!found) return { deleted: false, snapshot_id, reason: 'not_found' }
205
+ await fs.promises.rm(found.path, { recursive: true, force: true })
206
+ return { deleted: true, snapshot_id }
207
+ })
208
+
209
+ const prune = (options) => catch_errors('Failed to prune snapshots', async () => {
210
+ const { workspace, skill, is_global, project_root, keep } = options
211
+ const k = typeof keep === 'number' && keep >= 0 ? keep : DEFAULT_RETENTION
212
+ const [, listing] = await list({ workspace, skill, is_global, project_root })
213
+ if (!listing || !listing.snapshots) return { kept: 0, deleted: 0, deleted_ids: [] }
214
+ const sorted = listing.snapshots
215
+ const to_keep = sorted.slice(0, k)
216
+ const to_delete = sorted.slice(k)
217
+ for (const snap of to_delete) {
218
+ await fs.promises.rm(snap.path, { recursive: true, force: true })
219
+ }
220
+ return {
221
+ kept: to_keep.length,
222
+ deleted: to_delete.length,
223
+ deleted_ids: to_delete.map(s => s.snapshot_id)
224
+ }
225
+ })
226
+
227
+ module.exports = {
228
+ create,
229
+ list,
230
+ restore,
231
+ remove,
232
+ prune,
233
+ find_snapshot,
234
+ compute_skill_state_hash,
235
+ make_snapshot_id,
236
+ DEFAULT_RETENTION
237
+ }
@@ -0,0 +1,298 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const os = require('os')
7
+
8
+ const storage = require('./storage')
9
+ const { clear_integrity_cache } = require('../lock/integrity')
10
+
11
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-snapshot-test-'))
12
+ const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
13
+
14
+ const write_skill = (skill_dir, files) => {
15
+ fs.mkdirSync(skill_dir, { recursive: true })
16
+ for (const [rel, content] of Object.entries(files)) {
17
+ const full = path.join(skill_dir, rel)
18
+ fs.mkdirSync(path.dirname(full), { recursive: true })
19
+ fs.writeFileSync(full, content)
20
+ }
21
+ }
22
+
23
+ describe('snapshot.compute_skill_state_hash', () => {
24
+ it('is deterministic for identical inputs', () => {
25
+ const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0', commit: 'x' })
26
+ const b = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0', commit: 'x' })
27
+ assert.strictEqual(a, b)
28
+ assert.match(a, /^sha256-[0-9a-f]{64}$/)
29
+ })
30
+
31
+ it('differs when content hash changes', () => {
32
+ const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0' })
33
+ const b = storage.compute_skill_state_hash('sha256-def', { version: '1.0.0' })
34
+ assert.notStrictEqual(a, b)
35
+ })
36
+
37
+ it('differs when lock entry changes', () => {
38
+ const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0' })
39
+ const b = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.1' })
40
+ assert.notStrictEqual(a, b)
41
+ })
42
+
43
+ it('order of lock-entry keys does not affect the hash', () => {
44
+ const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0', commit: 'x', integrity: 'y' })
45
+ const b = storage.compute_skill_state_hash('sha256-abc', { integrity: 'y', commit: 'x', version: '1.0.0' })
46
+ assert.strictEqual(a, b)
47
+ })
48
+ })
49
+
50
+ describe('snapshot.create', () => {
51
+ it('captures skill directory contents and manifest', async () => {
52
+ const root = make_tmp()
53
+ clear_integrity_cache()
54
+ try {
55
+ const skill_dir = path.join(root, '.agents', 'skills', 'my-skill')
56
+ write_skill(skill_dir, {
57
+ 'skill.json': JSON.stringify({ name: 'my-skill', version: '1.0.0' }),
58
+ 'SKILL.md': '---\nname: my-skill\ndescription: test\n---\nbody\n'
59
+ })
60
+ const lock_entry = { version: '1.0.0', commit: 'abc', integrity: 'sha256-x' }
61
+ const [err, result] = await storage.create({
62
+ skill_dir, workspace: 'acme', skill: 'my-skill', lock_entry,
63
+ is_global: false, project_root: root, note: 'pre-test'
64
+ })
65
+ assert.strictEqual(err, null)
66
+ assert.match(result.snapshot_id, /^snap_/)
67
+ assert.match(result.skill_state_hash, /^sha256-/)
68
+ assert.strictEqual(result.note, 'pre-test')
69
+ assert.ok(fs.existsSync(result.path), 'snapshot dir must exist')
70
+ assert.ok(fs.existsSync(path.join(result.path, 'manifest.json')), 'manifest must exist')
71
+ assert.ok(fs.existsSync(path.join(result.path, 'content', 'skill.json')), 'content/skill.json must exist')
72
+ assert.ok(fs.existsSync(path.join(result.path, 'content', 'SKILL.md')), 'content/SKILL.md must exist')
73
+ const manifest = JSON.parse(fs.readFileSync(path.join(result.path, 'manifest.json'), 'utf-8'))
74
+ assert.strictEqual(manifest.workspace, 'acme')
75
+ assert.strictEqual(manifest.skill, 'my-skill')
76
+ assert.deepEqual(manifest.lock_entry, lock_entry)
77
+ } finally { cleanup(root); clear_integrity_cache() }
78
+ })
79
+
80
+ it('fails when the skill directory does not exist', async () => {
81
+ const root = make_tmp()
82
+ clear_integrity_cache()
83
+ try {
84
+ const skill_dir = path.join(root, '.agents', 'skills', 'ghost')
85
+ const [err] = await storage.create({
86
+ skill_dir, workspace: 'acme', skill: 'ghost',
87
+ is_global: false, project_root: root
88
+ })
89
+ assert.notStrictEqual(err, null)
90
+ } finally { cleanup(root); clear_integrity_cache() }
91
+ })
92
+ })
93
+
94
+ describe('snapshot.list', () => {
95
+ it('returns empty list when no snapshots exist', async () => {
96
+ const root = make_tmp()
97
+ try {
98
+ const [err, result] = await storage.list({
99
+ workspace: 'acme', skill: 'no-such', is_global: false, project_root: root
100
+ })
101
+ assert.strictEqual(err, null)
102
+ assert.deepEqual(result.snapshots, [])
103
+ } finally { cleanup(root) }
104
+ })
105
+
106
+ it('lists created snapshots newest-first', async () => {
107
+ const root = make_tmp()
108
+ clear_integrity_cache()
109
+ try {
110
+ const skill_dir = path.join(root, '.agents', 'skills', 'multi')
111
+ write_skill(skill_dir, { 'skill.json': '{"name":"multi","version":"1.0.0"}' })
112
+ const [, first] = await storage.create({
113
+ skill_dir, workspace: 'acme', skill: 'multi', lock_entry: { version: '1.0.0' },
114
+ is_global: false, project_root: root, note: 'first'
115
+ })
116
+ // Force the integrity cache to recompute so the second snapshot has a
117
+ // distinct content hash and thus a distinct snapshot_id.
118
+ clear_integrity_cache()
119
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"multi","version":"1.0.1"}')
120
+ // Sleep 10ms to guarantee a different timestamp in the id.
121
+ await new Promise(r => setTimeout(r, 10))
122
+ const [, second] = await storage.create({
123
+ skill_dir, workspace: 'acme', skill: 'multi', lock_entry: { version: '1.0.1' },
124
+ is_global: false, project_root: root, note: 'second'
125
+ })
126
+ const [, listing] = await storage.list({
127
+ workspace: 'acme', skill: 'multi', is_global: false, project_root: root
128
+ })
129
+ assert.strictEqual(listing.snapshots.length, 2)
130
+ assert.strictEqual(listing.snapshots[0].snapshot_id, second.snapshot_id, 'newest is first')
131
+ assert.strictEqual(listing.snapshots[1].snapshot_id, first.snapshot_id)
132
+ } finally { cleanup(root); clear_integrity_cache() }
133
+ })
134
+ })
135
+
136
+ describe('snapshot.restore', () => {
137
+ it('restores a captured directory bit-for-bit', async () => {
138
+ const root = make_tmp()
139
+ clear_integrity_cache()
140
+ try {
141
+ const skill_dir = path.join(root, '.agents', 'skills', 'restorable')
142
+ write_skill(skill_dir, {
143
+ 'skill.json': '{"name":"restorable","version":"1.0.0"}',
144
+ 'SKILL.md': 'original body\n',
145
+ 'references/foo.md': 'original reference\n'
146
+ })
147
+ const [, snap] = await storage.create({
148
+ skill_dir, workspace: 'acme', skill: 'restorable',
149
+ lock_entry: { version: '1.0.0' },
150
+ is_global: false, project_root: root
151
+ })
152
+
153
+ // Mutate the live directory after snapshot.
154
+ fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), 'mutated body\n')
155
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"restorable","version":"9.9.9"}')
156
+ fs.rmSync(path.join(skill_dir, 'references'), { recursive: true })
157
+
158
+ const [err, result] = await storage.restore(snap.snapshot_id, {
159
+ skill_dir, is_global: false, project_root: root
160
+ })
161
+ assert.strictEqual(err, null)
162
+ assert.strictEqual(result.restored, true)
163
+ assert.strictEqual(
164
+ fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8'),
165
+ 'original body\n'
166
+ )
167
+ assert.strictEqual(
168
+ fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8'),
169
+ '{"name":"restorable","version":"1.0.0"}'
170
+ )
171
+ assert.strictEqual(
172
+ fs.readFileSync(path.join(skill_dir, 'references', 'foo.md'), 'utf-8'),
173
+ 'original reference\n'
174
+ )
175
+ } finally { cleanup(root); clear_integrity_cache() }
176
+ })
177
+
178
+ it('refuses corrupted snapshots', async () => {
179
+ const root = make_tmp()
180
+ clear_integrity_cache()
181
+ try {
182
+ const skill_dir = path.join(root, '.agents', 'skills', 'corrupt-snap')
183
+ write_skill(skill_dir, { 'skill.json': '{"name":"corrupt-snap","version":"1.0.0"}' })
184
+ const [, snap] = await storage.create({
185
+ skill_dir, workspace: 'acme', skill: 'corrupt-snap',
186
+ lock_entry: { version: '1.0.0' },
187
+ is_global: false, project_root: root
188
+ })
189
+ // Tamper with snapshot content but leave manifest's expected hash in place.
190
+ fs.writeFileSync(path.join(snap.path, 'content', 'skill.json'), '{"name":"tampered","version":"9.9.9"}')
191
+
192
+ const [err] = await storage.restore(snap.snapshot_id, {
193
+ skill_dir, is_global: false, project_root: root
194
+ })
195
+ assert.notStrictEqual(err, null)
196
+ const messages = err.map(x => x?.message || '').join('|')
197
+ assert.match(messages, /SNAPSHOT_CORRUPTED/)
198
+ } finally { cleanup(root); clear_integrity_cache() }
199
+ })
200
+
201
+ it('throws when the snapshot id is unknown', async () => {
202
+ const root = make_tmp()
203
+ try {
204
+ const skill_dir = path.join(root, '.agents', 'skills', 'whatever')
205
+ const [err] = await storage.restore('snap_doesnotexist_000000000000', {
206
+ skill_dir, is_global: false, project_root: root
207
+ })
208
+ assert.notStrictEqual(err, null)
209
+ } finally { cleanup(root) }
210
+ })
211
+ })
212
+
213
+ describe('snapshot.remove', () => {
214
+ it('removes a snapshot directory', async () => {
215
+ const root = make_tmp()
216
+ clear_integrity_cache()
217
+ try {
218
+ const skill_dir = path.join(root, '.agents', 'skills', 'rmable')
219
+ write_skill(skill_dir, { 'skill.json': '{"name":"rmable","version":"1.0.0"}' })
220
+ const [, snap] = await storage.create({
221
+ skill_dir, workspace: 'acme', skill: 'rmable', lock_entry: { version: '1.0.0' },
222
+ is_global: false, project_root: root
223
+ })
224
+ const [err, result] = await storage.remove(snap.snapshot_id, { is_global: false, project_root: root })
225
+ assert.strictEqual(err, null)
226
+ assert.strictEqual(result.deleted, true)
227
+ assert.ok(!fs.existsSync(snap.path), 'snapshot dir should be gone')
228
+ } finally { cleanup(root); clear_integrity_cache() }
229
+ })
230
+
231
+ it('reports not_found for unknown ids', async () => {
232
+ const root = make_tmp()
233
+ try {
234
+ const [err, result] = await storage.remove('snap_nope_xxx', { is_global: false, project_root: root })
235
+ assert.strictEqual(err, null)
236
+ assert.strictEqual(result.deleted, false)
237
+ assert.strictEqual(result.reason, 'not_found')
238
+ } finally { cleanup(root) }
239
+ })
240
+ })
241
+
242
+ describe('snapshot.prune', () => {
243
+ it('keeps the N most recent and deletes the rest', async () => {
244
+ const root = make_tmp()
245
+ clear_integrity_cache()
246
+ try {
247
+ const skill_dir = path.join(root, '.agents', 'skills', 'prune-me')
248
+ // Make 5 snapshots, vary content so each gets a distinct id.
249
+ const created_ids = []
250
+ for (let i = 0; i < 5; i++) {
251
+ clear_integrity_cache()
252
+ write_skill(skill_dir, { 'skill.json': `{"name":"prune-me","version":"1.0.${i}"}` })
253
+ await new Promise(r => setTimeout(r, 5))
254
+ const [, snap] = await storage.create({
255
+ skill_dir, workspace: 'acme', skill: 'prune-me', lock_entry: { version: `1.0.${i}` },
256
+ is_global: false, project_root: root, retention: 100
257
+ })
258
+ created_ids.push(snap.snapshot_id)
259
+ }
260
+ const [err, result] = await storage.prune({
261
+ workspace: 'acme', skill: 'prune-me', is_global: false, project_root: root, keep: 2
262
+ })
263
+ assert.strictEqual(err, null)
264
+ assert.strictEqual(result.kept, 2)
265
+ assert.strictEqual(result.deleted, 3)
266
+ assert.strictEqual(result.deleted_ids.length, 3)
267
+ // The 3 oldest should be deleted (created_ids[0..2]).
268
+ for (const old of created_ids.slice(0, 3)) {
269
+ assert.ok(result.deleted_ids.includes(old), `oldest ${old} should be deleted`)
270
+ }
271
+ } finally { cleanup(root); clear_integrity_cache() }
272
+ })
273
+
274
+ it('auto-prunes on create when default retention is hit', async () => {
275
+ const root = make_tmp()
276
+ clear_integrity_cache()
277
+ try {
278
+ const skill_dir = path.join(root, '.agents', 'skills', 'auto-prune')
279
+ let last
280
+ for (let i = 0; i < 12; i++) {
281
+ clear_integrity_cache()
282
+ write_skill(skill_dir, { 'skill.json': `{"name":"auto-prune","version":"1.0.${i}"}` })
283
+ await new Promise(r => setTimeout(r, 5))
284
+ const [, snap] = await storage.create({
285
+ skill_dir, workspace: 'acme', skill: 'auto-prune', lock_entry: { version: `1.0.${i}` },
286
+ is_global: false, project_root: root
287
+ // no retention override — uses DEFAULT_RETENTION=10
288
+ })
289
+ last = snap
290
+ }
291
+ const [, listing] = await storage.list({
292
+ workspace: 'acme', skill: 'auto-prune', is_global: false, project_root: root
293
+ })
294
+ assert.strictEqual(listing.snapshots.length, 10, 'should keep exactly 10 (DEFAULT_RETENTION)')
295
+ assert.strictEqual(listing.snapshots[0].snapshot_id, last.snapshot_id, 'newest must survive')
296
+ } finally { cleanup(root); clear_integrity_cache() }
297
+ })
298
+ })
@@ -1,27 +1,34 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { error: { catch_errors } } = require('puffy-core')
4
+ const { MAX_FILE_SIZE, MAX_TOTAL_SIZE } = require('../config/limits')
4
5
 
5
- const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
6
+ const MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024)
7
+ const MAX_TOTAL_SIZE_MB = MAX_TOTAL_SIZE / (1024 * 1024)
6
8
 
7
- const result = (file, actual_size) => ({
9
+ const file_result = (file, actual_size) => ({
8
10
  file,
9
11
  field: null,
10
12
  rule: 'max_file_size',
11
13
  severity: 'error',
12
- message: `File exceeds 1MB limit (${(actual_size / (1024 * 1024)).toFixed(2)}MB)`
14
+ message: `File exceeds ${MAX_FILE_SIZE_MB}MB limit (${(actual_size / (1024 * 1024)).toFixed(2)}MB)`
15
+ })
16
+
17
+ const total_result = (actual_size) => ({
18
+ file: null,
19
+ field: null,
20
+ rule: 'max_total_size',
21
+ severity: 'error',
22
+ message: `Skill bundle exceeds ${MAX_TOTAL_SIZE_MB}MB limit (${(actual_size / (1024 * 1024)).toFixed(2)}MB)`
13
23
  })
14
24
 
15
25
  /**
16
- * Scans all files in a skill directory and checks that none exceed the max file size.
17
- * Returns error results for each file that exceeds the limit.
26
+ * Scans all files in a skill directory and checks both per-file and total bundle size.
18
27
  * Skips dotfiles and common non-publishable directories (consistent with hash_directory exclusions).
19
- *
20
- * @param {string} skill_dir - Absolute path to the skill directory
21
- * @returns {[errors, results[]]} — results with severity 'error' for each oversized file
22
28
  */
23
29
  const validate_file_sizes = (skill_dir) => catch_errors('Failed to check file sizes', async () => {
24
30
  const results = []
31
+ let total_size = 0
25
32
  const walk = async (dir, prefix) => {
26
33
  let items
27
34
  try { items = await fs.promises.readdir(dir, { withFileTypes: true }) } catch { return }
@@ -36,12 +43,16 @@ const validate_file_sizes = (skill_dir) => catch_errors('Failed to check file si
36
43
  let stat
37
44
  try { stat = await fs.promises.stat(full) } catch { continue }
38
45
  if (stat.size > MAX_FILE_SIZE) {
39
- results.push(result(rel, stat.size))
46
+ results.push(file_result(rel, stat.size))
40
47
  }
48
+ total_size += stat.size
41
49
  }
42
50
  }
43
51
  }
44
52
  await walk(skill_dir, '')
53
+ if (total_size > MAX_TOTAL_SIZE) {
54
+ results.push(total_result(total_size))
55
+ }
45
56
  return results
46
57
  })
47
58
 
@@ -4,8 +4,7 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
  const os = require('os')
6
6
  const { validate_file_sizes } = require('./file_size_rules')
7
-
8
- const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
7
+ const { MAX_FILE_SIZE, MAX_TOTAL_SIZE } = require('../config/limits')
9
8
 
10
9
  const make_temp_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'file-size-test-'))
11
10
  const clean = (dir) => fs.rmSync(dir, { recursive: true, force: true })
@@ -16,41 +15,47 @@ describe('validate_file_sizes', () => {
16
15
  beforeEach(() => { dir = make_temp_dir() })
17
16
  afterEach(() => { clean(dir) })
18
17
 
19
- it('returns empty for files under 1MB limit', async () => {
18
+ it('returns empty for files under the limit', async () => {
20
19
  fs.writeFileSync(path.join(dir, 'small.txt'), 'hello world')
21
20
  const [err, results] = await validate_file_sizes(dir)
22
21
  assert.strictEqual(err, null)
23
22
  assert.strictEqual(results.length, 0)
24
23
  })
25
24
 
26
- it('returns error for file exceeding 1MB', async () => {
25
+ it('returns max_file_size error for a single file exceeding MAX_FILE_SIZE', async () => {
27
26
  fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
28
27
  const [err, results] = await validate_file_sizes(dir)
29
28
  assert.strictEqual(err, null)
30
- assert.strictEqual(results.length, 1)
31
- assert.strictEqual(results[0].severity, 'error')
32
- assert.strictEqual(results[0].rule, 'max_file_size')
33
- assert.strictEqual(results[0].file, 'big.txt')
29
+ const per_file = results.filter(r => r.rule === 'max_file_size')
30
+ assert.strictEqual(per_file.length, 1)
31
+ assert.strictEqual(per_file[0].severity, 'error')
32
+ assert.strictEqual(per_file[0].file, 'big.txt')
34
33
  })
35
34
 
36
- it('handles multiple oversized files', async () => {
37
- fs.writeFileSync(path.join(dir, 'big1.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
38
- fs.writeFileSync(path.join(dir, 'big2.txt'), Buffer.alloc(MAX_FILE_SIZE + 100))
35
+ it('returns max_total_size error when bundle exceeds MAX_TOTAL_SIZE', async () => {
36
+ // Split across two files so per-file check passes but total fails.
37
+ const half = Math.floor(MAX_FILE_SIZE / 2) + 1
38
+ const each = Math.min(half, MAX_FILE_SIZE)
39
+ const n = Math.ceil((MAX_TOTAL_SIZE + 1) / each)
40
+ for (let i = 0; i < n; i++) {
41
+ fs.writeFileSync(path.join(dir, `f${i}.bin`), Buffer.alloc(each))
42
+ }
39
43
  const [err, results] = await validate_file_sizes(dir)
40
44
  assert.strictEqual(err, null)
41
- assert.strictEqual(results.length, 2)
42
- assert.ok(results.every(r => r.severity === 'error'))
43
- assert.ok(results.every(r => r.rule === 'max_file_size'))
45
+ const bundle = results.filter(r => r.rule === 'max_total_size')
46
+ assert.strictEqual(bundle.length, 1)
47
+ assert.strictEqual(bundle[0].severity, 'error')
44
48
  })
45
49
 
46
- it('reports correct relative path for nested files', async () => {
50
+ it('reports correct relative path for nested oversized files', async () => {
47
51
  const sub = path.join(dir, 'sub')
48
52
  fs.mkdirSync(sub)
49
53
  fs.writeFileSync(path.join(sub, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
50
54
  const [err, results] = await validate_file_sizes(dir)
51
55
  assert.strictEqual(err, null)
52
- assert.strictEqual(results.length, 1)
53
- assert.strictEqual(results[0].file, 'sub/big.txt')
56
+ const per_file = results.filter(r => r.rule === 'max_file_size')
57
+ assert.strictEqual(per_file.length, 1)
58
+ assert.strictEqual(per_file[0].file, 'sub/big.txt')
54
59
  })
55
60
 
56
61
  it('skips dotfiles', async () => {
@@ -69,7 +74,7 @@ describe('validate_file_sizes', () => {
69
74
  assert.strictEqual(results.length, 0)
70
75
  })
71
76
 
72
- it('passes for file exactly at 1MB limit', async () => {
77
+ it('passes for a file exactly at MAX_FILE_SIZE and bundle at MAX_TOTAL_SIZE', async () => {
73
78
  fs.writeFileSync(path.join(dir, 'exact.txt'), Buffer.alloc(MAX_FILE_SIZE))
74
79
  const [err, results] = await validate_file_sizes(dir)
75
80
  assert.strictEqual(err, null)