hlassiege/panopliepublic Fork
⑂ 7fa277e
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

perf: cache file hashes and share catalog reads to speed up scope pages

hlassiege committed 2026-10-23T17:45:51+02:00 Browse files
7fa277e parent: 30ddc7a
modified packages/core/src/catalog/index.ts +43 -41
@@ -18,7 +18,7 @@ import {
1818 } from '../catalog-model.ts'
1919 import type { CatalogStore, OpenCatalog } from '../contracts.ts'
2020 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'
2222 import { atomicWrite, copyDir, movePath, tmpSuffix } from './fsutil.ts'
2323 import { assertValidName, deriveName, isInside, normalizeTags, resolveInside } from './names.ts'
2424 import { normalizeSkillMd, parseHookYaml, parseMcpYaml, parseYamlDoc } from './templates.ts'
@@ -137,19 +137,6 @@ class Catalog implements CatalogStore {
137137
138138 // ---- reading ----
139139
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-
153140 private async buildItem(
154141 ref: CatalogRef,
155142 description: string | undefined,
@@ -157,13 +144,14 @@ class Catalog implements CatalogStore {
157144 ): Promise<CatalogItem> {
158145 const p = this.itemPath(ref)
159146 const entry = index.items[itemKey(ref)]
147+ const { hash, newestMtimeMs } = await pathFingerprint(p)
160148 const item: CatalogItem = {
161149 type: ref.type,
162150 name: ref.name,
163151 tags: entry?.tags ?? [],
164152 path: p,
165- hash: (await hashPath(p)) ?? '',
166- updatedAt: await this.newestMtime(p),
153+ hash: hash ?? '',
154+ updatedAt: new Date(newestMtimeMs || Date.now()).toISOString(),
167155 }
168156 if (description !== undefined) item.description = description
169157 if (entry?.origin) item.origin = entry.origin
@@ -197,17 +185,25 @@ class Catalog implements CatalogStore {
197185 names = []
198186 }
199187 }
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)
211207 }
212208 }
213209 let presetFiles: string[] = []
@@ -260,15 +256,18 @@ class Catalog implements CatalogStore {
260256 return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : []
261257 }
262258
263- async getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> {
259+ async getItem(ref: CatalogRef, known?: CatalogItem): Promise<CatalogItemDetail | undefined> {
264260 const main = this.mainFile(ref)
265261 const content = await readText(main)
266262 if (content === undefined) return undefined
263+ const reuse = known && known.type === ref.type && known.name === ref.name ? known : undefined
267264 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+ }
272271 }
273272 let description: string | undefined
274273 let template: McpTemplate | HookTemplate | undefined
@@ -279,15 +278,18 @@ class Catalog implements CatalogStore {
279278 } catch {
280279 // invalid content: still return it so it can be fixed
281280 }
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 }
291293 if (template) detail.template = template
292294 return detail
293295 }
@@ -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 = description156 if (description !== undefined) item.description = description
169 if (entry?.origin) item.origin = entry.origin157 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, dotfiles189+ 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 undefined262 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 unavailable268+ } catch {
269+ // tags unavailable
270+ }
272 }271 }
273 let description: string | undefined272 let description: string | undefined
274 let template: McpTemplate | HookTemplate | undefined273 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 fixed279 // 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- // vanished286+ 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 = template293 if (template) detail.template = template
292 return detail294 return detail
293 }295 }
modified packages/core/src/contracts.ts +2 -1
@@ -31,7 +31,8 @@ export interface CatalogStore {
3131 /** Creates the folder layout and `.skilly/index.json` if missing (idempotent). Does not run git. */
3232 init(): Promise<void>
3333 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>
3536 /** Absolute paths of all files of an item (folder content for skills, the single file otherwise). */
3637 itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]>
3738 /** 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(
3333 const snapshot = await catalog.snapshot()
3434 const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i]))
3535 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+ )
3645 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]!))
4447 return out
4548 }
4649
@@ -69,10 +72,20 @@ async function statusOf(
6972 return { managed: false, catalogRef: ref, state: same ? 'in-sync' : 'differs-from-catalog' }
7073 }
7174
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+
7287 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)
7689 }
7790
7891 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 out47 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 @@
22
33 import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises'
44 import path from 'node:path'
5-import { listFilesRecursive, readText } from '../util.ts'
5+import { fileSha256, listFilesRecursive, readText } from '../util.ts'
66 import { absPath, sha256, writeFileAtomic } from './helpers.ts'
77
88 /** Read-only, cached view of a scope. Paths are relative to the root. */
@@ -22,10 +22,8 @@ export class DiskReader {
2222
2323 /** sha256 of a regular file, undefined when missing or not a file. */
2424 async fileHash(rel: string): Promise<string | undefined> {
25- const abs = absPath(this.root, rel)
2625 try {
27- if (!(await stat(abs)).isFile()) return undefined
28- return sha256(await readFile(abs))
26+ return await fileSha256(absPath(this.root, rel))
2927 } catch {
3028 return undefined
3129 }
@@ -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 undefined26+ return await fileSha256(absPath(this.root, rel))
28- return sha256(await readFile(abs))
29 } catch {27 } catch {
30 return undefined28 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
7777 }
7878
7979 // 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.
8081 const desired: DesiredItem[] = []
8182 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)
9299 }
93100 const enabled = new Set(resolution.refs.map((r) => refKey(r.ref)))
94101 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
100107 const lockByKey = new Map((lock?.entries ?? []).map((e) => [lockEntryKey(e), e]))
101108 const missing = new Set(resolution.missing.map(refKey))
102109 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 }> => {
108115 try {
109- actions.push(await classifyDesired(d, lockByKey.get(key), disk))
116+ return { action: await run() }
110117 } catch (err) {
111- warn(`Cannot plan ${d.type}/${d.name} for ${d.agent}: ${errorMessage(err)}`)
118+ return { item, err }
112119 }
113120 }
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)}`)
122130 }
123131
124132 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)) continue123+ ...(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'
55 import { isRecord, joinRel, sha256, type Json } from '../helpers.ts'
66 import type { RenderedFile, SourceItem } from './types.ts'
77
8-export function renderedFile(path: string, content: Buffer | string): RenderedFile {
8+export function renderedFile(path: string, content: Buffer | string, knownSha256?: string): RenderedFile {
99 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) }
1111 }
1212
1313 /** Copies every file of a skill folder under `dir`. */
1414 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))
1616 }
1717
1818 /** 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') : content9 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 @@
11 import { readFile } from 'node:fs/promises'
22 import type { CatalogItem, CatalogRef } from '../../catalog-model.ts'
33 import type { CatalogStore } from '../../contracts.ts'
4+import { fileSha256 } from '../../util.ts'
45 import type { AgentId } from '../../model.ts'
56 import { renderClaude } from './claude.ts'
67 import { renderCodex } from './codex.ts'
78 import type { RenderContext, RenderedItem, SourceItem } from './types.ts'
89
910 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)
1112 if (!detail) throw new Error(`${ref.type}/${ref.name} not found in the catalog`)
1213 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+ }),
1418 )
1519 return { item: item ?? detail.item, detail, files }
1620 }
@@ -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 {
2828 item: CatalogItem
2929 detail: CatalogItemDetail
3030 /** 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 }[]
3233 }
3334
3435 export interface RenderContext {
@@ -28,7 +28,8 @@ export interface SourceItem {
28 item: CatalogItem28 item: CatalogItem
29 detail: CatalogItemDetail29 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
112112 return out
113113 }
114114
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`. */
116134 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 }> {
117140 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 }
118151 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')
128157 }
129- return h.digest('hex')
158+ const hash = h.digest('hex')
159+ dirHashes.set(p, { signature, hash })
160+ return { hash, newestMtimeMs }
130161 } catch {
131- return undefined
162+ return { hash: undefined, newestMtimeMs: 0 }
132163 }
133164 }
134165
@@ -112,23 +112,54 @@ export async function listFilesRecursive(dir: string, limit = 200): Promise<stri
112 return out112 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 undefined162+ 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):
8282 })
8383 }
8484
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+
85116 export async function isDirectory(p: string): Promise<boolean> {
86117 try {
87118 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 {
1717 type SyncPlan,
1818 } from '@skilly/core'
1919 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'
2121
2222 const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog']
2323 const HOME_TYPES = new Set(['skill', 'rule', 'agent'])
@@ -82,26 +82,25 @@ export function registerScopeRoutes(app: Hono, ctx: AppContext): void {
8282 const { syncEngine: engine } = ctx
8383
8484 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 => {
10393 if (catalogExists) throw e
94+ return fallback
10495 }
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+ ])
105104 return { scope: scope.id, config, inventory, statuses, plan, lock }
106105 }
107106
@@ -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 } = ctx82 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)) ?? null88+ engine.readConfig(scope.target).then((c) => c ?? null),
89- const lock = (await engine.readLock(scope.target)) ?? null89+ engine.readLock(scope.target).then((l) => l ?? null),
90- let plan: SyncPlan | null = null90+ ])
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 e93 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