@frontera-sdk/cli 1.53.23 → 1.53.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +4 -4
  2. package/src/api/knowledge-base-api.ts +230 -0
  3. package/src/commands/knowledge/index-commands.ts +6 -6
  4. package/src/commands/knowledge-base/index-commands.ts +328 -0
  5. package/src/commands/local/check.ts +12 -0
  6. package/src/commands/local/dev.ts +64 -2
  7. package/src/commands/local/index-commands.ts +34 -10
  8. package/src/commands/local/init.ts +11 -2
  9. package/src/commands/local/schema.ts +1 -1
  10. package/src/commands/local/sync-request.ts +135 -29
  11. package/src/commands/local/sync.ts +40 -25
  12. package/src/commands/registry.ts +4 -1
  13. package/src/commands/types.ts +6 -0
  14. package/src/flag-help.ts +10 -4
  15. package/src/forge/touches.ts +29 -0
  16. package/src/local/local-applied.ts +28 -0
  17. package/src/local/local-build-summary.ts +1 -0
  18. package/src/local/local-component-health.ts +7 -2
  19. package/src/local/local-dev-references.ts +6 -2
  20. package/src/local/local-dev-sync.ts +43 -4
  21. package/src/local/local-docker.ts +16 -1
  22. package/src/local/local-env.ts +19 -0
  23. package/src/local/local-file-checks.ts +56 -8
  24. package/src/local/local-knowledge-base-apply.ts +714 -0
  25. package/src/local/local-knowledge-base-files.ts +195 -0
  26. package/src/local/local-knowledge-base-pull.ts +458 -0
  27. package/src/local/local-knowledge-base-sources-off.ts +67 -0
  28. package/src/local/local-person-steps.ts +2 -1
  29. package/src/local/local-personas.ts +64 -7
  30. package/src/local/local-portability.ts +27 -0
  31. package/src/local/local-ports.ts +4 -2
  32. package/src/local/local-project-secrets.ts +30 -5
  33. package/src/local/local-scaffold.ts +2 -0
  34. package/src/local/local-schema-check.ts +3 -1
  35. package/src/local/local-schemas.ts +11 -1
  36. package/src/local/local-search-index.ts +92 -0
  37. package/src/local/local-secrets.ts +7 -2
  38. package/src/local/local-seed.ts +17 -0
  39. package/src/local/local-state.ts +7 -5
  40. package/src/local/local-studio-server.ts +3 -3
  41. package/src/local/local-supervisor.ts +45 -8
  42. package/src/local/local-sync-link.ts +16 -0
  43. package/src/local/local-sync-names.ts +10 -1
  44. package/src/local/local-sync-plan.ts +44 -5
  45. package/src/local/local-sync-push.ts +165 -34
  46. package/src/local/local-sync-refs.ts +3 -1
  47. package/src/local/local-sync-remote.ts +44 -12
  48. package/src/local/local-sync-snapshot.ts +156 -12
  49. package/src/main.ts +13 -0
  50. package/src/mcp/mcp-server.ts +3 -0
  51. package/src/mcp/tools/docs-tools.ts +1 -1
  52. package/src/mcp/tools/knowledge-base-tools.ts +85 -0
@@ -10,7 +10,7 @@ import { projectBlueprintOutline } from './local-blueprint-outline'
10
10
  import { currentBuildRows, currentBuildSummary } from './local-build-inputs'
11
11
  import { appStatusRow, itemBuild } from './local-build-summary'
12
12
  import { KnowledgeDocumentError, KNOWLEDGE_UPLOAD_EXTENSIONS, MAX_KNOWLEDGE_UPLOAD_BYTES, checkUpload, documentInFolder, knowledgeFolderOf, projectDocuments, removeKnowledgeDocument, removeRuntimeCopies, runtimeDocuments, saveKnowledgeDocument, type ServiceCall } from './local-knowledge-uploads'
13
- import { isAlive, logPath, readApplied, readState, type ComponentName } from './local-state'
13
+ import { COMPONENTS, isAlive, logPath, readApplied, readState, type ComponentName } from './local-state'
14
14
  import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, redactedVariablesOfTimeline, renderTestCase, variablesOfTimeline } from './local-test-cases'
