perf: cache file hashes and share catalog reads to speed up scope pages
7fa277e parent: 30ddc7a modified
packages/core/src/catalog/index.ts +43 -41 | @@ -18,7 +18,7 @@ import { | ||
| 18 | 18 | } from '../catalog-model.ts' |
| 19 | 19 | import type { CatalogStore, OpenCatalog } from '../contracts.ts' |
| 20 | 20 | import type { HookDetails, InventoryItem, McpDetails } from '../model.ts' |
| 21 | -import { hashPath, isDirectory, listDirs, listFilesRecursive, parseFrontmatter, pathExists, readText } from '../util.ts' | |
| 21 | +import { hashPath, isDirectory, listDirs, listFilesRecursive, parseFrontmatter, pathExists, pathFingerprint, readText } from '../util.ts' | |
| 22 | 22 | import { atomicWrite, copyDir, movePath, tmpSuffix } from './fsutil.ts' |
| 23 | 23 | import { assertValidName, deriveName, isInside, normalizeTags, resolveInside } from './names.ts' |
| 24 | 24 | import { normalizeSkillMd, parseHookYaml, parseMcpYaml, parseYamlDoc } from './templates.ts' |
| @@ -137,19 +137,6 @@ class Catalog implements CatalogStore { | ||
| 137 | 137 | |
| 138 | 138 | // ---- reading ---- |
| 139 | 139 | |
| 140 | - private async newestMtime(p: string): Promise<string> { | |
| 141 | - let newest = 0 | |
| 142 | - const files = (await isDirectory(p)) ? (await listFilesRecursive(p, 2000)).map((f) => path.join(p, f)) : [p] | |
| 143 | - for (const f of files) { | |
| 144 | - try { | |
| 145 | - newest = Math.max(newest, (await stat(f)).mtimeMs) | |
| 146 | - } catch { | |
| 147 | - // ignore | |
| 148 | - } | |
| 149 | - } | |
| 150 | - return new Date(newest || Date.now()).toISOString() | |
| 151 | - } | |
| 152 | - | |
| 153 | 140 | private async buildItem( |
| 154 | 141 | ref: CatalogRef, |
| 155 | 142 | description: string | undefined, |
| @@ -157,13 +144,14 @@ class Catalog implements CatalogStore { | ||
| 157 | 144 | ): Promise<CatalogItem> { |
| 158 | 145 | const p = this.itemPath(ref) |
| 159 | 146 | const entry = index.items[itemKey(ref)] |
| 147 | + const { hash, newestMtimeMs } = await pathFingerprint(p) | |
| 160 | 148 | const item: CatalogItem = { |
| 161 | 149 | type: ref.type, |
| 162 | 150 | name: ref.name, |
| 163 | 151 | tags: entry?.tags ?? [], |
| 164 | 152 | path: p, |
| 165 | - hash: (await hashPath(p)) ?? '', | |
| 166 | - updatedAt: await this.newestMtime(p), | |
| 153 | + hash: hash ?? '', | |
| 154 | + updatedAt: new Date(newestMtimeMs || Date.now()).toISOString(), | |
| 167 | 155 | } |
| 168 | 156 | if (description !== undefined) item.description = description |
| 169 | 157 | if (entry?.origin) item.origin = entry.origin |
| @@ -197,17 +185,25 @@ class Catalog implements CatalogStore { | ||
| 197 | 185 | names = [] |
| 198 | 186 | } |
| 199 | 187 | } |
| 200 | - for (const name of names) { | |
| 201 | - if (name.startsWith('.')) continue // staging folders, dotfiles | |
| 202 | - const ref = { type, name } | |
| 203 | - try { | |
| 204 | - assertValidName(name, `${type} name`) | |
| 205 | - const content = await readText(this.mainFile(ref)) | |
| 206 | - if (content === undefined) throw new Error(`Missing ${type === 'skill' ? 'SKILL.md' : 'file'}`) | |
| 207 | - snap.items.push(await this.buildItem(ref, describeContent(type, content, name), index)) | |
| 208 | - } catch (e) { | |
| 209 | - fail(path.join(dir, name), e) | |
| 210 | - } | |
| 188 | + // Items are read in parallel (hashing every file one by one is slow on Windows); errors keep the folder order. | |
| 189 | + const results = await Promise.all( | |
| 190 | + names | |
| 191 | + .filter((name) => !name.startsWith('.')) // staging folders, dotfiles | |
| 192 | + .map(async (name): Promise<{ item: CatalogItem } | { name: string; error: unknown }> => { | |
| 193 | + const ref = { type, name } | |
| 194 | + try { | |
| 195 | + assertValidName(name, `${type} name`) | |
| 196 | + const content = await readText(this.mainFile(ref)) | |
| 197 | + if (content === undefined) throw new Error(`Missing ${type === 'skill' ? 'SKILL.md' : 'file'}`) | |
| 198 | + return { item: await this.buildItem(ref, describeContent(type, content, name), index) } | |
| 199 | + } catch (error) { | |
| 200 | + return { name, error } | |
| 201 | + } | |
| 202 | + }), | |
| 203 | + ) | |
| 204 | + for (const r of results) { | |
| 205 | + if ('item' in r) snap.items.push(r.item) | |
| 206 | + else fail(path.join(dir, r.name), r.error) | |
| 211 | 207 | } |
| 212 | 208 | } |
| 213 | 209 | let presetFiles: string[] = [] |
| @@ -260,15 +256,18 @@ class Catalog implements CatalogStore { | ||
| 260 | 256 | return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : [] |
| 261 | 257 | } |
| 262 | 258 | |
| 263 | - async getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> { | |
| 259 | + async getItem(ref: CatalogRef, known?: CatalogItem): Promise<CatalogItemDetail | undefined> { | |
| 264 | 260 | const main = this.mainFile(ref) |
| 265 | 261 | const content = await readText(main) |
| 266 | 262 | if (content === undefined) return undefined |
| 263 | + const reuse = known && known.type === ref.type && known.name === ref.name ? known : undefined | |
| 267 | 264 | let index: CatalogIndex = { version: 1, items: {} } |
| 268 | - try { | |
| 269 | - index = await this.readIndex() | |
| 270 | - } catch { | |
| 271 | - // tags unavailable | |
| 265 | + if (!reuse) { | |
| 266 | + try { | |
| 267 | + index = await this.readIndex() | |
| 268 | + } catch { | |
| 269 | + // tags unavailable | |
| 270 | + } | |
| 272 | 271 | } |
| 273 | 272 | let description: string | undefined |
| 274 | 273 | let template: McpTemplate | HookTemplate | undefined |
| @@ -279,15 +278,18 @@ class Catalog implements CatalogStore { | ||
| 279 | 278 | } catch { |
| 280 | 279 | // invalid content: still return it so it can be fixed |
| 281 | 280 | } |
| 282 | - const files: CatalogFile[] = [] | |
| 283 | - for (const f of await this.itemFiles(ref)) { | |
| 284 | - try { | |
| 285 | - files.push({ path: f.rel, sizeBytes: (await stat(f.abs)).size }) | |
| 286 | - } catch { | |
| 287 | - // vanished | |
| 288 | - } | |
| 289 | - } | |
| 290 | - const detail: CatalogItemDetail = { item: await this.buildItem(ref, description, index), content, files } | |
| 281 | + const sizes = await Promise.all( | |
| 282 | + (await this.itemFiles(ref)).map(async (f): Promise<CatalogFile | undefined> => { | |
| 283 | + try { | |
| 284 | + return { path: f.rel, sizeBytes: (await stat(f.abs)).size } | |
| 285 | + } catch { | |
| 286 | + return undefined // vanished | |
| 287 | + } | |
| 288 | + }), | |
| 289 | + ) | |
| 290 | + const files = sizes.filter((f): f is CatalogFile => f !== undefined) | |
| 291 | + const item = reuse ?? (await this.buildItem(ref, description, index)) | |
| 292 | + const detail: CatalogItemDetail = { item, content, files } | |
| 291 | 293 | if (template) detail.template = template |
| 292 | 294 | return detail |
| 293 | 295 | } |
| @@ -18,7 +18,7 @@ import { | |||
| 18 | } from '../catalog-model.ts' | 18 | } from '../catalog-model.ts' |
| 19 | import type { CatalogStore, OpenCatalog } from '../contracts.ts' | 19 | import type { CatalogStore, OpenCatalog } from '../contracts.ts' |
| 20 | import type { HookDetails, InventoryItem, McpDetails } from '../model.ts' | 20 | import type { HookDetails, InventoryItem, McpDetails } from '../model.ts' |
| 21 | -import { hashPath, isDirectory, listDirs, listFilesRecursive, parseFrontmatter, pathExists, readText } from '../util.ts' | 21 | +import { hashPath, isDirectory, listDirs, listFilesRecursive, parseFrontmatter, pathExists, pathFingerprint, readText } from '../util.ts' |
| 22 | import { atomicWrite, copyDir, movePath, tmpSuffix } from './fsutil.ts' | 22 | import { atomicWrite, copyDir, movePath, tmpSuffix } from './fsutil.ts' |
| 23 | import { assertValidName, deriveName, isInside, normalizeTags, resolveInside } from './names.ts' | 23 | import { assertValidName, deriveName, isInside, normalizeTags, resolveInside } from './names.ts' |
| 24 | import { normalizeSkillMd, parseHookYaml, parseMcpYaml, parseYamlDoc } from './templates.ts' | 24 | import { normalizeSkillMd, parseHookYaml, parseMcpYaml, parseYamlDoc } from './templates.ts' |
| @@ -137,19 +137,6 @@ class Catalog implements CatalogStore { | |||
| 137 | 137 | ||
| 138 | // ---- reading ---- | 138 | // ---- reading ---- |
| 139 | 139 | ||
| 140 | - private async newestMtime(p: string): Promise<string> { | ||
| 141 | - let newest = 0 | ||
| 142 | - const files = (await isDirectory(p)) ? (await listFilesRecursive(p, 2000)).map((f) => path.join(p, f)) : [p] | ||
| 143 | - for (const f of files) { | ||
| 144 | - try { | ||
| 145 | - newest = Math.max(newest, (await stat(f)).mtimeMs) | ||
| 146 | - } catch { | ||
| 147 | - // ignore | ||
| 148 | - } | ||
| 149 | - } | ||
| 150 | - return new Date(newest || Date.now()).toISOString() | ||
| 151 | - } | ||
| 152 | - | ||
| 153 | private async buildItem( | 140 | private async buildItem( |
| 154 | ref: CatalogRef, | 141 | ref: CatalogRef, |
| 155 | description: string | undefined, | 142 | description: string | undefined, |
| @@ -157,13 +144,14 @@ class Catalog implements CatalogStore { | |||
| 157 | ): Promise<CatalogItem> { | 144 | ): Promise<CatalogItem> { |
| 158 | const p = this.itemPath(ref) | 145 | const p = this.itemPath(ref) |
| 159 | const entry = index.items[itemKey(ref)] | 146 | const entry = index.items[itemKey(ref)] |
| 147 | + const { hash, newestMtimeMs } = await pathFingerprint(p) | ||
| 160 | const item: CatalogItem = { | 148 | const item: CatalogItem = { |
| 161 | type: ref.type, | 149 | type: ref.type, |
| 162 | name: ref.name, | 150 | name: ref.name, |
| 163 | tags: entry?.tags ?? [], | 151 | tags: entry?.tags ?? [], |
| 164 | path: p, | 152 | path: p, |
| 165 | - hash: (await hashPath(p)) ?? '', | 153 | + hash: hash ?? '', |
| 166 | - updatedAt: await this.newestMtime(p), | 154 | + updatedAt: new Date(newestMtimeMs || Date.now()).toISOString(), |
| 167 | } | 155 | } |
| 168 | if (description !== undefined) item.description = description | 156 | if (description !== undefined) item.description = description |
| 169 | if (entry?.origin) item.origin = entry.origin | 157 | if (entry?.origin) item.origin = entry.origin |
| @@ -197,17 +185,25 @@ class Catalog implements CatalogStore { | |||
| 197 | names = [] | 185 | names = [] |
| 198 | } | 186 | } |
| 199 | } | 187 | } |
| 200 | - for (const name of names) { | 188 | + // Items are read in parallel (hashing every file one by one is slow on Windows); errors keep the folder order. |
| 201 | - if (name.startsWith('.')) continue // staging folders, dotfiles | 189 | + const results = await Promise.all( |
| 202 | - const ref = { type, name } | 190 | + names |
| 203 | - try { | 191 | + .filter((name) => !name.startsWith('.')) // staging folders, dotfiles |
| 204 | - assertValidName(name, `${type} name`) | 192 | + .map(async (name): Promise<{ item: CatalogItem } | { name: string; error: unknown }> => { |
| 205 | - const content = await readText(this.mainFile(ref)) | 193 | + const ref = { type, name } |
| 206 | - if (content === undefined) throw new Error(`Missing ${type === 'skill' ? 'SKILL.md' : 'file'}`) | 194 | + try { |
| 207 | - snap.items.push(await this.buildItem(ref, describeContent(type, content, name), index)) | 195 | + assertValidName(name, `${type} name`) |
| 208 | - } catch (e) { | 196 | + const content = await readText(this.mainFile(ref)) |
| 209 | - fail(path.join(dir, name), e) | 197 | + if (content === undefined) throw new Error(`Missing ${type === 'skill' ? 'SKILL.md' : 'file'}`) |
| 210 | - } | 198 | + return { item: await this.buildItem(ref, describeContent(type, content, name), index) } |
| 199 | + } catch (error) { | ||
| 200 | + return { name, error } | ||
| 201 | + } | ||
| 202 | + }), | ||
| 203 | + ) | ||
| 204 | + for (const r of results) { | ||
| 205 | + if ('item' in r) snap.items.push(r.item) | ||
| 206 | + else fail(path.join(dir, r.name), r.error) | ||
| 211 | } | 207 | } |
| 212 | } | 208 | } |
| 213 | let presetFiles: string[] = [] | 209 | let presetFiles: string[] = [] |
| @@ -260,15 +256,18 @@ class Catalog implements CatalogStore { | |||
| 260 | return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : [] | 256 | return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : [] |
| 261 | } | 257 | } |
| 262 | 258 | ||
| 263 | - async getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> { | 259 | + async getItem(ref: CatalogRef, known?: CatalogItem): Promise<CatalogItemDetail | undefined> { |
| 264 | const main = this.mainFile(ref) | 260 | const main = this.mainFile(ref) |
| 265 | const content = await readText(main) | 261 | const content = await readText(main) |
| 266 | if (content === undefined) return undefined | 262 | if (content === undefined) return undefined |
| 263 | + const reuse = known && known.type === ref.type && known.name === ref.name ? known : undefined | ||
| 267 | let index: CatalogIndex = { version: 1, items: {} } | 264 | let index: CatalogIndex = { version: 1, items: {} } |
| 268 | - try { | 265 | + if (!reuse) { |
| 269 | - index = await this.readIndex() | 266 | + try { |
| 270 | - } catch { | 267 | + index = await this.readIndex() |
| 271 | - // tags unavailable | 268 | + } catch { |
| 269 | + // tags unavailable | ||
| 270 | + } | ||
| 272 | } | 271 | } |
| 273 | let description: string | undefined | 272 | let description: string | undefined |
| 274 | let template: McpTemplate | HookTemplate | undefined | 273 | let template: McpTemplate | HookTemplate | undefined |
| @@ -279,15 +278,18 @@ class Catalog implements CatalogStore { | |||
| 279 | } catch { | 278 | } catch { |
| 280 | // invalid content: still return it so it can be fixed | 279 | // invalid content: still return it so it can be fixed |
| 281 | } | 280 | } |
| 282 | - const files: CatalogFile[] = [] | 281 | + const sizes = await Promise.all( |
| 283 | - for (const f of await this.itemFiles(ref)) { | 282 | + (await this.itemFiles(ref)).map(async (f): Promise<CatalogFile | undefined> => { |
| 284 | - try { | 283 | + try { |
| 285 | - files.push({ path: f.rel, sizeBytes: (await stat(f.abs)).size }) | 284 | + return { path: f.rel, sizeBytes: (await stat(f.abs)).size } |
| 286 | - } catch { | 285 | + } catch { |
| 287 | - // vanished | 286 | + return undefined // vanished |
| 288 | - } | 287 | + } |
| 289 | - } | 288 | + }), |
| 290 | - const detail: CatalogItemDetail = { item: await this.buildItem(ref, description, index), content, files } | 289 | + ) |
| 290 | + const files = sizes.filter((f): f is CatalogFile => f !== undefined) | ||
| 291 | + const item = reuse ?? (await this.buildItem(ref, description, index)) | ||
| 292 | + const detail: CatalogItemDetail = { item, content, files } | ||
| 291 | if (template) detail.template = template | 293 | if (template) detail.template = template |
| 292 | return detail | 294 | return detail |
| 293 | } | 295 | } |
modified
packages/core/src/contracts.ts +2 -1 | @@ -31,7 +31,8 @@ export interface CatalogStore { | ||
| 31 | 31 | /** Creates the folder layout and `.skilly/index.json` if missing (idempotent). Does not run git. */ |
| 32 | 32 | init(): Promise<void> |
| 33 | 33 | snapshot(): Promise<CatalogSnapshot> |
| 34 | - getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> | |
| 34 | + /** `known`: the item as listed by a fresh snapshot, reused instead of hashing the item again. */ | |
| 35 | + getItem(ref: CatalogRef, known?: CatalogItem): Promise<CatalogItemDetail | undefined> | |
| 35 | 36 | /** Absolute paths of all files of an item (folder content for skills, the single file otherwise). */ |
| 36 | 37 | itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]> |
| 37 | 38 | /** Create or update (or rename with `previousName`). Validates names and mcp/hook YAML. */ |
| @@ -31,7 +31,8 @@ export interface CatalogStore { | |||
| 31 | /** Creates the folder layout and `.skilly/index.json` if missing (idempotent). Does not run git. */ | 31 | /** Creates the folder layout and `.skilly/index.json` if missing (idempotent). Does not run git. */ |
| 32 | init(): Promise<void> | 32 | init(): Promise<void> |
| 33 | snapshot(): Promise<CatalogSnapshot> | 33 | snapshot(): Promise<CatalogSnapshot> |
| 34 | - getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> | 34 | + /** `known`: the item as listed by a fresh snapshot, reused instead of hashing the item again. */ |
| 35 | + getItem(ref: CatalogRef, known?: CatalogItem): Promise<CatalogItemDetail | undefined> | ||
| 35 | /** Absolute paths of all files of an item (folder content for skills, the single file otherwise). */ | 36 | /** Absolute paths of all files of an item (folder content for skills, the single file otherwise). */ |
| 36 | itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]> | 37 | itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]> |
| 37 | /** Create or update (or rename with `previousName`). Validates names and mcp/hook YAML. */ | 38 | /** Create or update (or rename with `previousName`). Validates names and mcp/hook YAML. */ |
modified
packages/core/src/sync/annotate.ts +23 -10 | @@ -33,14 +33,17 @@ export async function annotateInventory( | ||
| 33 | 33 | const snapshot = await catalog.snapshot() |
| 34 | 34 | const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i])) |
| 35 | 35 | const disk = new DiskReader(inventory.root) |
| 36 | + const statuses = await Promise.all( | |
| 37 | + inventory.items.map(async (item): Promise<ItemStatus> => { | |
| 38 | + try { | |
| 39 | + return await statusOf(item, inventory.root, lock?.entries ?? [], catalogItems, catalog, disk) | |
| 40 | + } catch { | |
| 41 | + return { managed: false, state: 'not-in-catalog' } | |
| 42 | + } | |
| 43 | + }), | |
| 44 | + ) | |
| 36 | 45 | const out: Record<string, ItemStatus> = {} |
| 37 | - for (const item of inventory.items) { | |
| 38 | - try { | |
| 39 | - out[item.id] = await statusOf(item, inventory.root, lock?.entries ?? [], catalogItems, catalog, disk) | |
| 40 | - } catch { | |
| 41 | - out[item.id] = { managed: false, state: 'not-in-catalog' } | |
| 42 | - } | |
| 43 | - } | |
| 46 | + inventory.items.forEach((item, i) => (out[item.id] = statuses[i]!)) | |
| 44 | 47 | return out |
| 45 | 48 | } |
| 46 | 49 | |
| @@ -69,10 +72,20 @@ async function statusOf( | ||
| 69 | 72 | return { managed: false, catalogRef: ref, state: same ? 'in-sync' : 'differs-from-catalog' } |
| 70 | 73 | } |
| 71 | 74 | |
| 75 | +/** Comparable form of a path (case-insensitive on Windows), memoized: every item is matched against every lock entry. */ | |
| 76 | +const pathKeys = new Map<string, string>() | |
| 77 | +function pathKey(p: string): string { | |
| 78 | + let k = pathKeys.get(p) | |
| 79 | + if (k === undefined) { | |
| 80 | + k = normalizePath(p) | |
| 81 | + if (process.platform === 'win32') k = k.toLowerCase() | |
| 82 | + pathKeys.set(p, k) | |
| 83 | + } | |
| 84 | + return k | |
| 85 | +} | |
| 86 | + | |
| 72 | 87 | function samePath(root: string, rel: string, abs: string): boolean { |
| 73 | - const a = normalizePath(path.resolve(root, rel)) | |
| 74 | - const b = normalizePath(abs) | |
| 75 | - return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b | |
| 88 | + return pathKey(path.resolve(root, rel)) === pathKey(abs) | |
| 76 | 89 | } |
| 77 | 90 | |
| 78 | 91 | async function findLockEntry(item: InventoryItem, root: string, entries: LockEntry[], disk: DiskReader): Promise<LockEntry | undefined> { |
| @@ -33,14 +33,17 @@ export async function annotateInventory( | |||
| 33 | const snapshot = await catalog.snapshot() | 33 | const snapshot = await catalog.snapshot() |
| 34 | const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i])) | 34 | const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i])) |
| 35 | const disk = new DiskReader(inventory.root) | 35 | const disk = new DiskReader(inventory.root) |
| 36 | + const statuses = await Promise.all( | ||
| 37 | + inventory.items.map(async (item): Promise<ItemStatus> => { | ||
| 38 | + try { | ||
| 39 | + return await statusOf(item, inventory.root, lock?.entries ?? [], catalogItems, catalog, disk) | ||
| 40 | + } catch { | ||
| 41 | + return { managed: false, state: 'not-in-catalog' } | ||
| 42 | + } | ||
| 43 | + }), | ||
| 44 | + ) | ||
| 36 | const out: Record<string, ItemStatus> = {} | 45 | const out: Record<string, ItemStatus> = {} |
| 37 | - for (const item of inventory.items) { | 46 | + inventory.items.forEach((item, i) => (out[item.id] = statuses[i]!)) |
| 38 | - try { | ||
| 39 | - out[item.id] = await statusOf(item, inventory.root, lock?.entries ?? [], catalogItems, catalog, disk) | ||
| 40 | - } catch { | ||
| 41 | - out[item.id] = { managed: false, state: 'not-in-catalog' } | ||
| 42 | - } | ||
| 43 | - } | ||
| 44 | return out | 47 | return out |
| 45 | } | 48 | } |
| 46 | 49 | ||
| @@ -69,10 +72,20 @@ async function statusOf( | |||
| 69 | return { managed: false, catalogRef: ref, state: same ? 'in-sync' : 'differs-from-catalog' } | 72 | return { managed: false, catalogRef: ref, state: same ? 'in-sync' : 'differs-from-catalog' } |
| 70 | } | 73 | } |
| 71 | 74 | ||
| 75 | +/** Comparable form of a path (case-insensitive on Windows), memoized: every item is matched against every lock entry. */ | ||
| 76 | +const pathKeys = new Map<string, string>() | ||
| 77 | +function pathKey(p: string): string { | ||
| 78 | + let k = pathKeys.get(p) | ||
| 79 | + if (k === undefined) { | ||
| 80 | + k = normalizePath(p) | ||
| 81 | + if (process.platform === 'win32') k = k.toLowerCase() | ||
| 82 | + pathKeys.set(p, k) | ||
| 83 | + } | ||
| 84 | + return k | ||
| 85 | +} | ||
| 86 | + | ||
| 72 | function samePath(root: string, rel: string, abs: string): boolean { | 87 | function samePath(root: string, rel: string, abs: string): boolean { |
| 73 | - const a = normalizePath(path.resolve(root, rel)) | 88 | + return pathKey(path.resolve(root, rel)) === pathKey(abs) |
| 74 | - const b = normalizePath(abs) | ||
| 75 | - return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b | ||
| 76 | } | 89 | } |
| 77 | 90 | ||
| 78 | async function findLockEntry(item: InventoryItem, root: string, entries: LockEntry[], disk: DiskReader): Promise<LockEntry | undefined> { | 91 | async function findLockEntry(item: InventoryItem, root: string, entries: LockEntry[], disk: DiskReader): Promise<LockEntry | undefined> { |
modified
packages/core/src/sync/disk.ts +2 -4 | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' |
| 4 | 4 | import path from 'node:path' |
| 5 | -import { listFilesRecursive, readText } from '../util.ts' | |
| 5 | +import { fileSha256, listFilesRecursive, readText } from '../util.ts' | |
| 6 | 6 | import { absPath, sha256, writeFileAtomic } from './helpers.ts' |
| 7 | 7 | |
| 8 | 8 | /** Read-only, cached view of a scope. Paths are relative to the root. */ |
| @@ -22,10 +22,8 @@ export class DiskReader { | ||
| 22 | 22 | |
| 23 | 23 | /** sha256 of a regular file, undefined when missing or not a file. */ |
| 24 | 24 | async fileHash(rel: string): Promise<string | undefined> { |
| 25 | - const abs = absPath(this.root, rel) | |
| 26 | 25 | try { |
| 27 | - if (!(await stat(abs)).isFile()) return undefined | |
| 28 | - return sha256(await readFile(abs)) | |
| 26 | + return await fileSha256(absPath(this.root, rel)) | |
| 29 | 27 | } catch { |
| 30 | 28 | return undefined |
| 31 | 29 | } |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' | 3 | import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' |
| 4 | import path from 'node:path' | 4 | import path from 'node:path' |
| 5 | -import { listFilesRecursive, readText } from '../util.ts' | 5 | +import { fileSha256, listFilesRecursive, readText } from '../util.ts' |
| 6 | import { absPath, sha256, writeFileAtomic } from './helpers.ts' | 6 | import { absPath, sha256, writeFileAtomic } from './helpers.ts' |
| 7 | 7 | ||
| 8 | /** Read-only, cached view of a scope. Paths are relative to the root. */ | 8 | /** Read-only, cached view of a scope. Paths are relative to the root. */ |
| @@ -22,10 +22,8 @@ export class DiskReader { | |||
| 22 | 22 | ||
| 23 | /** sha256 of a regular file, undefined when missing or not a file. */ | 23 | /** sha256 of a regular file, undefined when missing or not a file. */ |
| 24 | async fileHash(rel: string): Promise<string | undefined> { | 24 | async fileHash(rel: string): Promise<string | undefined> { |
| 25 | - const abs = absPath(this.root, rel) | ||
| 26 | try { | 25 | try { |
| 27 | - if (!(await stat(abs)).isFile()) return undefined | 26 | + return await fileSha256(absPath(this.root, rel)) |
| 28 | - return sha256(await readFile(abs)) | ||
| 29 | } catch { | 27 | } catch { |
| 30 | return undefined | 28 | return undefined |
| 31 | } | 29 | } |
modified
packages/core/src/sync/plan.ts +33 -25 | @@ -77,18 +77,25 @@ export async function computePlan(target: SyncTarget, config: ScopeConfig, catal | ||
| 77 | 77 | } |
| 78 | 78 | |
| 79 | 79 | // Desired renders. Items that fail to render keep their lock entry untouched (no action). |
| 80 | + // Renders and disk comparisons run in parallel (sequential file I/O is slow on Windows); results keep their order. | |
| 80 | 81 | const desired: DesiredItem[] = [] |
| 81 | 82 | const failed = new Set<string>() |
| 82 | - for (const { ref, via } of resolution.refs) { | |
| 83 | - for (const agent of config.targets) { | |
| 84 | - try { | |
| 85 | - const d = await render(ref, agent, via, catalogItems.get(refKey(ref))) | |
| 86 | - if (d) desired.push(d) | |
| 87 | - } catch (err) { | |
| 88 | - failed.add(lockEntryKey({ agent, ...ref })) | |
| 89 | - warn(`Cannot render ${refKey(ref)} for ${agent}: ${errorMessage(err)}`) | |
| 90 | - } | |
| 91 | - } | |
| 83 | + const renders = await Promise.all( | |
| 84 | + resolution.refs.flatMap(({ ref, via }) => | |
| 85 | + config.targets.map(async (agent): Promise<{ d: DesiredItem | undefined } | { ref: CatalogRef; agent: AgentId; err: unknown }> => { | |
| 86 | + try { | |
| 87 | + return { d: await render(ref, agent, via, catalogItems.get(refKey(ref))) } | |
| 88 | + } catch (err) { | |
| 89 | + return { ref, agent, err } | |
| 90 | + } | |
| 91 | + }), | |
| 92 | + ), | |
| 93 | + ) | |
| 94 | + for (const r of renders) { | |
| 95 | + if ('err' in r) { | |
| 96 | + failed.add(lockEntryKey({ agent: r.agent, ...r.ref })) | |
| 97 | + warn(`Cannot render ${refKey(r.ref)} for ${r.agent}: ${errorMessage(r.err)}`) | |
| 98 | + } else if (r.d) desired.push(r.d) | |
| 92 | 99 | } |
| 93 | 100 | const enabled = new Set(resolution.refs.map((r) => refKey(r.ref))) |
| 94 | 101 | for (const h of await renderHideGlobal({ target, config, layout, enabled, warn })) desired.push({ ...h, via: [HIDE_GLOBAL_VIA] }) |
| @@ -100,25 +107,26 @@ export async function computePlan(target: SyncTarget, config: ScopeConfig, catal | ||
| 100 | 107 | const lockByKey = new Map((lock?.entries ?? []).map((e) => [lockEntryKey(e), e])) |
| 101 | 108 | const missing = new Set(resolution.missing.map(refKey)) |
| 102 | 109 | const actions: InternalAction[] = [] |
| 103 | - const desiredKeys = new Set<string>() | |
| 104 | - | |
| 105 | - for (const d of desired) { | |
| 106 | - const key = lockEntryKey(d) | |
| 107 | - desiredKeys.add(key) | |
| 110 | + const desiredKeys = new Set(desired.map(lockEntryKey)) | |
| 111 | + const classify = async ( | |
| 112 | + item: { type: string; name: string; agent: AgentId }, | |
| 113 | + run: () => Promise<InternalAction>, | |
| 114 | + ): Promise<{ action: InternalAction } | { item: { type: string; name: string; agent: AgentId }; err: unknown }> => { | |
| 108 | 115 | try { |
| 109 | - actions.push(await classifyDesired(d, lockByKey.get(key), disk)) | |
| 116 | + return { action: await run() } | |
| 110 | 117 | } catch (err) { |
| 111 | - warn(`Cannot plan ${d.type}/${d.name} for ${d.agent}: ${errorMessage(err)}`) | |
| 118 | + return { item, err } | |
| 112 | 119 | } |
| 113 | 120 | } |
| 114 | - for (const entry of lock?.entries ?? []) { | |
| 115 | - const key = lockEntryKey(entry) | |
| 116 | - if (desiredKeys.has(key) || failed.has(key)) continue | |
| 117 | - try { | |
| 118 | - actions.push(await classifyStale(entry, missing.has(refKey(entry)), disk)) | |
| 119 | - } catch (err) { | |
| 120 | - warn(`Cannot plan ${entry.type}/${entry.name} for ${entry.agent}: ${errorMessage(err)}`) | |
| 121 | - } | |
| 121 | + const classified = await Promise.all([ | |
| 122 | + ...desired.map((d) => classify(d, () => classifyDesired(d, lockByKey.get(lockEntryKey(d)), disk))), | |
| 123 | + ...(lock?.entries ?? []) | |
| 124 | + .filter((entry) => !desiredKeys.has(lockEntryKey(entry)) && !failed.has(lockEntryKey(entry))) | |
| 125 | + .map((entry) => classify(entry, () => classifyStale(entry, missing.has(refKey(entry)), disk))), | |
| 126 | + ]) | |
| 127 | + for (const r of classified) { | |
| 128 | + if ('action' in r) actions.push(r.action) | |
| 129 | + else warn(`Cannot plan ${r.item.type}/${r.item.name} for ${r.item.agent}: ${errorMessage(r.err)}`) | |
| 122 | 130 | } |
| 123 | 131 | |
| 124 | 132 | actions.sort((a, b) => compareRefs(a.action, b.action) || a.action.agent.localeCompare(b.action.agent)) |
| @@ -77,18 +77,25 @@ export async function computePlan(target: SyncTarget, config: ScopeConfig, catal | |||
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | // Desired renders. Items that fail to render keep their lock entry untouched (no action). | 79 | // Desired renders. Items that fail to render keep their lock entry untouched (no action). |
| 80 | + // Renders and disk comparisons run in parallel (sequential file I/O is slow on Windows); results keep their order. | ||
| 80 | const desired: DesiredItem[] = [] | 81 | const desired: DesiredItem[] = [] |
| 81 | const failed = new Set<string>() | 82 | const failed = new Set<string>() |
| 82 | - for (const { ref, via } of resolution.refs) { | 83 | + const renders = await Promise.all( |
| 83 | - for (const agent of config.targets) { | 84 | + resolution.refs.flatMap(({ ref, via }) => |
| 84 | - try { | 85 | + config.targets.map(async (agent): Promise<{ d: DesiredItem | undefined } | { ref: CatalogRef; agent: AgentId; err: unknown }> => { |
| 85 | - const d = await render(ref, agent, via, catalogItems.get(refKey(ref))) | 86 | + try { |
| 86 | - if (d) desired.push(d) | 87 | + return { d: await render(ref, agent, via, catalogItems.get(refKey(ref))) } |
| 87 | - } catch (err) { | 88 | + } catch (err) { |
| 88 | - failed.add(lockEntryKey({ agent, ...ref })) | 89 | + return { ref, agent, err } |
| 89 | - warn(`Cannot render ${refKey(ref)} for ${agent}: ${errorMessage(err)}`) | 90 | + } |
| 90 | - } | 91 | + }), |
| 91 | - } | 92 | + ), |
| 93 | + ) | ||
| 94 | + for (const r of renders) { | ||
| 95 | + if ('err' in r) { | ||
| 96 | + failed.add(lockEntryKey({ agent: r.agent, ...r.ref })) | ||
| 97 | + warn(`Cannot render ${refKey(r.ref)} for ${r.agent}: ${errorMessage(r.err)}`) | ||
| 98 | + } else if (r.d) desired.push(r.d) | ||
| 92 | } | 99 | } |
| 93 | const enabled = new Set(resolution.refs.map((r) => refKey(r.ref))) | 100 | const enabled = new Set(resolution.refs.map((r) => refKey(r.ref))) |
| 94 | for (const h of await renderHideGlobal({ target, config, layout, enabled, warn })) desired.push({ ...h, via: [HIDE_GLOBAL_VIA] }) | 101 | for (const h of await renderHideGlobal({ target, config, layout, enabled, warn })) desired.push({ ...h, via: [HIDE_GLOBAL_VIA] }) |
| @@ -100,25 +107,26 @@ export async function computePlan(target: SyncTarget, config: ScopeConfig, catal | |||
| 100 | const lockByKey = new Map((lock?.entries ?? []).map((e) => [lockEntryKey(e), e])) | 107 | const lockByKey = new Map((lock?.entries ?? []).map((e) => [lockEntryKey(e), e])) |
| 101 | const missing = new Set(resolution.missing.map(refKey)) | 108 | const missing = new Set(resolution.missing.map(refKey)) |
| 102 | const actions: InternalAction[] = [] | 109 | const actions: InternalAction[] = [] |
| 103 | - const desiredKeys = new Set<string>() | 110 | + const desiredKeys = new Set(desired.map(lockEntryKey)) |
| 104 | - | 111 | + const classify = async ( |
| 105 | - for (const d of desired) { | 112 | + item: { type: string; name: string; agent: AgentId }, |
| 106 | - const key = lockEntryKey(d) | 113 | + run: () => Promise<InternalAction>, |
| 107 | - desiredKeys.add(key) | 114 | + ): Promise<{ action: InternalAction } | { item: { type: string; name: string; agent: AgentId }; err: unknown }> => { |
| 108 | try { | 115 | try { |
| 109 | - actions.push(await classifyDesired(d, lockByKey.get(key), disk)) | 116 | + return { action: await run() } |
| 110 | } catch (err) { | 117 | } catch (err) { |
| 111 | - warn(`Cannot plan ${d.type}/${d.name} for ${d.agent}: ${errorMessage(err)}`) | 118 | + return { item, err } |
| 112 | } | 119 | } |
| 113 | } | 120 | } |
| 114 | - for (const entry of lock?.entries ?? []) { | 121 | + const classified = await Promise.all([ |
| 115 | - const key = lockEntryKey(entry) | 122 | + ...desired.map((d) => classify(d, () => classifyDesired(d, lockByKey.get(lockEntryKey(d)), disk))), |
| 116 | - if (desiredKeys.has(key) || failed.has(key)) continue | 123 | + ...(lock?.entries ?? []) |
| 117 | - try { | 124 | + .filter((entry) => !desiredKeys.has(lockEntryKey(entry)) && !failed.has(lockEntryKey(entry))) |
| 118 | - actions.push(await classifyStale(entry, missing.has(refKey(entry)), disk)) | 125 | + .map((entry) => classify(entry, () => classifyStale(entry, missing.has(refKey(entry)), disk))), |
| 119 | - } catch (err) { | 126 | + ]) |
| 120 | - warn(`Cannot plan ${entry.type}/${entry.name} for ${entry.agent}: ${errorMessage(err)}`) | 127 | + for (const r of classified) { |
| 121 | - } | 128 | + if ('action' in r) actions.push(r.action) |
| 129 | + else warn(`Cannot plan ${r.item.type}/${r.item.name} for ${r.item.agent}: ${errorMessage(r.err)}`) | ||
| 122 | } | 130 | } |
| 123 | 131 | ||
| 124 | actions.sort((a, b) => compareRefs(a.action, b.action) || a.action.agent.localeCompare(b.action.agent)) | 132 | actions.sort((a, b) => compareRefs(a.action, b.action) || a.action.agent.localeCompare(b.action.agent)) |
modified
packages/core/src/sync/render/common.ts +3 -3 | @@ -5,14 +5,14 @@ import { stripBom } from '../../util.ts' | ||
| 5 | 5 | import { isRecord, joinRel, sha256, type Json } from '../helpers.ts' |
| 6 | 6 | import type { RenderedFile, SourceItem } from './types.ts' |
| 7 | 7 | |
| 8 | -export function renderedFile(path: string, content: Buffer | string): RenderedFile { | |
| 8 | +export function renderedFile(path: string, content: Buffer | string, knownSha256?: string): RenderedFile { | |
| 9 | 9 | const buf = typeof content === 'string' ? Buffer.from(content, 'utf8') : content |
| 10 | - return { path, content: buf, sha256: sha256(buf) } | |
| 10 | + return { path, content: buf, sha256: knownSha256 ?? sha256(buf) } | |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | 13 | /** Copies every file of a skill folder under `dir`. */ |
| 14 | 14 | export function copySkillFiles(source: SourceItem, dir: string): RenderedFile[] { |
| 15 | - return source.files.map((f) => renderedFile(joinRel(dir, f.rel), f.content)) | |
| 15 | + return source.files.map((f) => renderedFile(joinRel(dir, f.rel), f.content, f.sha256)) | |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | /** Main file of a single-file item (rule, agent): exact bytes when available. */ |
| @@ -5,14 +5,14 @@ import { stripBom } from '../../util.ts' | |||
| 5 | import { isRecord, joinRel, sha256, type Json } from '../helpers.ts' | 5 | import { isRecord, joinRel, sha256, type Json } from '../helpers.ts' |
| 6 | import type { RenderedFile, SourceItem } from './types.ts' | 6 | import type { RenderedFile, SourceItem } from './types.ts' |
| 7 | 7 | ||
| 8 | -export function renderedFile(path: string, content: Buffer | string): RenderedFile { | 8 | +export function renderedFile(path: string, content: Buffer | string, knownSha256?: string): RenderedFile { |
| 9 | const buf = typeof content === 'string' ? Buffer.from(content, 'utf8') : content | 9 | const buf = typeof content === 'string' ? Buffer.from(content, 'utf8') : content |
| 10 | - return { path, content: buf, sha256: sha256(buf) } | 10 | + return { path, content: buf, sha256: knownSha256 ?? sha256(buf) } |
| 11 | } | 11 | } |
| 12 | 12 | ||
| 13 | /** Copies every file of a skill folder under `dir`. */ | 13 | /** Copies every file of a skill folder under `dir`. */ |
| 14 | export function copySkillFiles(source: SourceItem, dir: string): RenderedFile[] { | 14 | export function copySkillFiles(source: SourceItem, dir: string): RenderedFile[] { |
| 15 | - return source.files.map((f) => renderedFile(joinRel(dir, f.rel), f.content)) | 15 | + return source.files.map((f) => renderedFile(joinRel(dir, f.rel), f.content, f.sha256)) |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | /** Main file of a single-file item (rule, agent): exact bytes when available. */ | 18 | /** Main file of a single-file item (rule, agent): exact bytes when available. */ |
modified
packages/core/src/sync/render/index.ts +6 -2 | @@ -1,16 +1,20 @@ | ||
| 1 | 1 | import { readFile } from 'node:fs/promises' |
| 2 | 2 | import type { CatalogItem, CatalogRef } from '../../catalog-model.ts' |
| 3 | 3 | import type { CatalogStore } from '../../contracts.ts' |
| 4 | +import { fileSha256 } from '../../util.ts' | |
| 4 | 5 | import type { AgentId } from '../../model.ts' |
| 5 | 6 | import { renderClaude } from './claude.ts' |
| 6 | 7 | import { renderCodex } from './codex.ts' |
| 7 | 8 | import type { RenderContext, RenderedItem, SourceItem } from './types.ts' |
| 8 | 9 | |
| 9 | 10 | export async function loadSource(catalog: CatalogStore, ref: CatalogRef, item?: CatalogItem): Promise<SourceItem> { |
| 10 | - const detail = await catalog.getItem(ref) | |
| 11 | + const detail = await catalog.getItem(ref, item) | |
| 11 | 12 | if (!detail) throw new Error(`${ref.type}/${ref.name} not found in the catalog`) |
| 12 | 13 | const files = await Promise.all( |
| 13 | - (await catalog.itemFiles(ref)).map(async (f) => ({ rel: f.rel.replace(/\\/g, '/'), content: await readFile(f.abs) })), | |
| 14 | + (await catalog.itemFiles(ref)).map(async (f) => { | |
| 15 | + const [content, sha256] = await Promise.all([readFile(f.abs), fileSha256(f.abs)]) | |
| 16 | + return { rel: f.rel.replace(/\\/g, '/'), content, sha256 } | |
| 17 | + }), | |
| 14 | 18 | ) |
| 15 | 19 | return { item: item ?? detail.item, detail, files } |
| 16 | 20 | } |
| @@ -1,16 +1,20 @@ | |||
| 1 | import { readFile } from 'node:fs/promises' | 1 | import { readFile } from 'node:fs/promises' |
| 2 | import type { CatalogItem, CatalogRef } from '../../catalog-model.ts' | 2 | import type { CatalogItem, CatalogRef } from '../../catalog-model.ts' |
| 3 | import type { CatalogStore } from '../../contracts.ts' | 3 | import type { CatalogStore } from '../../contracts.ts' |
| 4 | +import { fileSha256 } from '../../util.ts' | ||
| 4 | import type { AgentId } from '../../model.ts' | 5 | import type { AgentId } from '../../model.ts' |
| 5 | import { renderClaude } from './claude.ts' | 6 | import { renderClaude } from './claude.ts' |
| 6 | import { renderCodex } from './codex.ts' | 7 | import { renderCodex } from './codex.ts' |
| 7 | import type { RenderContext, RenderedItem, SourceItem } from './types.ts' | 8 | import type { RenderContext, RenderedItem, SourceItem } from './types.ts' |
| 8 | 9 | ||
| 9 | export async function loadSource(catalog: CatalogStore, ref: CatalogRef, item?: CatalogItem): Promise<SourceItem> { | 10 | export async function loadSource(catalog: CatalogStore, ref: CatalogRef, item?: CatalogItem): Promise<SourceItem> { |
| 10 | - const detail = await catalog.getItem(ref) | 11 | + const detail = await catalog.getItem(ref, item) |
| 11 | if (!detail) throw new Error(`${ref.type}/${ref.name} not found in the catalog`) | 12 | if (!detail) throw new Error(`${ref.type}/${ref.name} not found in the catalog`) |
| 12 | const files = await Promise.all( | 13 | const files = await Promise.all( |
| 13 | - (await catalog.itemFiles(ref)).map(async (f) => ({ rel: f.rel.replace(/\\/g, '/'), content: await readFile(f.abs) })), | 14 | + (await catalog.itemFiles(ref)).map(async (f) => { |
| 15 | + const [content, sha256] = await Promise.all([readFile(f.abs), fileSha256(f.abs)]) | ||
| 16 | + return { rel: f.rel.replace(/\\/g, '/'), content, sha256 } | ||
| 17 | + }), | ||
| 14 | ) | 18 | ) |
| 15 | return { item: item ?? detail.item, detail, files } | 19 | return { item: item ?? detail.item, detail, files } |
| 16 | } | 20 | } |
modified
packages/core/src/sync/render/types.ts +2 -1 | @@ -28,7 +28,8 @@ export interface SourceItem { | ||
| 28 | 28 | item: CatalogItem |
| 29 | 29 | detail: CatalogItemDetail |
| 30 | 30 | /** All files of the item, relative to the item folder (skills) or the file name. */ |
| 31 | - files: { rel: string; content: Buffer }[] | |
| 31 | + /** `sha256`: hash of the content when known (cached hash of the catalog file). */ | |
| 32 | + files: { rel: string; content: Buffer; sha256?: string }[] | |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | export interface RenderContext { |
| @@ -28,7 +28,8 @@ export interface SourceItem { | |||
| 28 | item: CatalogItem | 28 | item: CatalogItem |
| 29 | detail: CatalogItemDetail | 29 | detail: CatalogItemDetail |
| 30 | /** All files of the item, relative to the item folder (skills) or the file name. */ | 30 | /** All files of the item, relative to the item folder (skills) or the file name. */ |
| 31 | - files: { rel: string; content: Buffer }[] | 31 | + /** `sha256`: hash of the content when known (cached hash of the catalog file). */ |
| 32 | + files: { rel: string; content: Buffer; sha256?: string }[] | ||
| 32 | } | 33 | } |
| 33 | 34 | ||
| 34 | export interface RenderContext { | 35 | export interface RenderContext { |
modified
packages/core/src/util.ts +43 -12 | @@ -112,23 +112,54 @@ export async function listFilesRecursive(dir: string, limit = 200): Promise<stri | ||
| 112 | 112 | return out |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | -/** sha256 of a file, or of all files of a folder (relative path + content, sorted). */ | |
| 115 | +/* | |
| 116 | + * Hashes are cached for the life of the process, keyed by size and mtime like git's index: skills can weigh tens | |
| 117 | + * of MB and every page hashes the catalog and the scopes, so unchanged files are only stat-ed, never read again. | |
| 118 | + */ | |
| 119 | +const fileHashes = new Map<string, { size: number; mtimeMs: number; hash: string }>() | |
| 120 | +const dirHashes = new Map<string, { signature: string; hash: string }>() | |
| 121 | + | |
| 122 | +/** sha256 of a regular file's content (cached). Throws when missing or not a file. */ | |
| 123 | +export async function fileSha256(p: string): Promise<string> { | |
| 124 | + const st = await stat(p) | |
| 125 | + if (!st.isFile()) throw new Error(`Not a file: ${p}`) | |
| 126 | + const cached = fileHashes.get(p) | |
| 127 | + if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) return cached.hash | |
| 128 | + const hash = createHash('sha256').update(await readFile(p)).digest('hex') | |
| 129 | + fileHashes.set(p, { size: st.size, mtimeMs: st.mtimeMs, hash }) | |
| 130 | + return hash | |
| 131 | +} | |
| 132 | + | |
| 133 | +/** sha256 of a file, or of all files of a folder (relative path + content, sorted). Cached, see `fileSha256`. */ | |
| 116 | 134 | export async function hashPath(p: string): Promise<string | undefined> { |
| 135 | + return (await pathFingerprint(p)).hash | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** `hashPath` plus the newest mtime of the file or folder content (0 when unknown), in a single walk. */ | |
| 139 | +export async function pathFingerprint(p: string): Promise<{ hash: string | undefined; newestMtimeMs: number }> { | |
| 117 | 140 | try { |
| 141 | + if (!(await isDirectory(p))) { | |
| 142 | + const st = await stat(p) | |
| 143 | + return { hash: await fileSha256(p), newestMtimeMs: st.mtimeMs } | |
| 144 | + } | |
| 145 | + const rels = await listFilesRecursive(p, 10_000) | |
| 146 | + const stats = await Promise.all(rels.map((rel) => stat(path.join(p, rel)))) | |
| 147 | + const newestMtimeMs = stats.reduce((m, st) => Math.max(m, st.mtimeMs), 0) | |
| 148 | + const signature = rels.map((rel, i) => `${rel}\0${stats[i]!.size}\0${stats[i]!.mtimeMs}`).join('\n') | |
| 149 | + const cached = dirHashes.get(p) | |
| 150 | + if (cached?.signature === signature) return { hash: cached.hash, newestMtimeMs } | |
| 118 | 151 | const h = createHash('sha256') |
| 119 | - if (await isDirectory(p)) { | |
| 120 | - for (const rel of await listFilesRecursive(p, 10_000)) { | |
| 121 | - h.update(rel) | |
| 122 | - h.update('\0') | |
| 123 | - h.update(await readFile(path.join(p, rel))) | |
| 124 | - h.update('\0') | |
| 125 | - } | |
| 126 | - } else { | |
| 127 | - h.update(await readFile(p)) | |
| 152 | + for (const rel of rels) { | |
| 153 | + h.update(rel) | |
| 154 | + h.update('\0') | |
| 155 | + h.update(await readFile(path.join(p, rel))) | |
| 156 | + h.update('\0') | |
| 128 | 157 | } |
| 129 | - return h.digest('hex') | |
| 158 | + const hash = h.digest('hex') | |
| 159 | + dirHashes.set(p, { signature, hash }) | |
| 160 | + return { hash, newestMtimeMs } | |
| 130 | 161 | } catch { |
| 131 | - return undefined | |
| 162 | + return { hash: undefined, newestMtimeMs: 0 } | |
| 132 | 163 | } |
| 133 | 164 | } |
| 134 | 165 | |
| @@ -112,23 +112,54 @@ export async function listFilesRecursive(dir: string, limit = 200): Promise<stri | |||
| 112 | return out | 112 | return out |
| 113 | } | 113 | } |
| 114 | 114 | ||
| 115 | -/** sha256 of a file, or of all files of a folder (relative path + content, sorted). */ | 115 | +/* |
| 116 | + * Hashes are cached for the life of the process, keyed by size and mtime like git's index: skills can weigh tens | ||
| 117 | + * of MB and every page hashes the catalog and the scopes, so unchanged files are only stat-ed, never read again. | ||
| 118 | + */ | ||
| 119 | +const fileHashes = new Map<string, { size: number; mtimeMs: number; hash: string }>() | ||
| 120 | +const dirHashes = new Map<string, { signature: string; hash: string }>() | ||
| 121 | + | ||
| 122 | +/** sha256 of a regular file's content (cached). Throws when missing or not a file. */ | ||
| 123 | +export async function fileSha256(p: string): Promise<string> { | ||
| 124 | + const st = await stat(p) | ||
| 125 | + if (!st.isFile()) throw new Error(`Not a file: ${p}`) | ||
| 126 | + const cached = fileHashes.get(p) | ||
| 127 | + if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) return cached.hash | ||
| 128 | + const hash = createHash('sha256').update(await readFile(p)).digest('hex') | ||
| 129 | + fileHashes.set(p, { size: st.size, mtimeMs: st.mtimeMs, hash }) | ||
| 130 | + return hash | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +/** sha256 of a file, or of all files of a folder (relative path + content, sorted). Cached, see `fileSha256`. */ | ||
| 116 | export async function hashPath(p: string): Promise<string | undefined> { | 134 | export async function hashPath(p: string): Promise<string | undefined> { |
| 135 | + return (await pathFingerprint(p)).hash | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +/** `hashPath` plus the newest mtime of the file or folder content (0 when unknown), in a single walk. */ | ||
| 139 | +export async function pathFingerprint(p: string): Promise<{ hash: string | undefined; newestMtimeMs: number }> { | ||
| 117 | try { | 140 | try { |
| 141 | + if (!(await isDirectory(p))) { | ||
| 142 | + const st = await stat(p) | ||
| 143 | + return { hash: await fileSha256(p), newestMtimeMs: st.mtimeMs } | ||
| 144 | + } | ||
| 145 | + const rels = await listFilesRecursive(p, 10_000) | ||
| 146 | + const stats = await Promise.all(rels.map((rel) => stat(path.join(p, rel)))) | ||
| 147 | + const newestMtimeMs = stats.reduce((m, st) => Math.max(m, st.mtimeMs), 0) | ||
| 148 | + const signature = rels.map((rel, i) => `${rel}\0${stats[i]!.size}\0${stats[i]!.mtimeMs}`).join('\n') | ||
| 149 | + const cached = dirHashes.get(p) | ||
| 150 | + if (cached?.signature === signature) return { hash: cached.hash, newestMtimeMs } | ||
| 118 | const h = createHash('sha256') | 151 | const h = createHash('sha256') |
| 119 | - if (await isDirectory(p)) { | 152 | + for (const rel of rels) { |
| 120 | - for (const rel of await listFilesRecursive(p, 10_000)) { | 153 | + h.update(rel) |
| 121 | - h.update(rel) | 154 | + h.update('\0') |
| 122 | - h.update('\0') | 155 | + h.update(await readFile(path.join(p, rel))) |
| 123 | - h.update(await readFile(path.join(p, rel))) | 156 | + h.update('\0') |
| 124 | - h.update('\0') | ||
| 125 | - } | ||
| 126 | - } else { | ||
| 127 | - h.update(await readFile(p)) | ||
| 128 | } | 157 | } |
| 129 | - return h.digest('hex') | 158 | + const hash = h.digest('hex') |
| 159 | + dirHashes.set(p, { signature, hash }) | ||
| 160 | + return { hash, newestMtimeMs } | ||
| 130 | } catch { | 161 | } catch { |
| 131 | - return undefined | 162 | + return { hash: undefined, newestMtimeMs: 0 } |
| 132 | } | 163 | } |
| 133 | } | 164 | } |
| 134 | 165 | ||
modified
packages/server/src/context.ts +31 -0 | @@ -82,6 +82,37 @@ export async function guardGitConflicts(catalog: CatalogStore, git: CatalogGit): | ||
| 82 | 82 | }) |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | +/** | |
| 86 | + * Read-only view of the catalog that computes each snapshot, item and file list once. Plan and annotate both read | |
| 87 | + * the whole catalog (hashing every file), and plan loads each item once per target agent: within one read-only | |
| 88 | + * request the catalog does not change, so the work is shared. Never use it across a write. | |
| 89 | + */ | |
| 90 | +export function memoizeCatalogReads(catalog: CatalogStore): CatalogStore { | |
| 91 | + let snapshot: ReturnType<CatalogStore['snapshot']> | undefined | |
| 92 | + const items = new Map<string, ReturnType<CatalogStore['getItem']>>() | |
| 93 | + const files = new Map<string, ReturnType<CatalogStore['itemFiles']>>() | |
| 94 | + const memo = <T>(cache: Map<string, T>, key: string, load: () => T): T => { | |
| 95 | + let v = cache.get(key) | |
| 96 | + if (v === undefined) { | |
| 97 | + v = load() | |
| 98 | + cache.set(key, v) | |
| 99 | + } | |
| 100 | + return v | |
| 101 | + } | |
| 102 | + const overrides: Partial<CatalogStore> = { | |
| 103 | + snapshot: () => (snapshot ??= catalog.snapshot()), | |
| 104 | + getItem: (ref, known) => memo(items, `${ref.type}/${ref.name}`, () => catalog.getItem(ref, known)), | |
| 105 | + itemFiles: (ref) => memo(files, `${ref.type}/${ref.name}`, () => catalog.itemFiles(ref)), | |
| 106 | + } | |
| 107 | + return new Proxy(catalog, { | |
| 108 | + get(target, prop) { | |
| 109 | + if (prop in overrides) return overrides[prop as keyof CatalogStore] | |
| 110 | + const v: unknown = Reflect.get(target, prop) | |
| 111 | + return typeof v === 'function' ? v.bind(target) : v | |
| 112 | + }, | |
| 113 | + }) | |
| 114 | +} | |
| 115 | + | |
| 85 | 116 | export async function isDirectory(p: string): Promise<boolean> { |
| 86 | 117 | try { |
| 87 | 118 | return (await stat(p)).isDirectory() |
| @@ -82,6 +82,37 @@ export async function guardGitConflicts(catalog: CatalogStore, git: CatalogGit): | |||
| 82 | }) | 82 | }) |
| 83 | } | 83 | } |
| 84 | 84 | ||
| 85 | +/** | ||
| 86 | + * Read-only view of the catalog that computes each snapshot, item and file list once. Plan and annotate both read | ||
| 87 | + * the whole catalog (hashing every file), and plan loads each item once per target agent: within one read-only | ||
| 88 | + * request the catalog does not change, so the work is shared. Never use it across a write. | ||
| 89 | + */ | ||
| 90 | +export function memoizeCatalogReads(catalog: CatalogStore): CatalogStore { | ||
| 91 | + let snapshot: ReturnType<CatalogStore['snapshot']> | undefined | ||
| 92 | + const items = new Map<string, ReturnType<CatalogStore['getItem']>>() | ||
| 93 | + const files = new Map<string, ReturnType<CatalogStore['itemFiles']>>() | ||
| 94 | + const memo = <T>(cache: Map<string, T>, key: string, load: () => T): T => { | ||
| 95 | + let v = cache.get(key) | ||
| 96 | + if (v === undefined) { | ||
| 97 | + v = load() | ||
| 98 | + cache.set(key, v) | ||
| 99 | + } | ||
| 100 | + return v | ||
| 101 | + } | ||
| 102 | + const overrides: Partial<CatalogStore> = { | ||
| 103 | + snapshot: () => (snapshot ??= catalog.snapshot()), | ||
| 104 | + getItem: (ref, known) => memo(items, `${ref.type}/${ref.name}`, () => catalog.getItem(ref, known)), | ||
| 105 | + itemFiles: (ref) => memo(files, `${ref.type}/${ref.name}`, () => catalog.itemFiles(ref)), | ||
| 106 | + } | ||
| 107 | + return new Proxy(catalog, { | ||
| 108 | + get(target, prop) { | ||
| 109 | + if (prop in overrides) return overrides[prop as keyof CatalogStore] | ||
| 110 | + const v: unknown = Reflect.get(target, prop) | ||
| 111 | + return typeof v === 'function' ? v.bind(target) : v | ||
| 112 | + }, | ||
| 113 | + }) | ||
| 114 | +} | ||
| 115 | + | ||
| 85 | export async function isDirectory(p: string): Promise<boolean> { | 116 | export async function isDirectory(p: string): Promise<boolean> { |
| 86 | try { | 117 | try { |
| 87 | return (await stat(p)).isDirectory() | 118 | return (await stat(p)).isDirectory() |
modified
packages/server/src/scope-routes.ts +18 -19 | @@ -17,7 +17,7 @@ import { | ||
| 17 | 17 | type SyncPlan, |
| 18 | 18 | } from '@skilly/core' |
| 19 | 19 | import type { Hono } from 'hono' |
| 20 | -import { HttpError, isStringArray, readJsonBody, type AppContext, type ScopeRef } from './context.ts' | |
| 20 | +import { HttpError, isStringArray, memoizeCatalogReads, readJsonBody, type AppContext, type ScopeRef } from './context.ts' | |
| 21 | 21 | |
| 22 | 22 | const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog'] |
| 23 | 23 | const HOME_TYPES = new Set(['skill', 'rule', 'agent']) |
| @@ -82,26 +82,25 @@ export function registerScopeRoutes(app: Hono, ctx: AppContext): void { | ||
| 82 | 82 | const { syncEngine: engine } = ctx |
| 83 | 83 | |
| 84 | 84 | async function buildState(scope: ScopeRef): Promise<ScopeState> { |
| 85 | - const catalog = await ctx.catalog() | |
| 86 | - const catalogExists = await catalog.exists() | |
| 87 | - const inventory = await scope.scan() | |
| 88 | - const config = (await engine.readConfig(scope.target)) ?? null | |
| 89 | - const lock = (await engine.readLock(scope.target)) ?? null | |
| 90 | - let plan: SyncPlan | null = null | |
| 91 | - if (config) { | |
| 92 | - try { | |
| 93 | - plan = await engine.plan(scope.target, config, catalog) | |
| 94 | - } catch (e) { | |
| 95 | - // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet. | |
| 96 | - if (catalogExists) throw e | |
| 97 | - } | |
| 98 | - } | |
| 99 | - let statuses: ScopeState['statuses'] = {} | |
| 100 | - try { | |
| 101 | - statuses = await engine.annotate(inventory, catalog, lock ?? undefined) | |
| 102 | - } catch (e) { | |
| 85 | + const catalog = memoizeCatalogReads(await ctx.catalog()) | |
| 86 | + const [catalogExists, config, lock] = await Promise.all([ | |
| 87 | + catalog.exists(), | |
| 88 | + engine.readConfig(scope.target).then((c) => c ?? null), | |
| 89 | + engine.readLock(scope.target).then((l) => l ?? null), | |
| 90 | + ]) | |
| 91 | + // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet. | |
| 92 | + const tolerate = <T>(fallback: T) => (e: unknown): T => { | |
| 103 | 93 | if (catalogExists) throw e |
| 94 | + return fallback | |
| 104 | 95 | } |
| 96 | + // Plan and scan + annotate are independent: they run side by side and share the catalog reads. | |
| 97 | + const [plan, [inventory, statuses]] = await Promise.all([ | |
| 98 | + config ? engine.plan(scope.target, config, catalog).catch(tolerate<SyncPlan | null>(null)) : null, | |
| 99 | + scope.scan().then(async (inv) => { | |
| 100 | + const st = await engine.annotate(inv, catalog, lock ?? undefined).catch(tolerate<ScopeState['statuses']>({})) | |
| 101 | + return [inv, st] as const | |
| 102 | + }), | |
| 103 | + ]) | |
| 105 | 104 | return { scope: scope.id, config, inventory, statuses, plan, lock } |
| 106 | 105 | } |
| 107 | 106 | |
| @@ -17,7 +17,7 @@ import { | |||
| 17 | type SyncPlan, | 17 | type SyncPlan, |
| 18 | } from '@skilly/core' | 18 | } from '@skilly/core' |
| 19 | import type { Hono } from 'hono' | 19 | import type { Hono } from 'hono' |
| 20 | -import { HttpError, isStringArray, readJsonBody, type AppContext, type ScopeRef } from './context.ts' | 20 | +import { HttpError, isStringArray, memoizeCatalogReads, readJsonBody, type AppContext, type ScopeRef } from './context.ts' |
| 21 | 21 | ||
| 22 | const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog'] | 22 | const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog'] |
| 23 | const HOME_TYPES = new Set(['skill', 'rule', 'agent']) | 23 | const HOME_TYPES = new Set(['skill', 'rule', 'agent']) |
| @@ -82,26 +82,25 @@ export function registerScopeRoutes(app: Hono, ctx: AppContext): void { | |||
| 82 | const { syncEngine: engine } = ctx | 82 | const { syncEngine: engine } = ctx |
| 83 | 83 | ||
| 84 | async function buildState(scope: ScopeRef): Promise<ScopeState> { | 84 | async function buildState(scope: ScopeRef): Promise<ScopeState> { |
| 85 | - const catalog = await ctx.catalog() | 85 | + const catalog = memoizeCatalogReads(await ctx.catalog()) |
| 86 | - const catalogExists = await catalog.exists() | 86 | + const [catalogExists, config, lock] = await Promise.all([ |
| 87 | - const inventory = await scope.scan() | 87 | + catalog.exists(), |
| 88 | - const config = (await engine.readConfig(scope.target)) ?? null | 88 | + engine.readConfig(scope.target).then((c) => c ?? null), |
| 89 | - const lock = (await engine.readLock(scope.target)) ?? null | 89 | + engine.readLock(scope.target).then((l) => l ?? null), |
| 90 | - let plan: SyncPlan | null = null | 90 | + ]) |
| 91 | - if (config) { | 91 | + // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet. |
| 92 | - try { | 92 | + const tolerate = <T>(fallback: T) => (e: unknown): T => { |
| 93 | - plan = await engine.plan(scope.target, config, catalog) | ||
| 94 | - } catch (e) { | ||
| 95 | - // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet. | ||
| 96 | - if (catalogExists) throw e | ||
| 97 | - } | ||
| 98 | - } | ||
| 99 | - let statuses: ScopeState['statuses'] = {} | ||
| 100 | - try { | ||
| 101 | - statuses = await engine.annotate(inventory, catalog, lock ?? undefined) | ||
| 102 | - } catch (e) { | ||
| 103 | if (catalogExists) throw e | 93 | if (catalogExists) throw e |
| 94 | + return fallback | ||
| 104 | } | 95 | } |
| 96 | + // Plan and scan + annotate are independent: they run side by side and share the catalog reads. | ||
| 97 | + const [plan, [inventory, statuses]] = await Promise.all([ | ||
| 98 | + config ? engine.plan(scope.target, config, catalog).catch(tolerate<SyncPlan | null>(null)) : null, | ||
| 99 | + scope.scan().then(async (inv) => { | ||
| 100 | + const st = await engine.annotate(inv, catalog, lock ?? undefined).catch(tolerate<ScopeState['statuses']>({})) | ||
| 101 | + return [inv, st] as const | ||
| 102 | + }), | ||
| 103 | + ]) | ||
| 105 | return { scope: scope.id, config, inventory, statuses, plan, lock } | 104 | return { scope: scope.id, config, inventory, statuses, plan, lock } |
| 106 | } | 105 | } |
| 107 | 106 | ||