@frontera-sdk/cli 1.52.43 → 1.52.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/commands/kit/vendor.ts +62 -48
- package/src/commands/local/check.ts +17 -9
- package/src/commands/local/index-commands.ts +7 -1
- package/src/commands/local/init.ts +22 -2
- package/src/commands/local/snapshot.ts +8 -2
- package/src/commands/local/sync-request.ts +95 -9
- package/src/commands/local/sync.ts +27 -6
- package/src/commands/registry.ts +2 -1
- package/src/flag-help.ts +1 -0
- package/src/local/local-case-evidence.ts +3 -2
- package/src/local/local-db-snapshot.ts +23 -3
- package/src/local/local-dev-references.ts +59 -7
- package/src/local/local-env.ts +21 -0
- package/src/local/local-history-carry.ts +103 -0
- package/src/local/local-key-custodian.ts +64 -0
- package/src/local/local-keys.ts +5 -3
- package/src/local/local-knowledge-uploads.ts +62 -5
- package/src/local/local-scaffold.ts +48 -13
- package/src/local/local-schemas.ts +1 -1
- package/src/local/local-seed.ts +1 -1
- package/src/local/local-studio-server.ts +14 -8
- package/src/local/local-supervisor.ts +13 -0
- package/src/local/local-sync-link.ts +43 -2
- package/src/local/local-sync-models.ts +118 -0
- package/src/local/local-sync-plan.ts +19 -9
- package/src/local/local-sync-portable.ts +269 -0
- package/src/local/local-sync-precheck.ts +94 -0
- package/src/local/local-sync-push.ts +45 -7
- package/src/local/local-sync-remote.ts +39 -8
- package/src/local/local-sync-snapshot.ts +9 -1
- package/src/vendor/kit-assets.json +66 -5
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
type SyncKind,
|
|
36
36
|
} from '../../local/local-sync-plan'
|
|
37
37
|
import { readWorkspace, type SyncSource } from '../../local/local-sync-remote'
|
|
38
|
+
import { rememberLocalCatalog } from '../../local/local-sync-models'
|
|
39
|
+
import { runningTarget, runtimeCatalog } from '../../local/local-providers'
|
|
38
40
|
import { buildStatusText, projectBuildSummary } from '../../local/local-build-summary'
|
|
39
41
|
import { kindTitle } from '../../local/local-sync-names'
|
|
40
42
|
import { describeUnresolved } from '../../local/local-sync-refs'
|
|
@@ -59,7 +61,7 @@ import {
|
|
|
59
61
|
} from '../../local/local-sync-snapshot'
|
|
60
62
|
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
61
63
|
import { currentRequest, readRequests } from '../../local/local-sync-push'
|
|
62
|
-
import { syncPush, syncRequest } from './sync-request'
|
|
64
|
+
import { syncMapModel, syncPush, syncRequest } from './sync-request'
|
|
63
65
|
|
|
64
66
|
/**
|
|
65
67
|
* `frontera local sync`: a local project's link to one platform workspace.
|
|
@@ -231,7 +233,16 @@ function sourceIfKeyed(project: LocalProject, link: SyncLink): SyncSource | unde
|
|
|
231
233
|
* unchanged copies only, `online` from the platform too, at the lock's
|
|
232
234
|
* revision. `bases` says what was found and what was not.
|
|
233
235
|
*/
|
|
236
|
+
/** This computer's model catalog, kept while its runtime runs, so models are matched by what they are offline too. */
|
|
237
|
+
async function refreshLocalCatalog(project: LocalProject): Promise<void> {
|
|
238
|
+
const target = runningTarget(project.root)
|
|
239
|
+
if (!target) return
|
|
240
|
+
const models = await runtimeCatalog(target).catch(() => null)
|
|
241
|
+
if (models?.length) rememberLocalCatalog(project.root, models)
|
|
242
|
+
}
|
|
243
|
+
|
|
234
244
|
export async function planFor(project: LocalProject, link: SyncLink, opts: { kinds?: Set<SyncKind>; drafts?: Set<string>; fetch?: 'auto' | 'always' | 'never'; repair?: 'offline' | 'online' } = {}) {
|
|
245
|
+
await refreshLocalCatalog(project)
|
|
235
246
|
const local = localItems(project).filter((l) => !opts.kinds || opts.kinds.has(l.kind))
|
|
236
247
|
const readFromBase = (path: string) => readBase(project.root, path)
|
|
237
248
|
const noBases: BaseRepair[] = []
|
|
@@ -397,8 +408,12 @@ async function checkKeys(platform: string, keys: { workspace: string; organizati
|
|
|
397
408
|
org = syncClient(platform, keys.organization)
|
|
398
409
|
const orgMe = await org.find<WhoAmI>('/v1/whoami').catch(() => null)
|
|
399
410
|
if (orgMe?.orgId && orgMe.orgId !== me.orgId) throw new CliError('the organization key belongs to another organization than the workspace', { code: 'USAGE', hint: 'use a key from the organization that owns the workspace' })
|
|
400
|
-
// The lifecycle is what an organization key is for here; reading it proves the key.
|
|
401
|
-
|
|
411
|
+
// The lifecycle is what an organization key is for here; reading it proves the key. The read is authorized before
|
|
412
|
+
// the lookup, so a 404 (an organization that has never published a Blueprint: no lifecycle yet) proves it too.
|
|
413
|
+
await org.find('/v1/blueprint/lifecycle').catch((err: Error) => {
|
|
414
|
+
if (err instanceof CliError && (err.code === 'UNAUTHORIZED' || err.code === 'FORBIDDEN')) throw err
|
|
415
|
+
throw new CliError(`the organization key couldn’t read the organization’s Blueprint: ${err.message.split('\n')[0]}`, { code: 'SYNC_ORG_KEY_UNREADABLE', hint: 'check the key is a current organization key (sk-org-…) for this platform, then connect again' })
|
|
416
|
+
})
|
|
402
417
|
}
|
|
403
418
|
return { me, identity }
|
|
404
419
|
}
|
|
@@ -425,6 +440,8 @@ const connect = async (ctx: CommandContext) => {
|
|
|
425
440
|
organization: keys.organization ? me.orgId! : previous?.organization,
|
|
426
441
|
organizationName: identity?.organization.name ?? previous?.organizationName,
|
|
427
442
|
blueprint: (blueprintFlag as SyncLink['blueprint'] | undefined) ?? (previous?.blueprint === 'organization' && keys.organization ? 'organization' : 'workspace'),
|
|
443
|
+
// The models mapped for this workspace stay; another workspace has its own catalog.
|
|
444
|
+
...(previous?.models && previous.workspace === me.workspaceId ? { models: previous.models } : {}),
|
|
428
445
|
}
|
|
429
446
|
// A folder that is not a project yet gets the project file the link lives in.
|
|
430
447
|
if (!existsSync(projectFile(project.root))) writeFileSync(projectFile(project.root), `# A Frontera project. \`frontera local dev\` applies agents/, workflows/, functions/ and blueprint/.\nname: ${basename(project.root)}\n`)
|
|
@@ -948,6 +965,7 @@ const SUBCOMMANDS: Record<string, (ctx: CommandContext) => Promise<{ data: unkno
|
|
|
948
965
|
// Called through, not referenced: `sync-request.ts` imports this module back.
|
|
949
966
|
push: (ctx) => syncPush(ctx),
|
|
950
967
|
request: (ctx) => syncRequest(ctx),
|
|
968
|
+
'map-model': (ctx) => syncMapModel(ctx),
|
|
951
969
|
}
|
|
952
970
|
|
|
953
971
|
export const localSync: Command = {
|
|
@@ -955,8 +973,8 @@ export const localSync: Command = {
|
|
|
955
973
|
noun: 'local',
|
|
956
974
|
verb: 'sync',
|
|
957
975
|
args: [
|
|
958
|
-
{ name: 'action', required: true, description: 'connect | fetch | status | pull | diff | resolve | push | request | disconnect' },
|
|
959
|
-
{ name: 'items', required: false, description: 'kind/name … to pull or push, the one to diff or resolve,
|
|
976
|
+
{ name: 'action', required: true, description: 'connect | fetch | status | pull | diff | resolve | push | request | map-model | disconnect' },
|
|
977
|
+
{ name: 'items', required: false, description: 'kind/name … to pull or push, the one to diff or resolve, show | apply | close after request, or the local and workspace models after map-model' },
|
|
960
978
|
],
|
|
961
979
|
flags: {
|
|
962
980
|
platform: 'string',
|
|
@@ -994,6 +1012,7 @@ export const localSync: Command = {
|
|
|
994
1012
|
renders: 'boolean',
|
|
995
1013
|
screenshots: 'string',
|
|
996
1014
|
trace: 'string',
|
|
1015
|
+
remove: 'boolean',
|
|
997
1016
|
},
|
|
998
1017
|
aliases: { t: 'title', d: 'description' },
|
|
999
1018
|
summary: 'Link the project to a platform workspace, pull and merge what you choose, and propose changes back',
|
|
@@ -1024,6 +1043,8 @@ export const localSync: Command = {
|
|
|
1024
1043
|
'frontera local sync request apply',
|
|
1025
1044
|
'frontera local sync request apply --force --reason "hotfix, checked by hand"',
|
|
1026
1045
|
'frontera local sync request close',
|
|
1046
|
+
'frontera local sync map-model llama-4-70b claude-haiku-5-5',
|
|
1047
|
+
'frontera local sync map-model llama-4-70b --remove',
|
|
1027
1048
|
'frontera local sync disconnect --forget-link',
|
|
1028
1049
|
],
|
|
1029
1050
|
offline: true,
|
|
@@ -1032,7 +1053,7 @@ export const localSync: Command = {
|
|
|
1032
1053
|
const action = ctx.positional[0]
|
|
1033
1054
|
const run = action ? SUBCOMMANDS[action] : undefined
|
|
1034
1055
|
const named = action ? `\`${action}\`` : '(none given)'
|
|
1035
|
-
if (!run) throw new UsageError(`unknown sync action ${named}`, 'use connect, fetch, status, pull, diff, resolve, push, request or disconnect')
|
|
1056
|
+
if (!run) throw new UsageError(`unknown sync action ${named}`, 'use connect, fetch, status, pull, diff, resolve, push, request, map-model or disconnect')
|
|
1036
1057
|
return run(ctx)
|
|
1037
1058
|
},
|
|
1038
1059
|
}
|
package/src/commands/registry.ts
CHANGED
|
@@ -222,7 +222,8 @@ export function nounSummary(noun: string): string {
|
|
|
222
222
|
|
|
223
223
|
const KEYS_ARE_SETTINGS_ONLY =
|
|
224
224
|
'organization keys are managed in Organization settings → API Keys, workspace keys in '
|
|
225
|
-
+ 'Workspace settings → API Keys
|
|
225
|
+
+ 'Workspace settings → API Keys, and an agent’s server and public website keys under the '
|
|
226
|
+
+ 'agent’s API keys — by a signed-in administrator, never by the CLI: a '
|
|
226
227
|
+ 'credential that mints credentials is the escalation that separation exists to prevent'
|
|
227
228
|
|
|
228
229
|
/**
|
package/src/flag-help.ts
CHANGED
|
@@ -276,6 +276,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
276
276
|
conflicts: 'the conflicts the picks were chosen against, as `diff --json` names them; refused if they moved',
|
|
277
277
|
'from-workspace': 'start the project from a platform workspace instead of the examples',
|
|
278
278
|
'no-sample-data': 'do not generate sample rows for the pulled Blueprint',
|
|
279
|
+
'no-kit': 'leave out the Frontera skills (the kit) that local init vendors into the project; for a machine that uses the installed plugin instead',
|
|
279
280
|
'keep-sample-data': 'leave the sample datasets as they are; by default they are made again (same seed, rows and day) after a pull that brings Blueprint items, or when an older generator made them',
|
|
280
281
|
'remake-sample-data': 'make the sample datasets again with the newest sample rules (same seed, rows and day), even the ones a pull wrote, which local dev otherwise keeps as pulled',
|
|
281
282
|
synthetic: 'generate sample rows for the Blueprint: values fit each property, links stay consistent',
|
|
@@ -11,8 +11,9 @@ import type { CaseResult } from './local-test-runner'
|
|
|
11
11
|
* matching traces by their time.
|
|
12
12
|
*
|
|
13
13
|
* Traces are kept by the local traces store, outside the platform database:
|
|
14
|
-
* `local check` rolls back the database after its tests
|
|
15
|
-
*
|
|
14
|
+
* `local check` rolls back the database after its tests, keeping the run
|
|
15
|
+
* history (the conversation included) unless that fails, and the trace stays
|
|
16
|
+
* readable either way (`trace_get`, Studio's Traces page).
|
|
16
17
|
*/
|
|
17
18
|
export interface CaseEvidence {
|
|
18
19
|
conversationId?: string
|
|
@@ -6,6 +6,7 @@ import { writeFileAtomic } from '../fs-atomic'
|
|
|
6
6
|
import { loadHashes } from './local-applied'
|
|
7
7
|
import { appDeployments, appsLostByRestore, appsLostWords, markAppsLostByRestore, type AppLostByRestore } from './local-app-restore'
|
|
8
8
|
import { execInContainer, queryInContainer } from './local-docker'
|
|
9
|
+
import { dumpHistory, historyWords, loadHistory, type HistoryCarried } from './local-history-carry'
|
|
9
10
|
import { POSTGRES_DB, POSTGRES_USER } from './local-env'
|
|
10
11
|
import type { LocalProject } from './local-project'
|
|
11
12
|
import { carrySecrets, readSecrets, type QueryFn, type SecretsCarried } from './local-secret-carry'
|
|
@@ -34,6 +35,12 @@ import { containerName } from './local-supervisor'
|
|
|
34
35
|
* encrypted under, copied as stored), unless it is asked for the baseline's
|
|
35
36
|
* (`secrets: 'baseline'`, `frontera local snapshot restore --exact`).
|
|
36
37
|
*
|
|
38
|
+
* The run history is what happened, not test data either: conversations,
|
|
39
|
+
* Agent sessions and runs, workflow and Function runs and evaluation runs are
|
|
40
|
+
* kept as they are now (`local-history-carry.ts`), so a change's results can be
|
|
41
|
+
* compared with the last ones, unless the baseline's are asked for
|
|
42
|
+
* (`history: 'baseline'`, `--exact`). The records those runs wrote go back.
|
|
43
|
+
*
|
|
37
44
|
* The service, job engine and runner keep running: connections to the
|
|
38
45
|
* database are refused for the second or two it takes (`ALLOW_CONNECTIONS
|
|
39
46
|
* false`), the open ones ended (`pg_terminate_backend`), and the pools
|
|
@@ -103,6 +110,8 @@ export interface SnapshotOutcome {
|
|
|
103
110
|
appsLost?: AppLostByRestore[]
|
|
104
111
|
/** restore: the workspace secrets kept as they were before it, or `baseline` when the baseline's were put back. */
|
|
105
112
|
secrets?: (SecretsCarried & { mode: 'keep' }) | { mode: 'baseline' }
|
|
113
|
+
/** restore: the run history kept as it was before it, or `baseline` when the baseline's was put back. */
|
|
114
|
+
history?: (HistoryCarried & { mode: 'keep' }) | { mode: 'baseline' }
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/** The secrets part of a restore's line: what was kept, what has to be set again, or that the baseline's are back. */
|
|
@@ -284,11 +293,12 @@ function changedSince(baseline: string | null, current: string | null): number {
|
|
|
284
293
|
* ended, the database dropped and made again from the baseline, the cache
|
|
285
294
|
* emptied. The CLI's record of what is applied is put back with it.
|
|
286
295
|
*/
|
|
287
|
-
export async function restoreBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, opts: { secrets?: RestoreSecrets } = {}): Promise<SnapshotOutcome> {
|
|
296
|
+
export async function restoreBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, opts: { secrets?: RestoreSecrets; history?: RestoreSecrets } = {}): Promise<SnapshotOutcome> {
|
|
288
297
|
const exec = deps.exec ?? execInContainer
|
|
289
298
|
// A stand-in `exec` without a stand-in `query` carries nothing: tests never reach docker.
|
|
290
299
|
const query = deps.query ?? (deps.exec ? undefined : queryInContainer)
|
|
291
300
|
const keepSecrets = (opts.secrets ?? 'keep') === 'keep'
|
|
301
|
+
const keepHistory = (opts.history ?? 'keep') === 'keep'
|
|
292
302
|
const now = deps.now ?? Date.now
|
|
293
303
|
const psql = psqlFor(project, exec)
|
|
294
304
|
const stored = readBaseline(project, organizationId)
|
|
@@ -299,14 +309,18 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
|
|
|
299
309
|
// The secrets as they are now, read before the database goes: configuration, kept across the restore.
|
|
300
310
|
const pg = containerName(project, 'postgres')
|
|
301
311
|
const held = keepSecrets && query ? readSecrets(query, pg) : null
|
|
312
|
+
// What happened since the baseline, dumped in the container: kept across the restore too.
|
|
313
|
+
const happened = keepHistory && query ? dumpHistory(query, pg) : null
|
|
302
314
|
psql(refuse(POSTGRES_DB, false))
|
|
303
315
|
psql(terminate(POSTGRES_DB))
|
|
304
316
|
if (!psql(`DROP DATABASE ${POSTGRES_DB} WITH (FORCE)`)) {
|
|
305
317
|
psql(refuse(POSTGRES_DB, true))
|
|
318
|
+
if (happened && query && !('problem' in happened)) query(pg, ['rm', '-f', happened.file])
|
|
306
319
|
return { ok: false, ms: now() - started, note: `couldn’t drop ${POSTGRES_DB} to restore it; the database is as the tests left it (see \`frontera local logs postgres\`)` }
|
|
307
320
|
}
|
|
308
321
|
// A new database takes connections: nothing is left refused, whichever way this ends.
|
|
309
322
|
if (!copyWhileHeld(psql, POSTGRES_DB, BASELINE_DB)) {
|
|
323
|
+
if (happened && query && !('problem' in happened)) query(pg, ['rm', '-f', happened.file])
|
|
310
324
|
return { ok: false, ms: now() - started, note: `the database was dropped and couldn’t be made again from ${BASELINE_DB}: run \`frontera local snapshot restore\` to try again, or \`frontera local reset\`` }
|
|
311
325
|
}
|
|
312
326
|
// Before the cache is emptied and the service reads them: the secrets as they were.
|
|
@@ -315,6 +329,11 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
|
|
|
315
329
|
: held && query
|
|
316
330
|
? { mode: 'keep', ...('problem' in held ? { ok: false, kept: 0, notKept: [], problem: held.problem } : carrySecrets(query, pg, held)) }
|
|
317
331
|
: undefined
|
|
332
|
+
const history: SnapshotOutcome['history'] = !keepHistory
|
|
333
|
+
? { mode: 'baseline' }
|
|
334
|
+
: happened && query
|
|
335
|
+
? { mode: 'keep', ...('problem' in happened ? { ok: false, rows: 0, notKept: 0, problem: happened.problem } : loadHistory(query, pg, happened)) }
|
|
336
|
+
: undefined
|
|
318
337
|
// The service's cache holds rows of the database that was dropped.
|
|
319
338
|
exec(`frontera-${project.id}-cache`, ['redis-cli', 'FLUSHALL'])
|
|
320
339
|
await settle(deps)
|
|
@@ -335,9 +354,10 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
|
|
|
335
354
|
// Written last: a watching `local dev` reads it to take the record above.
|
|
336
355
|
writeBaseline(project, { ...stored, restoredAt, restoreMs: ms })
|
|
337
356
|
const kept = secretsWords(secrets)
|
|
357
|
+
const ran = historyWords(history)
|
|
338
358
|
const lostApps = appsLostWords(appsLost)
|
|
339
|
-
const tail = `${reapply ? `; ${reapply} item${reapply === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`` : ''}${lostApps ? `; ${lostApps}` : ''}${kept ? `; ${kept}` : ''}`
|
|
340
|
-
const withSecrets = secrets ? { secrets } : {}
|
|
359
|
+
const tail = `${reapply ? `; ${reapply} item${reapply === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`` : ''}${lostApps ? `; ${lostApps}` : ''}${kept ? `; ${kept}` : ''}${ran ? `; ${ran}` : ''}`
|
|
360
|
+
const withSecrets = { ...(secrets ? { secrets } : {}), ...(history ? { history } : {}) }
|
|
341
361
|
const counted = { reapply: reapply + appsLost.length, ...(appsLost.length ? { appsLost } : {}) }
|
|
342
362
|
if (!answering) return { ok: false, ms, ...counted, ...withSecrets, note: `restored the database (${ms} ms), but the service isn’t answering since: see \`frontera local logs service\`${tail}` }
|
|
343
363
|
return { ok: true, ms, ...counted, ...withSecrets, note: `restored the database as applied (${ms} ms)${tail}` }
|
|
@@ -875,7 +875,8 @@ async function localModels(local: { serviceUrl: string; creds: LocalCredentials
|
|
|
875
875
|
* A knowledge base: created when the runtime has none by its name, given the
|
|
876
876
|
* file's settings (retrieval mode, chunking, top-K and threshold, hybrid
|
|
877
877
|
* search and reranking, extraction and OCR), then the folder's documents the
|
|
878
|
-
* base does not hold yet are uploaded
|
|
878
|
+
* base does not hold yet are uploaded, and those edited here since are
|
|
879
|
+
* uploaded again in place of the runtime's older copy.
|
|
879
880
|
*/
|
|
880
881
|
/**
|
|
881
882
|
* What to say of a knowledge base with no documents in its folder: an Agent
|
|
@@ -987,7 +988,7 @@ export async function applyKnowledge(
|
|
|
987
988
|
return await done({ ok: true, output: '' })
|
|
988
989
|
}
|
|
989
990
|
const sources = await run(['knowledge', 'sources', name, '--json', ...p])
|
|
990
|
-
const listedSources = parseJson<Array<{ id?: string; fileName?: string; folderId?: string | null; status?: string; updatedAt?: string }>>(sources.output) ?? []
|
|
991
|
+
const listedSources = parseJson<Array<{ id?: string; fileName?: string; folderId?: string | null; status?: string; updatedAt?: string; sizeBytes?: number | null }>>(sources.output) ?? []
|
|
991
992
|
const key = local?.creds.workspaceKey
|
|
992
993
|
const http = async (method: string, path: string, body?: FormData | Record<string, unknown>) => {
|
|
993
994
|
const res = await fetch(new URL(path, local!.serviceUrl), {
|
|
@@ -1008,15 +1009,54 @@ export async function applyKnowledge(
|
|
|
1008
1009
|
// A document the runtime holds pending or processing with no ingestion behind it (a rollback took its
|
|
1009
1010
|
// reading with the database it replaced) is queued to be read again: uploading it again would add a second.
|
|
1010
1011
|
if (key && id) notes.push(...(await requeueStuckDocuments(project, local!, name, id, listedSources)))
|
|
1011
|
-
|
|
1012
|
-
|
|
1012
|
+
// A document edited in place keeps its name: the runtime's older copy is found by content, uploaded over, then taken off.
|
|
1013
|
+
const { loadUploadedDocuments, saveUploadedDocuments, staleDocuments } = await import('./local-knowledge-uploads')
|
|
1014
|
+
const { sha256Of } = await import('./local-knowledge-documents')
|
|
1015
|
+
const contents = documents.flatMap((path) => {
|
|
1016
|
+
try {
|
|
1017
|
+
const bytes = readFileSync(join(project.root, dirname(target.file), path))
|
|
1018
|
+
return [{ path, sha256: sha256Of(bytes), bytes: bytes.byteLength }]
|
|
1019
|
+
} catch {
|
|
1020
|
+
return []
|
|
1021
|
+
}
|
|
1022
|
+
})
|
|
1023
|
+
const onRuntime = listedSources.flatMap((s) => (s.id ? [{ id: s.id, path: documentPath(s.folderId ? folders.get(s.folderId) : null, s.fileName ?? ''), sizeBytes: s.sizeBytes }] : []))
|
|
1024
|
+
const uploaded = key && id ? loadUploadedDocuments(project.stateDir, local!.creds.organizationId, id) : {}
|
|
1025
|
+
const stale = staleDocuments(contents, onRuntime, uploaded)
|
|
1026
|
+
if (stale.length && (!key || !id)) {
|
|
1027
|
+
notes.push(` warning: Knowledge base ${name}: ${stale.map((s) => s.path).join(', ')} changed here, but the runtime keeps ${stale.length === 1 ? 'its older copy' : 'their older copies'} until it has a workspace key`)
|
|
1028
|
+
}
|
|
1029
|
+
const replacing = key && id ? stale : []
|
|
1030
|
+
// What the runtime now holds for each document, recorded so the next apply tells an edit from no change.
|
|
1031
|
+
const record = async () => {
|
|
1032
|
+
if (!key || !id) return
|
|
1033
|
+
const now = rows<{ id?: string; fileName?: string; folderId?: string | null; createdAt?: string }>(await http('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources`).catch(() => null))
|
|
1034
|
+
// Folders this apply made are not in `folders` yet.
|
|
1035
|
+
const filedNow = now.some((s) => s.folderId && !folders.has(s.folderId))
|
|
1036
|
+
? folderPaths((((await http('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/folders`).catch(() => null))?.folders ?? []) as Array<{ id: string; parentId?: string | null; name: string }>))
|
|
1037
|
+
: folders
|
|
1038
|
+
const next: typeof uploaded = {}
|
|
1039
|
+
for (const doc of contents) {
|
|
1040
|
+
const copies = now.filter((s) => s.id && documentPath(s.folderId ? filedNow.get(s.folderId) : null, s.fileName ?? '') === doc.path)
|
|
1041
|
+
const newest = copies.sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')))[0]
|
|
1042
|
+
if (newest?.id) next[doc.path] = { sourceId: newest.id, sha256: doc.sha256 }
|
|
1043
|
+
}
|
|
1044
|
+
saveUploadedDocuments(project.stateDir, local!.creds.organizationId, id, next)
|
|
1045
|
+
}
|
|
1046
|
+
const fresh = [...documents.filter((f) => !have.has(f)), ...replacing.map((s) => s.path)]
|
|
1047
|
+
if (!fresh.length) {
|
|
1048
|
+
await record()
|
|
1049
|
+
return await done({ ok: true, output: '' })
|
|
1050
|
+
}
|
|
1013
1051
|
const top = fresh.filter((f) => !f.includes('/'))
|
|
1014
1052
|
const filed = fresh.filter((f) => f.includes('/'))
|
|
1015
1053
|
const lines: string[] = []
|
|
1054
|
+
const sent = new Set<string>()
|
|
1016
1055
|
if (top.length) {
|
|
1017
|
-
const
|
|
1018
|
-
if (!
|
|
1019
|
-
if (
|
|
1056
|
+
const uploadedTop = await run(['knowledge', 'upload', name, ...top.map((f) => join(dirname(target.file), f)), ...p])
|
|
1057
|
+
if (!uploadedTop.ok) return await done(uploadedTop)
|
|
1058
|
+
if (uploadedTop.output.trim()) lines.push(uploadedTop.output.trim())
|
|
1059
|
+
for (const f of top) sent.add(f)
|
|
1020
1060
|
}
|
|
1021
1061
|
if (filed.length && (!key || !id)) {
|
|
1022
1062
|
notes.push(` warning: Knowledge base ${name}: ${filed.length} document${filed.length === 1 ? ' in a folder waits' : 's in folders wait'} until the runtime has a workspace key`)
|
|
@@ -1048,11 +1088,23 @@ export async function applyKnowledge(
|
|
|
1048
1088
|
form.append('folderId', await folderFor(folder!))
|
|
1049
1089
|
await http('POST', `/v1/workspace-knowledge/${encodeURIComponent(id!)}/sources`, form)
|
|
1050
1090
|
lines.push(` uploaded ${path}`)
|
|
1091
|
+
sent.add(path)
|
|
1051
1092
|
} catch (err) {
|
|
1052
1093
|
notes.push(` warning: Knowledge base ${name}: ${path} wasn’t uploaded (${(err as Error).message})`)
|
|
1053
1094
|
}
|
|
1054
1095
|
}
|
|
1055
1096
|
}
|
|
1097
|
+
// The older copies come off only once the new content is up, so the base is never without the document.
|
|
1098
|
+
for (const doc of replacing) {
|
|
1099
|
+
if (!sent.has(doc.path)) continue
|
|
1100
|
+
try {
|
|
1101
|
+
for (const sourceId of doc.replaces) await http('DELETE', `/v1/workspace-knowledge/${encodeURIComponent(id!)}/sources/${encodeURIComponent(sourceId)}`)
|
|
1102
|
+
lines.push(` replaced ${doc.path}: it changed here`)
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
notes.push(` warning: Knowledge base ${name}: ${doc.path}’s older copy wasn’t taken off (${(err as Error).message}); search may find both`)
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
await record()
|
|
1056
1108
|
return await done({ ok: true, output: lines.join('\n') })
|
|
1057
1109
|
}
|
|
1058
1110
|
|
package/src/local/local-env.ts
CHANGED
|
@@ -118,6 +118,21 @@ export const SERVICE_OUTBOUND = [
|
|
|
118
118
|
'AUTOMATION_RUNNER_URL',
|
|
119
119
|
] as const
|
|
120
120
|
|
|
121
|
+
/** The service's settings for a hosted key custodian (OpenBao / Vault transit), blanked on the local runtime. */
|
|
122
|
+
export const KEY_CUSTODIAN_SETTINGS = [
|
|
123
|
+
'BAO_ADDR',
|
|
124
|
+
'BAO_TOKEN',
|
|
125
|
+
'BAO_CACERT',
|
|
126
|
+
'VAULT_ADDR',
|
|
127
|
+
'VAULT_TOKEN',
|
|
128
|
+
'SECRETS_TRANSIT_AUTH',
|
|
129
|
+
'SECRETS_TRANSIT_MOUNT',
|
|
130
|
+
'SECRETS_TRANSIT_KEY',
|
|
131
|
+
'SECRETS_TRANSIT_K8S_ROLE',
|
|
132
|
+
'SECRETS_TRANSIT_K8S_MOUNT',
|
|
133
|
+
'SECRETS_TRANSIT_K8S_TOKEN_FILE',
|
|
134
|
+
] as const
|
|
135
|
+
|
|
121
136
|
/** What the Function runner dials on this machine. */
|
|
122
137
|
export const RUNNER_OUTBOUND = ['SERVICE_URL', 'S3_ENDPOINT', 'INNGEST_BASE_URL'] as const
|
|
123
138
|
|
|
@@ -182,6 +197,12 @@ export function serviceEnv(args: {
|
|
|
182
197
|
DAYTONA_API_KEY: '',
|
|
183
198
|
DAYTONA_API_URL: '',
|
|
184
199
|
DAYTONA_TARGET: '',
|
|
200
|
+
// The runtime holds its own keys: ENCRYPTION_KEY from its secrets.json wraps every data key.
|
|
201
|
+
// A checkout's .env can name a hosted OpenBao instead (the service loads it from its
|
|
202
|
+
// directory), and every data key wrapped there is unreadable once that OpenBao goes away.
|
|
203
|
+
// Empty values win over the file.
|
|
204
|
+
SECRETS_KEY_PROVIDER: 'env',
|
|
205
|
+
...Object.fromEntries(KEY_CUSTODIAN_SETTINGS.map((key) => [key, ''])),
|
|
185
206
|
// Traces go to the runtime's own local traces, read back by Studio.
|
|
186
207
|
LANGFUSE_BASE_URL: `http://127.0.0.1:${ports.traces}`,
|
|
187
208
|
LANGFUSE_PUBLIC_KEY: LOCAL_TRACE_KEYS.publicKey,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { POSTGRES_DB, POSTGRES_USER } from './local-env'
|
|
4
|
+
import type { QueryFn } from './local-secret-carry'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The run history carried across a database restore.
|
|
8
|
+
*
|
|
9
|
+
* A restore (`local check`'s rollback, `frontera local snapshot restore`)
|
|
10
|
+
* throws away what was written since the baseline. What happened is not test
|
|
11
|
+
* data: the conversations (an Agent test case's included), Agent sessions and
|
|
12
|
+
* runs, workflow and Function runs and evaluation runs are how a change is
|
|
13
|
+
* compared with the one before it. So a restore keeps them as they are now,
|
|
14
|
+
* unless asked for the baseline's (`--exact`). The records those runs wrote
|
|
15
|
+
* are still rolled back.
|
|
16
|
+
*
|
|
17
|
+
* The rows are dumped inside the database container before the database is
|
|
18
|
+
* dropped (`pg_dump --data-only --inserts --on-conflict-do-nothing`), then
|
|
19
|
+
* loaded into the restored one, a row a statement: a row the baseline holds
|
|
20
|
+
* stays as the baseline holds it, and a row that names something the restore
|
|
21
|
+
* took away (a work item a test made) is left out and counted. Nothing leaves
|
|
22
|
+
* the container, and the dump is removed once loaded.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The tables that hold what happened, parents before children (pg_dump orders them by their keys too). */
|
|
26
|
+
export const HISTORY_TABLES = [
|
|
27
|
+
'conversations',
|
|
28
|
+
'messages',
|
|
29
|
+
'message_feedback',
|
|
30
|
+
'conversation_labels',
|
|
31
|
+
'conversation_files',
|
|
32
|
+
'trace_annotations',
|
|
33
|
+
'session_handoffs',
|
|
34
|
+
'agent_sessions',
|
|
35
|
+
'agent_session_events',
|
|
36
|
+
'agent_session_analyses',
|
|
37
|
+
'agent_timeline_events',
|
|
38
|
+
'agent_exchanges',
|
|
39
|
+
'pi_runs',
|
|
40
|
+
'pi_run_events',
|
|
41
|
+
'pi_model_calls',
|
|
42
|
+
'workflow_runs',
|
|
43
|
+
'workflow_run_nodes',
|
|
44
|
+
'workflow_step_test_results',
|
|
45
|
+
'function_runs',
|
|
46
|
+
'function_run_steps',
|
|
47
|
+
'ctx_agent_turns',
|
|
48
|
+
'dataset_runs',
|
|
49
|
+
'dataset_run_items',
|
|
50
|
+
'knowledge_eval_runs',
|
|
51
|
+
'knowledge_eval_run_fixtures',
|
|
52
|
+
'scheduled_task_runs',
|
|
53
|
+
'agent_routine_runs',
|
|
54
|
+
] as const
|
|
55
|
+
|
|
56
|
+
/** The history dumped from the current database, waiting in the container for the restored one. */
|
|
57
|
+
export interface HistoryHeld {
|
|
58
|
+
file: string
|
|
59
|
+
rows: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface HistoryCarried {
|
|
63
|
+
ok: boolean
|
|
64
|
+
/** Rows dumped before the restore (those the baseline already held included). */
|
|
65
|
+
rows: number
|
|
66
|
+
/** Rows left out: they name something the restored database lacks. */
|
|
67
|
+
notKept: number
|
|
68
|
+
/** Why nothing was carried, when it failed. */
|
|
69
|
+
problem?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sh = (script: string) => ['sh', '-c', script]
|
|
73
|
+
|
|
74
|
+
/** Dump the history tables as they are now into a file in the container, or why it could not be. */
|
|
75
|
+
export function dumpHistory(query: QueryFn, container: string): HistoryHeld | { problem: string } {
|
|
76
|
+
const file = `/tmp/frontera-history-${randomBytes(6).toString('hex')}.sql`
|
|
77
|
+
const tables = HISTORY_TABLES.map((t) => `-t public.${t}`).join(' ')
|
|
78
|
+
const res = query(container, sh(`pg_dump -U ${POSTGRES_USER} -d ${POSTGRES_DB} --data-only --inserts --on-conflict-do-nothing ${tables} -f ${file} || exit 1; grep -c '^INSERT INTO' ${file}; exit 0`))
|
|
79
|
+
const rows = Number.parseInt(res.out.trim().split('\n').pop() ?? '', 10)
|
|
80
|
+
if (!res.ok || !Number.isFinite(rows)) {
|
|
81
|
+
query(container, sh(`rm -f ${file}`))
|
|
82
|
+
return { problem: 'the run history could not be read' }
|
|
83
|
+
}
|
|
84
|
+
return { file, rows }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Load the dumped history into the restored database, then remove the dump. */
|
|
88
|
+
export function loadHistory(query: QueryFn, container: string, held: HistoryHeld): HistoryCarried {
|
|
89
|
+
// No ON_ERROR_STOP: each row is its own statement, and one the restored database refuses is counted, not fatal.
|
|
90
|
+
const res = query(container, sh(`psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -q -f ${held.file} 2>&1 | grep -c 'ERROR:'; rm -f ${held.file}`))
|
|
91
|
+
const notKept = Number.parseInt(res.out.trim().split('\n').pop() ?? '', 10)
|
|
92
|
+
if (!res.ok || !Number.isFinite(notKept)) return { ok: false, rows: held.rows, notKept: 0, problem: 'the run history could not be written back' }
|
|
93
|
+
return { ok: true, rows: held.rows, notKept }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What a restore does with the run history, in words. */
|
|
97
|
+
export function historyWords(history: (HistoryCarried & { mode: 'keep' }) | { mode: 'baseline' } | undefined): string {
|
|
98
|
+
if (!history) return ''
|
|
99
|
+
if (history.mode === 'baseline') return 'the run history is as the baseline holds it (--exact)'
|
|
100
|
+
if (!history.ok) return `the run history couldn’t be kept (${history.problem ?? 'unknown'}), so conversations and runs are as the baseline holds them`
|
|
101
|
+
const kept = 'kept the run history (conversations, runs and evaluations)'
|
|
102
|
+
return history.notKept ? `${kept}, except ${history.notKept} row${history.notKept === 1 ? '' : 's'} naming records the rollback took away` : kept
|
|
103
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { withFix } from './fix-words'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The local runtime's key custodian, as its service reports it on
|
|
5
|
+
* `/v1/health`: whether the keys that lock its secrets and governed Action
|
|
6
|
+
* records answer. A runtime holds its own keys (`serviceEnv` pins the env
|
|
7
|
+
* custodian); one whose data keys were wrapped by a hosted OpenBao it no
|
|
8
|
+
* longer reaches cannot read them again, so the only fix is a clean runtime.
|
|
9
|
+
* Read by `local start` (which refuses), `local status`, `local check` and
|
|
10
|
+
* Forge's Runtime page. The report carries categories and a count, never a
|
|
11
|
+
* key, token or address.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface KeyCustodianReport {
|
|
15
|
+
state: 'ready' | 'failed' | 'none'
|
|
16
|
+
provider: 'env' | 'transit' | null
|
|
17
|
+
reason?: 'unreachable' | 'misconfigured' | 'unreadable-keys'
|
|
18
|
+
foreignKeys: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What fixes a broken custodian: nothing else can unwrap those data keys. */
|
|
22
|
+
export const KEY_CUSTODIAN_FIX = 'run `frontera local reset` for a clean runtime that holds its own keys (it wipes this runtime’s data)'
|
|
23
|
+
|
|
24
|
+
/** How every custodian problem starts, so a failed start's error is recognised as one. */
|
|
25
|
+
export const KEY_CUSTODIAN_LEAD = 'the runtime’s encryption keys can’t be read'
|
|
26
|
+
|
|
27
|
+
/** The problem a report describes, with its fix; null when the keys answer (or the report is from a service that has none). */
|
|
28
|
+
export function keyCustodianProblem(report: KeyCustodianReport | null | undefined): string | null {
|
|
29
|
+
if (!report) return null
|
|
30
|
+
const what = 'so its secrets and governed Action records can’t be read or saved'
|
|
31
|
+
if (report.foreignKeys > 0) {
|
|
32
|
+
const keys = report.foreignKeys === 1 ? '1 data key was' : `${report.foreignKeys} data keys were`
|
|
33
|
+
return withFix(`${KEY_CUSTODIAN_LEAD}: ${keys} locked by a key service it doesn’t hold (an OpenBao named in a checkout’s .env), ${what}`, KEY_CUSTODIAN_FIX)
|
|
34
|
+
}
|
|
35
|
+
if (report.state !== 'failed') return null
|
|
36
|
+
const why =
|
|
37
|
+
report.reason === 'unreachable'
|
|
38
|
+
? 'its key service doesn’t answer'
|
|
39
|
+
: report.reason === 'unreadable-keys'
|
|
40
|
+
? 'its stored data keys don’t open with the keys in .frontera/local/secrets.json'
|
|
41
|
+
: 'its key service is set up wrong'
|
|
42
|
+
return withFix(`${KEY_CUSTODIAN_LEAD}: ${why}, ${what}`, KEY_CUSTODIAN_FIX)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether a failed start's error is a custodian problem (its fix is a reset, not a restart). */
|
|
46
|
+
export function isKeyCustodianProblem(error: string | null | undefined): boolean {
|
|
47
|
+
return !!error && error.includes(KEY_CUSTODIAN_LEAD)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The custodian problem the running service reports, or null: its keys
|
|
52
|
+
* answer, it can't be reached (other checks say so), or it is a release from
|
|
53
|
+
* before the report.
|
|
54
|
+
*/
|
|
55
|
+
export async function runtimeKeyCustodianProblem(serviceUrl: string, doFetch: (url: string, init?: RequestInit) => Promise<Response> = fetch): Promise<string | null> {
|
|
56
|
+
try {
|
|
57
|
+
const res = await doFetch(`${serviceUrl.replace(/\/+$/, '')}/v1/health`, { signal: AbortSignal.timeout(3000) })
|
|
58
|
+
if (!res.ok) return null
|
|
59
|
+
const body = (await res.json().catch(() => null)) as { data?: { keyCustodian?: KeyCustodianReport | null } } | null
|
|
60
|
+
return keyCustodianProblem(body?.data?.keyCustodian)
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/local/local-keys.ts
CHANGED
|
@@ -21,8 +21,8 @@ const SERVICE = 'frontera-local-model-key'
|
|
|
21
21
|
export interface ProviderSpec {
|
|
22
22
|
id: string
|
|
23
23
|
label: string
|
|
24
|
-
/** The variable the service reads for this provider. */
|
|
25
|
-
envVar
|
|
24
|
+
/** The variable the service reads for this provider; none for a key only an organization holds. */
|
|
25
|
+
envVar?: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Providers the local runtime can pass a key to; mirrors the service's built-in providers. */
|
|
@@ -32,6 +32,8 @@ export const LOCAL_PROVIDERS: readonly ProviderSpec[] = [
|
|
|
32
32
|
{ id: 'openrouter', label: 'OpenRouter', envVar: 'OPENROUTER_API_KEY' },
|
|
33
33
|
{ id: 'google', label: 'Google Gemini', envVar: 'GOOGLE_GENERATIVE_AI_API_KEY' },
|
|
34
34
|
{ id: 'cohere', label: 'Cohere (reranking)', envVar: 'COHERE_API_KEY' },
|
|
35
|
+
// Agents' web search; stored as the local organization's own key, never an environment variable.
|
|
36
|
+
{ id: 'openrouter-web-search', label: 'OpenRouter web search' },
|
|
35
37
|
]
|
|
36
38
|
|
|
37
39
|
export function findProvider(id: string): ProviderSpec | undefined {
|
|
@@ -100,7 +102,7 @@ export function providerEnv(): Record<string, string> {
|
|
|
100
102
|
const env: Record<string, string> = {}
|
|
101
103
|
for (const p of LOCAL_PROVIDERS) {
|
|
102
104
|
const key = getKey(p.id)
|
|
103
|
-
if (key) env[p.envVar] = key
|
|
105
|
+
if (key && p.envVar) env[p.envVar] = key
|
|
104
106
|
}
|
|
105
107
|
return env
|
|
106
108
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, join, resolve, sep } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { KNOWLEDGE_DOCUMENT } from './local-dev-references'
|
|
@@ -23,10 +23,10 @@ import { rows } from './local-sync-remote'
|
|
|
23
23
|
* - a name already in the folder, unless the request says to replace it;
|
|
24
24
|
* - a folder or file that is a symbolic link, or resolves outside `knowledge/`.
|
|
25
25
|
*
|
|
26
|
-
* Removing a document takes its file out of the folder
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* Removing a document takes its file out of the folder, and the server takes
|
|
27
|
+
* the runtime's copy off (`removeRuntimeCopies`), as it does before a replaced
|
|
28
|
+
* file is written. A file edited in place (by a coding agent, say) the apply
|
|
29
|
+
* uploads again itself, then takes the older copy off (`staleDocuments`).
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
/** The extensions `local dev` uploads into a knowledge base, as `KNOWLEDGE_DOCUMENT` matches them. */
|
|
@@ -211,6 +211,63 @@ export async function removeRuntimeCopies(call: ServiceCall, knowledgeId: string
|
|
|
211
211
|
return copies.length
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/** What the last apply uploaded for each document, by its path: the runtime's copy and the content it was. */
|
|
215
|
+
export type UploadedDocuments = Record<string, { sourceId: string; sha256: string }>
|
|
216
|
+
|
|
217
|
+
const uploadsPath = (stateDir: string) => join(stateDir, 'knowledge-uploads.json')
|
|
218
|
+
|
|
219
|
+
/** The documents the apply uploaded into one knowledge base on this runtime; nothing after a reset (another organization). */
|
|
220
|
+
export function loadUploadedDocuments(stateDir: string, organizationId: string, knowledgeId: string): UploadedDocuments {
|
|
221
|
+
try {
|
|
222
|
+
const saved = JSON.parse(readFileSync(uploadsPath(stateDir), 'utf8')) as { organizationId?: string; bases?: Record<string, UploadedDocuments> }
|
|
223
|
+
if (saved.organizationId === organizationId) return saved.bases?.[knowledgeId] ?? {}
|
|
224
|
+
} catch {
|
|
225
|
+
// Missing or unreadable: no copy counts as recorded.
|
|
226
|
+
}
|
|
227
|
+
return {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function saveUploadedDocuments(stateDir: string, organizationId: string, knowledgeId: string, uploaded: UploadedDocuments): void {
|
|
231
|
+
let bases: Record<string, UploadedDocuments> = {}
|
|
232
|
+
try {
|
|
233
|
+
const saved = JSON.parse(readFileSync(uploadsPath(stateDir), 'utf8')) as { organizationId?: string; bases?: Record<string, UploadedDocuments> }
|
|
234
|
+
if (saved.organizationId === organizationId && saved.bases) bases = saved.bases
|
|
235
|
+
} catch {
|
|
236
|
+
// Starts afresh.
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
mkdirSync(stateDir, { recursive: true })
|
|
240
|
+
writeFileSync(uploadsPath(stateDir), `${JSON.stringify({ organizationId, bases: { ...bases, [knowledgeId]: uploaded } })}\n`, { mode: 0o600 })
|
|
241
|
+
} catch {
|
|
242
|
+
// Losing this only means the next apply judges an edit by the copy's size.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The documents the runtime holds an older copy of, each with the copies to
|
|
248
|
+
* take off once the new content is up. A copy the last apply recorded is
|
|
249
|
+
* older when the file's content differs from what was uploaded; a copy no
|
|
250
|
+
* apply here recorded (uploaded before the record, or by hand) is older when
|
|
251
|
+
* its size differs, and otherwise taken as the file's.
|
|
252
|
+
*/
|
|
253
|
+
export function staleDocuments(
|
|
254
|
+
local: Array<{ path: string; sha256: string; bytes: number }>,
|
|
255
|
+
runtime: Array<{ id: string; path: string; sizeBytes?: number | null }>,
|
|
256
|
+
uploaded: UploadedDocuments,
|
|
257
|
+
): Array<{ path: string; replaces: string[] }> {
|
|
258
|
+
const out: Array<{ path: string; replaces: string[] }> = []
|
|
259
|
+
for (const doc of local) {
|
|
260
|
+
const copies = runtime.filter((s) => s.path === doc.path)
|
|
261
|
+
if (!copies.length) continue
|
|
262
|
+
const was = uploaded[doc.path]
|
|
263
|
+
const stale = was && copies.some((c) => c.id === was.sourceId)
|
|
264
|
+
? was.sha256 !== doc.sha256
|
|
265
|
+
: copies.length === 1 && typeof copies[0]!.sizeBytes === 'number' && copies[0]!.sizeBytes !== doc.bytes
|
|
266
|
+
if (stale) out.push({ path: doc.path, replaces: copies.map((c) => c.id) })
|
|
267
|
+
}
|
|
268
|
+
return out
|
|
269
|
+
}
|
|
270
|
+
|
|
214
271
|
/** The project's documents for a knowledge base, each with whether the runtime holds it yet. */
|
|
215
272
|
export function projectDocuments(root: string, folder: string, onRuntime: ReadonlySet<string> | null): Array<{ path: string; onRuntime: boolean | null }> {
|
|
216
273
|
return documentPaths(root, folder).map((path) => ({ path, onRuntime: onRuntime ? onRuntime.has(path) : null }))
|