15
15
  import { findCaseFiles } from './local-test-runner'
16
16
  import { workflowTestListing } from './local-workflow-tests'
@@ -91,7 +91,7 @@ function cookieToken(req: Request): string | null {
91
91
  return null
92
92
  }
93
93
 
94
- const LOG_COMPONENTS: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
94
+ const LOG_COMPONENTS: readonly ComponentName[] = COMPONENTS
95
95
 
96
96
  function tail(path: string, lines: number): string[] {
97
97
  if (!existsSync(path)) return []
@@ -193,7 +193,7 @@ export function startStudioServer(opts: StudioServerOptions) {
193
193
  const run = testRuns.get(runMatch[1]!)
194
194
  return run ? json(run) : json({ error: 'no such test run' }, 404)
195
195
  }
196
- const logMatch = /^\/__local\/logs\/([a-z]+)$/.exec(url.pathname)
196
+ const logMatch = /^\/__local\/logs\/([a-z-]+)$/.exec(url.pathname)
197
197
  if (logMatch) {
198
198
  const component = logMatch[1] as ComponentName
199
199
  if (!LOG_COMPONENTS.includes(component)) return json({ error: 'unknown component' }, 404)
@@ -6,6 +6,7 @@ import {
6
6
  containerState,
7
7
  copyFromContainer,
8
8
  dbVolumeName,
9
+ dockerMemoryBytes,
9
10
  execInContainer,
10
11
  followLogs,
11
12
  imagePresent,
@@ -49,8 +50,10 @@ import { recordRuntime } from './local-runtime-history'
49
50
  import { recordExit } from '../telemetry/cli-telemetry'
50
51
  import { buildDocs, checkoutDocsStale, copyDocs, customerDocsBundle, IMAGE_DOCS_PATH, writeDocsBuildMarker } from './local-docs'
51
52
  import { keepSchemas, type SchemaSource } from './local-schemas'
52
- import { applyModelKeys, ensureBlueprint, ensureOrgKey, ensureProviderModels, retireLocalModel, seedLocalRuntime, signIn } from './local-seed'
53
+ import { applyModelKeys, ensureBlueprint, ensureKnowledgeSteward, ensureOrgKey, ensureProviderModels, retireLocalModel, seedLocalRuntime, signIn } from './local-seed'
54
+ import { SEARCH_INDEX_IMAGE, searchIndexChoice, searchIndexRunSpec, waitForSearchIndex } from './local-search-index'
53
55
  import {
56
+ COMPONENTS,
54
57
  logPath,
55
58
  readState,
56
59
  updateComponent,
@@ -93,7 +96,8 @@ export const containerName = (project: LocalProject, component: ComponentName) =
93
96
  export const volumeName = (project: LocalProject) => dbVolumeName(project.id)
94
97
 
95
98
  interface Running {
96
- children: Map<ComponentName, ChildProcess>
99
+ // The search index is a container, never a child process.
100
+ children: Map<Exclude<ComponentName, 'search-index'>, ChildProcess>
97
101
  containers: Set<string>
98
102
  studio?: ReturnType<typeof startStudioServer>
99
103
  embeddings?: ReturnType<typeof startLocalEmbeddings>
@@ -105,7 +109,8 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
105
109
  if (!config) throw new Error('no local runtime config; run `frontera local start`')
106
110
  const secrets = loadOrCreateSecrets(project)
107
111
  const previous = readState(project)
108
- const enabled = (c: ComponentName) => !config.exclude.includes(c)
112
+ const searchIndex = searchIndexChoice({ root: project.root, with: config.with ?? [], exclude: config.exclude, dockerMemoryBytes: dockerMemoryBytes() })
113
+ const enabled = (c: ComponentName) => (c === 'search-index' ? searchIndex.on : !config.exclude.includes(c))
109
114
 
110
115
  let state: LocalState = {
111
116
  version: 1,
@@ -118,13 +123,13 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
118
123
  startedAt: new Date().toISOString(),
119
124
  updatedAt: new Date().toISOString(),
120
125
  ports,
121
- components: (['postgres', 'storage', 'service', 'jobs', 'runner', 'studio'] as ComponentName[]).map(
126
+ components: COMPONENTS.map(
122
127
  (name): ComponentState => ({
123
128
  name,
124
129
  status: enabled(name) ? 'starting' : 'off',
125
- kind: name === 'studio' ? 'supervisor' : name === 'postgres' || name === 'jobs' || name === 'storage' ? 'container' : config.mode === 'source' ? 'process' : 'container',
130
+ kind: name === 'studio' ? 'supervisor' : name === 'service' || name === 'runner' ? (config.mode === 'source' ? 'process' : 'container') : 'container',
126
131
  port: ports[name],
127
- detail: enabled(name) ? undefined : 'excluded',
132
+ detail: enabled(name) ? undefined : name === 'search-index' && !searchIndex.on ? searchIndex.why : 'excluded',
128
133
  }),
129
134
  ),
130
135
  credentials: previous?.credentials,
@@ -177,6 +182,11 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
177
182
  await startPostgres(project, ports, secrets, running, set)
178
183
  // Object storage before anything that uploads to it.
179
184
  if (enabled('storage')) await startStorage(project, ports, secrets, running, set)
185
+ // The search index takes about a minute on one CPU, and the service reaches it only when Knowledge is used:
186
+ // it comes up while the rest starts, and the runtime is ready once it answers. It says its own failure.
187
+ const searchIndexFailed = enabled('search-index')
188
+ ? startSearchIndex(project, ports, running, set).then(() => null, (err: Error) => err.message)
189
+ : Promise.resolve(null)
180
190
  // 2. Job engine (before the service, so the service's registration lands)
181
191
  if (enabled('jobs')) await startJobs(project, ports, running, set)
182
192
  // Keyless embeddings answer before the service needs them.
@@ -203,7 +213,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
203
213
  },
204
214
  })
205
215
  // 3. Service
206
- await startService(project, ports, secrets, config, running, set, enabled('runner'), enabled('jobs'), enabled('storage'))
216
+ await startService(project, ports, secrets, config, running, set, enabled('runner'), enabled('jobs'), enabled('storage'), enabled('search-index'))
207
217
  // The service has migrated; the catalog it reads is seeded per environment.
208
218
  seedPluginCatalog(project)
209
219
  // 4. Function runner
@@ -269,10 +279,18 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
269
279
  organizationId: state.credentials!.organizationId,
270
280
  logFile: logPath(project, 'service'),
271
281
  })
282
+ if (enabled('search-index')) {
283
+ await ensureKnowledgeSteward({ serviceUrl, password: secrets.ownerPassword, organizationId: state.credentials!.organizationId, logFile: logPath(project, 'service') })
284
+ }
272
285
  state = { ...state, credentials: { ...state.credentials!, session, modelProviders } }
273
286
  save()
274
287
  // The formats of the project's files, for the release the service runs.
275
288
  await keepFileSchemas(project, { serviceUrl, token: state.credentials!.workspaceKey ?? session }, logPath(project, 'service'))
289
+ const searchIndexProblem = await searchIndexFailed
290
+ if (searchIndexProblem) {
291
+ await fail('search-index', searchIndexProblem)
292
+ return
293
+ }
276
294
  // 6. Studio. From a checkout, rebuild it first when its sources changed
277
295
  // since the last build, so a pulled change is never served stale.
278
296
  if (studioBuildStale(config)) {
@@ -294,7 +312,8 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
294
312
  save()
295
313
  recordRuntime(project, { event: 'started', at: new Date().toISOString(), since: state.startedAt })
296
314
  } catch (err) {
297
- const failing = state.components.find((c) => c.status === 'starting')?.name ?? 'service'
315
+ // The search index starts beside the others and says its own failure above.
316
+ const failing = state.components.find((c) => c.status === 'starting' && c.name !== 'search-index')?.name ?? 'service'
298
317
  await fail(failing, (err as Error).message)
299
318
  return
300
319
  }
@@ -491,6 +510,22 @@ async function startStorage(project: LocalProject, ports: PortBlock, secrets: Lo
491
510
  throw new Error('object storage did not answer within 45 s')
492
511
  }
493
512
 
513
+ async function startSearchIndex(project: LocalProject, ports: PortBlock, running: Running, set: SetFn) {
514
+ const name = containerName(project, 'search-index')
515
+ await ensureImage(SEARCH_INDEX_IMAGE, project, 'search-index')
516
+ const current = containerState(name)
517
+ if (current === 'exited') startContainer(name)
518
+ else if (current !== 'running') {
519
+ if (current !== 'missing') removeContainer(name)
520
+ runContainer(searchIndexRunSpec({ name, projectId: project.id, port: ports['search-index'] }))
521
+ }
522
+ running.containers.add(name)
523
+ followLogs(name, logPath(project, 'search-index'))
524
+ const why = await waitForSearchIndex(ports['search-index'], 180_000)
525
+ if (why) throw new Error(`the search index did not come up within 180 s (${why}); see \`frontera local logs search-index\`, or start without it: \`frontera local start --exclude search-index\``)
526
+ set('search-index', { status: 'running', container: name, startedAt: new Date().toISOString() })
527
+ }
528
+
494
529
  async function startJobs(project: LocalProject, ports: PortBlock, running: Running, set: SetFn) {
495
530
  const name = containerName(project, 'jobs')
496
531
  await ensureImage(IMAGES.jobs, project, 'jobs')
@@ -527,6 +562,7 @@ async function startService(
527
562
  runnerEnabled: boolean,
528
563
  jobsEnabled: boolean,
529
564
  storageEnabled: boolean,
565
+ searchIndexEnabled: boolean,
530
566
  ) {
531
567
  const env = serviceEnv({
532
568
  secrets,
@@ -535,6 +571,7 @@ async function startService(
535
571
  runnerEnabled,
536
572
  jobsEnabled,
537
573
  storageEnabled,
574
+ searchIndexEnabled,
538
575
  keylessEmbeddings: localEmbeddingsReachable(),
539
576
  documentationBundle: config.mode === 'source' ? customerDocsBundle(config.platformSource) : null,
540
577
  })
@@ -12,6 +12,7 @@ import { configDir } from '../paths'
12
12
  import { CliError } from '../errors'
13
13
  import { deleteProfile, fingerprint, getProfile, putProfile } from '../profiles'
14
14
  import { syncClient, type SyncClient } from './local-sync-client'
15
+ import type { PersonClients } from './local-sync-remote'
15
16
  import { PROJECT_FILES, type LocalProject } from './local-project'
16
17
  import { normalizeOrigin } from './local-sync-client'
17
18
 
@@ -360,6 +361,21 @@ export async function organizationReader(owner: KeyOwner, link: SyncLink): Promi
360
361
  return { client: byKey, notSteward: byKey ? null : (person.email ?? 'the person') }
361
362
  }
362
363
 
364
+ /** One session's two places: the linked workspace, and its organization (where a steward or an administrator reads more). */
365
+ export function personClients(platform: string, token: string, orgId: string, workspaceId: string): PersonClients {
366
+ return {
367
+ workspace: syncClient(platform, token, { 'x-org-id': orgId, 'x-workspace-id': workspaceId }),
368
+ organization: syncClient(platform, token, { 'x-org-id': orgId }),
369
+ }
370
+ }
371
+
372
+ /** The person signed in for the link or a named target, as the Knowledge app reads them; null with no session. */
373
+ export async function personReader(owner: KeyOwner, link: SyncLink): Promise<PersonClients | null> {
374
+ const person = await targetSession(owner, link)
375
+ const orgId = person ? await targetOrganization(owner, link) : null
376
+ return person && orgId ? personClients(link.platform, person.token, orgId, link.workspace) : null
377
+ }
378
+
363
379
  const organizationReads = new Map<string, Promise<boolean>>()
364
380
 
365
381
  /** Whether the person may read the organization's Blueprint, asked once per process: a refusal there is a refusal of every organization read. */
@@ -12,7 +12,8 @@ const KIND_WORD: Record<string, string> = {
12
12
  workflow: 'Workflow',
13
13
  function: 'Function',
14
14
  skill: 'Skill',
15
- knowledge: 'Knowledge base',
15
+ knowledge: 'Agent knowledge',
16
+ 'knowledge-base': 'Knowledge base',
16
17
  plugin: 'Plugin',
17
18
  evaluation: 'Evaluation dataset',
18
19
  dataset: 'Dataset',
@@ -44,6 +45,8 @@ export function readableName(slug: string): string {
44
45
  export function kindWord(ref: string): string {
45
46
  const [kind, sub] = ref.split('/')
46
47
  if (kind === 'blueprint') return BLUEPRINT_WORD[sub ?? ''] ?? 'Blueprint'
48
+ // The organization's Knowledge settings live in the knowledge-base folder but are no base.
49
+ if (ref === 'knowledge-base/settings') return 'Knowledge settings'
47
50
  return KIND_WORD[kind ?? ''] ?? readableName(kind ?? 'item')
48
51
  }
49
52
 
@@ -114,6 +117,7 @@ export function namingPaths(kind: string, name: string): string[] {
114
117
  if (kind === 'chat-app') return [`chat-apps/${name}.json`]
115
118
  if (kind === 'app') return [`apps/${name}.json`]
116
119
  if (kind === 'knowledge') return [`knowledge/${name}/knowledge.json`]
120
+ if (kind === 'knowledge-base') return name === 'settings' ? [] : [`knowledge-base/${name}/knowledge-base.json`]
117
121
  if (kind === 'dataset') return [`datasets/${name}.json`]
118
122
  return []
119
123
  }
@@ -160,6 +164,11 @@ export function itemTitle(kind: string, name: string, files: Record<string, stri
160
164
  const own = declared(parseJson(files[`knowledge/${name}/knowledge.json`]), 'displayName', 'name')
161
165
  if (own) return own
162
166
  }
167
+ if (kind === 'knowledge-base') {
168
+ if (name === 'settings') return 'Knowledge settings'
169
+ const own = declared(parseJson(files[`knowledge-base/${name}/knowledge-base.json`]), 'name')
170
+ if (own) return own
171
+ }
163
172
  // An Agent's lessons are named by their Agent: "Lessons for Agent Claims desk".
164
173
  if (kind === 'lessons') {
165
174
  const own = declared((parseJson(files[`agents/${name}/agent.json`]) as { config?: unknown } | null)?.config, 'name')
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
3
- import { dirname, join, relative, resolve, sep } from 'node:path'
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
4
4
 
5
5
  import { parse as parseYaml } from 'yaml'
6
6
 
@@ -28,11 +28,16 @@ import { comparablePortable, toLocal } from './local-sync-portable'
28
28
  * pull produces a `dataset/` item. It stays a kind so `--kinds datasets`, a
29
29
  * lock written with one and the change-request kinds keep reading as before.
30
30
  *
31
+ * `knowledge-base` is the Knowledge app's (`knowledge-base/<folder>/`, and
32
+ * the organization's `knowledge-base/settings.json`), apart from Agent
33
+ * knowledge (`knowledge`). A base's documents are not in its item: they come
34
+ * only when asked for (`pull --documents knowledge-base/<folder>`).
35
+ *
31
36
  * `lessons` is an Agent's `lessons.yaml` as a change request carries it
32
37
  * (`lessons/<slug>`): brought by `pull --lessons`, never listed by a fetch or
33
38
  * kept in the lock, and proposed back as rules for the platform's reviewers.
34
39
  */
35
- export const SYNC_KINDS = ['blueprint', 'dataset', 'skill', 'function', 'plugin', 'knowledge', 'workflow', 'agent', 'evaluation', 'theme', 'chat-app', 'app', 'brand', 'library', 'lessons'] as const
40
+ export const SYNC_KINDS = ['blueprint', 'dataset', 'skill', 'function', 'plugin', 'knowledge', 'knowledge-base', 'workflow', 'agent', 'evaluation', 'theme', 'chat-app', 'app', 'brand', 'library', 'lessons'] as const
36
41
  export type SyncKind = (typeof SYNC_KINDS)[number]
37
42
 
38
43
  export const KIND_LABEL: Record<SyncKind, string> = {
@@ -42,6 +47,7 @@ export const KIND_LABEL: Record<SyncKind, string> = {
42
47
  function: 'Functions',
43
48
  plugin: 'Plugins',
44
49
  knowledge: 'Knowledge',
50
+ 'knowledge-base': 'Knowledge bases',
45
51
  workflow: 'Workflows',
46
52
  agent: 'Agents',
47
53
  evaluation: 'Evaluations',
@@ -93,6 +99,18 @@ export interface RemoteItem {
93
99
  * for the original source.
94
100
  */
95
101
  recovered?: string[]
102
+ /**
103
+ * What the platform keeps of it that its files leave out: a knowledge
104
+ * base's sources the read could not file, by name. A change request carries
105
+ * every source a base has, so a push never proposes it from these files.
106
+ */
107
+ keptOnPlatform?: string[]
108
+ /**
109
+ * Its name in the workspace's sync index, when the project files it under
110
+ * another (a knowledge base keeps the folder it was pulled into): what a
111
+ * change request names it by.
112
+ */
113
+ platformName?: string
96
114
  }
97
115
 
98
116
  export interface ItemAsset {
@@ -538,11 +556,31 @@ export function entryFor(item: RemoteItem, now: string): LockEntry {
538
556
 
539
557
  export const WORKSPACE_SUFFIX = '.workspace'
540
558
 
559
+ type CaseFs = { existsSync(path: string): boolean; readdirSync(path: string): string[]; renameSync(from: string, to: string): void }
560
+
561
+ /**
562
+ * Where the file system ignores case (a Mac's, by default), writing
563
+ * `Newsroom.json` over `newsroom.json` keeps the old name, and a push matches a
564
+ * knowledge base's sources by file name: the file there is renamed to the
565
+ * workspace's name first. Where case counts, they are two files, and both stay.
566
+ * True when it renamed one.
567
+ */
568
+ export function matchCase(full: string, fs: CaseFs = { existsSync, readdirSync, renameSync }): boolean {
569
+ if (!fs.existsSync(full)) return false
570
+ const name = basename(full)
571
+ const entries = fs.readdirSync(dirname(full))
572
+ const held = entries.includes(name) ? undefined : entries.find((e) => e.toLowerCase() === name.toLowerCase())
573
+ if (!held) return false
574
+ fs.renameSync(join(dirname(full), held), full)
575
+ return true
576
+ }
577
+
541
578
  /** Write or delete a working file; true when it changed. */
542
579
  function put(root: string, path: string, content: string | null): boolean {
543
580
  const full = safePath(root, path)
581
+ const renamed = content !== null && matchCase(full)
544
582
  const now = readLocal(root, path)
545
- if (now === content) return false
583
+ if (now === content) return renamed
546
584
  if (content === null) rmSync(full, { force: true })
547
585
  else {
548
586
  mkdirSync(dirname(full), { recursive: true })
@@ -628,11 +666,12 @@ export function applyItems(root: string, workspace: string, items: RemoteItem[],
628
666
  mkdirSync(dirname(full), { recursive: true })
629
667
  // A Plugin's secret values set here stay: they are this computer's configuration. So do a mapped model and
630
668
  // the multi-turn evaluation cases (`local-sync-portable.ts`).
669
+ const renamed = matchCase(full)
631
670
  const content = isPluginFile(path) ? withLocalSecrets(remote, readLocal(root, path)) : toLocal(path, remote, readLocal(root, path), projectModels(root), opts.base?.(path) ?? null)
632
671
  if (readLocal(root, path) !== content) {
633
672
  writeFileSync(full, content)
634
673
  written.push(path)
635
- }
674
+ } else if (renamed) written.push(path)
636
675
  }
637
676
  for (const [path, wroteHash] of Object.entries(previous?.files ?? {})) {
638
677
  if (path in item.files) continue