@frontera-sdk/cli 1.52.43 → 1.52.44
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/local/check.ts +17 -9
- package/src/commands/local/index-commands.ts +7 -1
- 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/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-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-studio-server.ts +1 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.44",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
42
|
"@faker-js/faker": "10.6.0",
|
|
43
|
-
"@frontera-sdk/core": "1.52.
|
|
44
|
-
"@frontera-sdk/functions": "1.52.
|
|
43
|
+
"@frontera-sdk/core": "1.52.44",
|
|
44
|
+
"@frontera-sdk/functions": "1.52.44",
|
|
45
45
|
"ai": "^6.0.116",
|
|
46
46
|
"axe-core": "4.12.1",
|
|
47
47
|
"gray-matter": "^4.0.3",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"yaml": "^2.9.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@frontera-sdk/forge-contracts": "1.52.
|
|
52
|
+
"@frontera-sdk/forge-contracts": "1.52.44",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
|
@@ -26,6 +26,7 @@ import { agentFilesProblem, caseConcurrency, findCaseFiles, runCases, selectCase
|
|
|
26
26
|
import { findWorkflowTestFiles, recordWorkflowTestRun, runWorkflowTests, workflowFileState } from '../../local/local-workflow-tests'
|
|
27
27
|
import { vendoredSdkLine, vendoredSdkState } from '../../local/local-vendored-sdk'
|
|
28
28
|
import { withFix } from '../../local/fix-words'
|
|
29
|
+
import { runtimeKeyCustodianProblem } from '../../local/local-key-custodian'
|
|
29
30
|
import { ensureAppPackages } from '../../project-bootstrap'
|
|
30
31
|
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
31
32
|
|
|
@@ -53,8 +54,10 @@ import { flagBool, flagString, type Command, type CommandContext } from '../type
|
|
|
53
54
|
* case with `realActions` or a `decision`), so after the apply the database
|
|
54
55
|
* is kept as a baseline (`local-db-snapshot.ts`, taken again only when what
|
|
55
56
|
* is applied changed), and after the tests it is restored: each check starts
|
|
56
|
-
* from the data the project's files make.
|
|
57
|
-
*
|
|
57
|
+
* from the data the project's files make. The run history (conversations,
|
|
58
|
+
* runs, evaluations) is kept across it (`local-history-carry.ts`), so one
|
|
59
|
+
* check's results can be compared with the last. `--keep-state` keeps what the
|
|
60
|
+
* tests wrote, to look at it.
|
|
58
61
|
*
|
|
59
62
|
* Knowledge documents are read by a background job whose work lands in the
|
|
60
63
|
* database: before the baseline and the tests, the check waits (bounded)
|
|
@@ -373,8 +376,13 @@ async function check(ctx: CommandContext) {
|
|
|
373
376
|
continue
|
|
374
377
|
}
|
|
375
378
|
if (step === 'start') {
|
|
376
|
-
if (running(project))
|
|
377
|
-
|
|
379
|
+
if (running(project)) {
|
|
380
|
+
// Running, with keys it can't read: every later step would fail on a secret or an Action record.
|
|
381
|
+
const state = readState(project)!
|
|
382
|
+
const keyProblem = await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`)
|
|
383
|
+
if (keyProblem) record({ step, status: 'failed', note: keyProblem, ms: 0 })
|
|
384
|
+
else record({ step, status: 'skipped', note: 'the runtime is already running', ms: 0 })
|
|
385
|
+
} else await command(step, root, self(['local', 'start']), 'frontera local start')
|
|
378
386
|
continue
|
|
379
387
|
}
|
|
380
388
|
if (step === 'apply') {
|
|
@@ -560,10 +568,9 @@ async function check(ctx: CommandContext) {
|
|
|
560
568
|
})
|
|
561
569
|
recordRun(project, results)
|
|
562
570
|
const bad = results.filter((r) => !r.passed && !r.notRun)
|
|
563
|
-
// Each failing case's conversation, last turn's trace and reply, read before the rollback
|
|
571
|
+
// Each failing case's conversation, last turn's trace and reply, read before the rollback (which keeps the conversation).
|
|
564
572
|
const evidence = await failingCaseEvidence(project, results)
|
|
565
|
-
const
|
|
566
|
-
for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e, { rolledBack: rollsBack })}`)
|
|
573
|
+
for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e)}`)
|
|
567
574
|
const notRun = notRunGroups(results)
|
|
568
575
|
for (const g of notRun) {
|
|
569
576
|
if (g.skipped) ctx.output.note(` – ${g.skipped} more case${g.skipped === 1 ? '' : 's'} not sent: ${g.what}`)
|
|
@@ -604,9 +611,10 @@ async function check(ctx: CommandContext) {
|
|
|
604
611
|
const state = readState(project)
|
|
605
612
|
if (state?.credentials && running(project)) {
|
|
606
613
|
const restored = await restoreBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
|
|
607
|
-
//
|
|
614
|
+
// The run history is kept across the rollback; when it couldn't be, a failing case's conversation went with it (its trace stays: traces live outside the database).
|
|
608
615
|
const traced = steps.some((st) => st.failures?.some((f) => f.traceId))
|
|
609
|
-
|
|
616
|
+
const historyLost = restored.history?.mode === 'keep' && !restored.history.ok
|
|
617
|
+
testData = { ...testData, rolledBack: restored.ok, ms: restored.ms, note: restored.ok ? `the tests’ writes were rolled back: ${restored.note}${traced && historyLost ? '; the failing cases’ conversations went with them, their traces stay (trace_get, Studio’s Traces)' : ''}` : `the tests’ writes were not rolled back: ${restored.note}` }
|
|
610
618
|
ctx.output.note(`── test data: ${testData.note}${restored.ok ? ' (`--keep-state` keeps them)' : ''}`)
|
|
611
619
|
// The documents the baseline holds unread are read again: their reading went with the database the restore replaced.
|
|
612
620
|
const ingest = restored.ok ? localIngestApi(`http://127.0.0.1:${state.ports.service}`, state.credentials) : null
|
|
@@ -59,6 +59,7 @@ import { localSchema } from './schema'
|
|
|
59
59
|
import { localSync } from './sync'
|
|
60
60
|
import { keylessEmbeddingsStatus, KEYLESS_RETRIEVAL_NOTE } from '../../local/local-org-settings'
|
|
61
61
|
import { withFix } from '../../local/fix-words'
|
|
62
|
+
import { isKeyCustodianProblem, KEY_CUSTODIAN_FIX, runtimeKeyCustodianProblem } from '../../local/local-key-custodian'
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* `frontera local …`: Frontera's runtimes on this machine, for FDEs and the coding agents working with them.
|
|
@@ -302,7 +303,8 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
|
|
|
302
303
|
if (state.phase !== 'ready') {
|
|
303
304
|
throw new CliError(`the local runtime did not start: ${state.error ?? state.phase}`, {
|
|
304
305
|
code: 'LOCAL_START',
|
|
305
|
-
|
|
306
|
+
// Keys a restart can't bring back: the error carries its fix, a reset.
|
|
307
|
+
hint: isKeyCustodianProblem(state.error) ? KEY_CUSTODIAN_FIX : 'see `frontera local logs <component>`; fix the cause and run `frontera local start` again',
|
|
306
308
|
})
|
|
307
309
|
}
|
|
308
310
|
const profile = await registerLocalProfile(project, state)
|
|
@@ -411,6 +413,8 @@ async function status(ctx: CommandContext) {
|
|
|
411
413
|
const stuck = (state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await stuckKnowledge(`http://127.0.0.1:${state.ports.service}`, state.credentials, readBaseline(project, state.credentials.organizationId)?.restoredAt) : null) ?? []
|
|
412
414
|
// What the runtime holds with no file in the project (an Agent whose folder was deleted): said with its fix.
|
|
413
415
|
const orphans = (state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await projectOrphans(project, `http://127.0.0.1:${state.ports.service}`, state.credentials).catch(() => null) : null) ?? []
|
|
416
|
+
// Encryption keys the runtime can't read (data keys a hosted OpenBao locked): every secret and governed Action record fails until a reset.
|
|
417
|
+
const keyProblem = state.phase === 'ready' && isAlive(state.supervisorPid) ? await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`) : null
|
|
414
418
|
return {
|
|
415
419
|
data: {
|
|
416
420
|
...rest,
|
|
@@ -431,9 +435,11 @@ async function status(ctx: CommandContext) {
|
|
|
431
435
|
...(stuck.length ? { knowledgeStuck: stuck } : {}),
|
|
432
436
|
// On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).
|
|
433
437
|
...(orphans.length ? { notInProject: orphans } : {}),
|
|
438
|
+
...(keyProblem ? { keyCustodian: { problem: keyProblem, fix: KEY_CUSTODIAN_FIX } } : {}),
|
|
434
439
|
},
|
|
435
440
|
text: [
|
|
436
441
|
renderStatus({ ...state, components }, project, undefined, live, setIds, personas, secrets, liveApps ?? null, down),
|
|
442
|
+
...(keyProblem ? ['', 'Encryption keys', ` ✕ ${keyProblem}`] : []),
|
|
437
443
|
...(stuck.length ? ['', 'Knowledge', ...stuck.map((k) => ` warning: ${k.problem}`)] : []),
|
|
438
444
|
...(orphans.length ? ['', 'On the runtime, not in the project', ...orphans.map((o) => ` warning: ${orphanLine(o)}`)] : []),
|
|
439
445
|
...(devServers.length ? ['', 'App dev servers', ...devServers.map((s) => ` ${devServerLine(s)}`)] : []),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CliError, UsageError } from '../../errors'
|
|
2
2
|
import { appsLostWords } from '../../local/local-app-restore'
|
|
3
3
|
import { BASELINE_DB, readBaseline, restoreBaseline, saveBaseline, secretsWords } from '../../local/local-db-snapshot'
|
|
4
|
+
import { historyWords } from '../../local/local-history-carry'
|
|
4
5
|
import { localIngestApi, settleKnowledge } from '../../local/local-knowledge-ingestion'
|
|
5
6
|
import { localProject } from '../../local/local-project'
|
|
6
7
|
import { projectSecretLines } from '../../local/local-project-secrets'
|
|
@@ -15,7 +16,8 @@ import { flagBool, type Command } from '../types'
|
|
|
15
16
|
* same, by hand: save after setting up data to come back to, restore to throw
|
|
16
17
|
* away what was written since. The catalog of managed datasets, object
|
|
17
18
|
* storage and local traces are not part of it. Workspace secrets are
|
|
18
|
-
* configuration, not data
|
|
19
|
+
* configuration, not data, and the run history (conversations, runs,
|
|
20
|
+
* evaluations) is what happened: a restore keeps both as they are now, unless
|
|
19
21
|
* `--exact` puts the baseline's back too.
|
|
20
22
|
*/
|
|
21
23
|
|
|
@@ -31,6 +33,7 @@ export const localSnapshot: Command = {
|
|
|
31
33
|
details: [
|
|
32
34
|
'save keeps the database as it is now as the baseline, replacing the one before. restore puts the database back as the baseline holds it; the service, job engine and runner keep running and reconnect, and the command returns once the service reaches the database again (it waits up to 30 seconds).',
|
|
33
35
|
'Workspace secrets are configuration, not data: restore keeps them as they are now (set, changed or deleted since the baseline), and `local check` keeps them across its rollback too. `--exact` puts the baseline\'s secrets back as well.',
|
|
36
|
+
'The run history is kept the same way: conversations (an Agent test case\'s included), Agent sessions and runs, workflow and Function runs and evaluation runs are as they were before the restore, so results can be compared across runs; the records those runs wrote are rolled back. A history row that names a record the restore took away is left out and counted. `--exact` puts the baseline\'s history back as well.',
|
|
34
37
|
'Items applied after the baseline count as not applied once it is restored: the next `frontera local dev` applies them again (a running one does so at once).',
|
|
35
38
|
'Knowledge documents are read by a background job. save waits (up to two minutes) until no document is still being read, so the baseline never holds one half-read; restore queues any document the baseline holds unread to be read again, and waits for it.',
|
|
36
39
|
],
|
|
@@ -64,7 +67,7 @@ export const localSnapshot: Command = {
|
|
|
64
67
|
}
|
|
65
68
|
const exact = flagBool(ctx, 'exact')
|
|
66
69
|
const restored = await ctx.output.task('Restoring the database from the baseline', () =>
|
|
67
|
-
restoreBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, { secrets: exact ? 'baseline' : 'keep' }),
|
|
70
|
+
restoreBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, { secrets: exact ? 'baseline' : 'keep', history: exact ? 'baseline' : 'keep' }),
|
|
68
71
|
)
|
|
69
72
|
if (!restored.ok) throw new CliError(restored.note, { code: 'LOCAL_SNAPSHOT_FAILED', hint: 'see `frontera local logs postgres` and `frontera local logs service`' })
|
|
70
73
|
// The documents the baseline holds unread are read again: their reading went with the database the restore replaced.
|
|
@@ -85,6 +88,8 @@ export const localSnapshot: Command = {
|
|
|
85
88
|
...(lost.length ? { appsLost: lost.map((a) => ({ app: a.name, dir: a.dir, deployment: a.lost, baselineDeployment: a.back })) } : {}),
|
|
86
89
|
// Names and counts only: what became of the workspace secrets.
|
|
87
90
|
workspaceSecrets: restored.secrets?.mode === 'keep' ? { kept: restored.secrets.ok, count: restored.secrets.kept, setAgain: restored.secrets.notKept, ...(restored.secrets.problem ? { problem: restored.secrets.problem } : {}) } : { kept: false, exact: exact },
|
|
91
|
+
// Counts only: what became of the run history.
|
|
92
|
+
runHistory: restored.history?.mode === 'keep' ? { kept: restored.history.ok, rows: restored.history.rows, notKept: restored.history.notKept, ...(restored.history.problem ? { problem: restored.history.problem } : {}) } : { kept: false, exact },
|
|
88
93
|
...(secrets.length ? { secrets } : {}),
|
|
89
94
|
...(knowledge.length ? { knowledge: knowledge.map((l) => l.replace(/^warning:\s*/, '')) } : {}),
|
|
90
95
|
},
|
|
@@ -92,6 +97,7 @@ export const localSnapshot: Command = {
|
|
|
92
97
|
`Restored the database from the baseline: what was written since is gone (${restored.ms} ms).${items ? ` ${items} item${items === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`.` : ''}`,
|
|
93
98
|
...(lost.length ? [` ${appsLostWords(lost).replace(/^./, (c) => c.toUpperCase())}.`] : []),
|
|
94
99
|
...(restored.secrets ? [` ${secretsWords(restored.secrets).replace(/^./, (c) => c.toUpperCase())}${restored.secrets.mode === 'keep' && restored.secrets.ok && !restored.secrets.notKept.length ? ': secrets are configuration, not test data (`--exact` restores the baseline’s).' : '.'}`] : []),
|
|
100
|
+
...(restored.history ? [` ${historyWords(restored.history).replace(/^./, (c) => c.toUpperCase())}.`] : []),
|
|
95
101
|
...secrets.map((l) => ` warning: ${l}`),
|
|
96
102
|
...knowledge.map((l) => ` ${l}`),
|
|
97
103
|
].join('\n'),
|
|
@@ -12,8 +12,10 @@ import { adoptProposedLessons } from '../../local/local-lessons'
|
|
|
12
12
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
13
13
|
import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
14
14
|
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
15
|
-
import { getSyncKey, readLink, type SyncLink } from '../../local/local-sync-link'
|
|
15
|
+
import { getSyncKey, readLink, setModelMapping, type SyncLink } from '../../local/local-sync-link'
|
|
16
16
|
import { itemTitle } from '../../local/local-sync-names'
|
|
17
|
+
import { checkBody, markMissingModels, markRefused, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
18
|
+
import { rows } from '../../local/local-sync-remote'
|
|
17
19
|
import { evalsDetail, evalsLine, lastRender, sessionScreenshots } from '../../local/local-request-evidence'
|
|
18
20
|
import { readAppliedRun, readLastRun, recordApplied, recordRun } from '../../local/local-test-baseline'
|
|
19
21
|
import { agentFilesProblem, runCases, selectCaseFiles } from '../../local/local-test-runner'
|
|
@@ -330,6 +332,9 @@ function itemJson(item: PushItem) {
|
|
|
330
332
|
proposable: item.proposable,
|
|
331
333
|
blocked: item.blocked,
|
|
332
334
|
reason: item.reason,
|
|
335
|
+
...(item.fix ? { fix: item.fix } : {}),
|
|
336
|
+
...(item.sample ? { sample: true } : {}),
|
|
337
|
+
...(item.missingModels?.length ? { missingModels: item.missingModels } : {}),
|
|
333
338
|
files: item.diff,
|
|
334
339
|
notes: item.notes,
|
|
335
340
|
...(item.assets ? { assets: item.assets.map((a) => ({ path: a.path, bytes: a.bytes, change: a.change })) } : {}),
|
|
@@ -404,8 +409,41 @@ async function deletionUsers(project: LocalProject, link: SyncLink, items: PushI
|
|
|
404
409
|
}
|
|
405
410
|
}
|
|
406
411
|
|
|
412
|
+
/**
|
|
413
|
+
* The workspace's checks for what would be proposed, run before anything is
|
|
414
|
+
* sent (`POST /v1/change-requests/check`); each item they refuse is blocked
|
|
415
|
+
* with its reason and fix. A platform without the check leaves them to
|
|
416
|
+
* Propose: `checked` says which.
|
|
417
|
+
*/
|
|
418
|
+
async function precheck(api: Platform, items: PushItem[]): Promise<{ checked: boolean; refused: string[]; problem?: string }> {
|
|
419
|
+
const body = checkBody(items)
|
|
420
|
+
if (!body.length) return { checked: true, refused: [] }
|
|
421
|
+
try {
|
|
422
|
+
const answer = await api.call<{ checks?: unknown[] }>('POST', '/v1/change-requests/check', { items: body }, { timeoutMs: 120_000 })
|
|
423
|
+
return { checked: true, refused: markRefused(items, checksOf(answer)) }
|
|
424
|
+
} catch (err) {
|
|
425
|
+
if (err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'SYNC_CHECKS')) {
|
|
426
|
+
// An older platform has no check; a refusal of the check itself names the items as the push would.
|
|
427
|
+
if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)) }
|
|
428
|
+
return { checked: false, refused: [] }
|
|
429
|
+
}
|
|
430
|
+
return { checked: false, refused: [], problem: (err as Error).message.split('\n')[0] }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The workspace's chat models, offered in place of one only this computer has; empty when the platform can't list them. */
|
|
435
|
+
async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
|
|
436
|
+
try {
|
|
437
|
+
const answer = await api.call<unknown>('GET', '/v1/sync/models')
|
|
438
|
+
return rows<{ slug?: string; name?: string; kind?: string }>(answer).flatMap((m) => (m.slug && (m.kind ?? 'text') === 'text' ? [{ slug: m.slug, name: m.name ?? m.slug, kind: m.kind ?? 'text' }] : []))
|
|
439
|
+
} catch {
|
|
440
|
+
return []
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
407
444
|
function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map()): string {
|
|
408
|
-
const ok = items.filter((i) => i.proposable)
|
|
445
|
+
const ok = items.filter((i) => i.proposable && !i.sample)
|
|
446
|
+
const samples = items.filter((i) => i.proposable && i.sample)
|
|
409
447
|
const not = items.filter((i) => !i.proposable)
|
|
410
448
|
const name = link.workspaceName ?? link.workspace
|
|
411
449
|
const lines = [
|
|
@@ -413,7 +451,8 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
|
|
|
413
451
|
// Each item's notes under it.
|
|
414
452
|
...ok.flatMap((i) => [` [x] ${pushItemLine(i)}`, ...(usedBy.has(i.ref) ? [` ! Still used by ${usedBy.get(i.ref)!.join(', ')}: applying would be refused`] : []), ...i.notes.map((n) => ` · ${n}`)]),
|
|
415
453
|
]
|
|
416
|
-
if (
|
|
454
|
+
if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
|
|
455
|
+
if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
|
|
417
456
|
if (orgNote) lines.push('', orgNote)
|
|
418
457
|
if (!available) lines.push('', 'This platform does not take change requests yet.')
|
|
419
458
|
if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
|
|
@@ -443,6 +482,36 @@ async function releaseItems(api: Platform, asked: Array<{ slug: string; deployme
|
|
|
443
482
|
return out
|
|
444
483
|
}
|
|
445
484
|
|
|
485
|
+
/**
|
|
486
|
+
* `frontera local sync map-model <local model> <workspace model>`: the
|
|
487
|
+
* workspace's model a model only this computer has goes as when proposed
|
|
488
|
+
* (`sync.models` in frontera.yaml); the files keep naming the local one.
|
|
489
|
+
* `--remove` stops mapping it; with no model, the mappings are listed.
|
|
490
|
+
*/
|
|
491
|
+
export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
492
|
+
const project = localProject(ctx.cwd)
|
|
493
|
+
const link = needLink(project)
|
|
494
|
+
const [local, workspace] = ctx.positional.slice(1)
|
|
495
|
+
const name = link.workspaceName ?? link.workspace
|
|
496
|
+
if (!local) {
|
|
497
|
+
const models = link.models ?? {}
|
|
498
|
+
const lines = Object.entries(models).map(([from, to]) => ` ${from} → ${to}`)
|
|
499
|
+
return { data: { models }, text: lines.length ? [`Models proposed to ${name} as the workspace’s:`, ...lines].join('\n') : `No models mapped. \`frontera local sync push --preview\` names any model ${name} doesn’t have.` }
|
|
500
|
+
}
|
|
501
|
+
if (flagBool(ctx, 'remove')) {
|
|
502
|
+
setModelMapping(project.root, local, null)
|
|
503
|
+
return { data: { local, workspace: null }, text: `${local} is no longer mapped: an item that names it isn’t proposed until it is mapped again.` }
|
|
504
|
+
}
|
|
505
|
+
if (!workspace) throw new UsageError('name the workspace model it goes as', `frontera local sync map-model ${local} <workspace model>; \`push --preview\` lists ${name}’s models`)
|
|
506
|
+
const offered = await workspaceModels(platformFor(project, link))
|
|
507
|
+
if (offered.length && !offered.some((m) => m.slug === workspace)) {
|
|
508
|
+
throw new UsageError(`${name} has no chat model ${workspace}`, `choose one of: ${offered.map((m) => m.slug).slice(0, 12).join(', ')}${offered.length > 12 ? ', …' : ''}`)
|
|
509
|
+
}
|
|
510
|
+
setModelMapping(project.root, local, workspace)
|
|
511
|
+
const label = offered.find((m) => m.slug === workspace)?.name ?? workspace
|
|
512
|
+
return { data: { local, workspace }, text: `${local} goes to ${name} as ${label} when proposed; the project keeps using ${local} here (sync.models in frontera.yaml).` }
|
|
513
|
+
}
|
|
514
|
+
|
|
446
515
|
export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
447
516
|
const project = localProject(ctx.cwd)
|
|
448
517
|
const link = needLink(project)
|
|
@@ -462,6 +531,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
462
531
|
? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(platformFor(project, link), documentNames, remote.items) }
|
|
463
532
|
: undefined
|
|
464
533
|
const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, ...(documents ? { documents } : {}) })
|
|
534
|
+
// Before anything is sent: models only this computer has, then the workspace's own checks.
|
|
535
|
+
const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
|
|
536
|
+
const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
|
|
537
|
+
const api = platformFor(project, link)
|
|
538
|
+
const checked = available ? await precheck(api, all) : { checked: false, refused: [] as string[] }
|
|
465
539
|
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
466
540
|
const updating = kept && isOpen(kept.state) ? kept : null
|
|
467
541
|
const orgStored = !!getSyncKey(project, 'organization')
|
|
@@ -474,6 +548,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
474
548
|
if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`, 'list what can be proposed with `frontera local sync push --preview`')
|
|
475
549
|
chosen = named.map((r) => byRef.get(r)!)
|
|
476
550
|
} else chosen = all
|
|
551
|
+
// Samples from setting up the project go only when named.
|
|
552
|
+
const leftSamples = named.length ? [] : chosen.filter((i) => i.proposable && i.sample)
|
|
477
553
|
const orgNote = needsOrganizationKey(chosen) ? (orgStored ? 'Blueprint changes are applied with the organization key stored for this project.' : 'Blueprint changes need the organization key to apply: connect again with `--org-key -` before applying.') : null
|
|
478
554
|
|
|
479
555
|
if (preview) {
|
|
@@ -484,7 +560,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
484
560
|
available,
|
|
485
561
|
request: updating ? { id: updating.id, title: updating.title, state: updating.state, items: updating.refs.length, refs: updating.refs } : null,
|
|
486
562
|
items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
|
|
487
|
-
counts: { proposable: chosen.filter((i) => i.proposable).length, blocked: chosen.filter((i) => !i.proposable).length },
|
|
563
|
+
counts: { proposable: chosen.filter((i) => i.proposable && !i.sample).length, samples: leftSamples.length, blocked: chosen.filter((i) => !i.proposable).length },
|
|
564
|
+
// Whether the workspace's checks ran before proposing; a platform without them checks at Propose.
|
|
565
|
+
checked: checked.checked,
|
|
566
|
+
...(('problem' in checked && checked.problem) ? { checkProblem: checked.problem } : {}),
|
|
567
|
+
models: {
|
|
568
|
+
missing: [...missing].map(([slug, refs]) => ({ slug, refs })),
|
|
569
|
+
workspace: missing.size ? await workspaceModels(api) : [],
|
|
570
|
+
mapped: link.models ?? {},
|
|
571
|
+
},
|
|
488
572
|
organizationKey: { needed: needsOrganizationKey(chosen), stored: orgStored },
|
|
489
573
|
fetchedAt: snapshot.fetchedAt,
|
|
490
574
|
},
|
|
@@ -503,12 +587,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
503
587
|
details: { blocked: stopping.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })) },
|
|
504
588
|
})
|
|
505
589
|
}
|
|
506
|
-
const going = chosen.filter((i) => i.proposable)
|
|
590
|
+
const going = chosen.filter((i) => i.proposable && !leftSamples.includes(i))
|
|
507
591
|
const releaseAsks = parseReleases(flagString(ctx, 'release'))
|
|
508
592
|
if (!going.length && !releaseAsks.length) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
|
|
509
593
|
const title = flagString(ctx, 'title')?.trim()
|
|
510
594
|
const description = flagString(ctx, 'description')
|
|
511
|
-
const api = platformFor(project, link)
|
|
512
595
|
// The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
|
|
513
596
|
let target = updating
|
|
514
597
|
if (target) {
|
|
@@ -554,14 +637,16 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
554
637
|
request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
|
|
555
638
|
pushed: [...going.map((i) => i.ref), ...releases.map((r) => r.ref)],
|
|
556
639
|
releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
|
|
557
|
-
notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })),
|
|
640
|
+
notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
|
|
641
|
+
samples: leftSamples.map((i) => i.ref),
|
|
558
642
|
organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
|
|
559
643
|
},
|
|
560
644
|
text: [
|
|
561
645
|
`${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
|
|
562
646
|
...going.map((i) => ` ${pushItemLine(i)}`),
|
|
563
647
|
...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
|
|
564
|
-
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.
|
|
648
|
+
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
|
|
649
|
+
...(leftSamples.length ? ['', `Left out: ${plural(leftSamples.length, 'sample')} from setting up the project (${leftSamples.map((i) => i.title).join(', ')}); name ${leftSamples.length === 1 ? 'it' : 'them'} to propose`] : []),
|
|
565
650
|
...(orgNote ? ['', orgNote] : []),
|
|
566
651
|
'',
|
|
567
652
|
'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
|
|
@@ -812,7 +897,8 @@ async function apply(ctx: CommandContext) {
|
|
|
812
897
|
if (force && !reason) throw new UsageError('--force needs --reason', 'frontera local sync request apply --force --reason "why it applies without its required checks"')
|
|
813
898
|
let answer: View
|
|
814
899
|
try {
|
|
815
|
-
|
|
900
|
+
// The key in the body: the platform's edge strips `x-frontera-*` headers a client sends. The header stays for an older platform.
|
|
901
|
+
answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}), ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
|
|
816
902
|
} catch (err) {
|
|
817
903
|
if (err instanceof CliError && err.code === 'SYNC_APPLY_GATED') {
|
|
818
904
|
const gate = (err.details as { gate?: Gate } | undefined)?.gate
|
|
@@ -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
|
/**
|
|
@@ -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}` }
|