@frontera-sdk/cli 1.52.44 → 1.52.46
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/api/apps-api.ts +3 -1
- package/src/api/automation-api.ts +32 -0
- package/src/api/credential-failure.ts +1 -1
- package/src/api/governed-action-api.ts +24 -0
- package/src/api/media-api.ts +24 -0
- package/src/api/platform-api.ts +13 -0
- package/src/binding-registry.ts +9 -2
- package/src/blueprint/compile.ts +3 -1
- package/src/blueprint/ontology-edit-plan.ts +71 -4
- package/src/blueprint/org-name-check.ts +64 -0
- package/src/blueprint/tree.ts +28 -0
- package/src/commands/action/deploy.ts +22 -5
- package/src/commands/action/index-commands.ts +2 -0
- package/src/commands/action/retire.ts +60 -0
- package/src/commands/agent/compose.ts +1 -0
- package/src/commands/agent/index-commands.ts +42 -0
- package/src/commands/app/deploy.ts +1 -1
- package/src/commands/app/dev.ts +2 -2
- package/src/commands/app/save.ts +1 -1
- package/src/commands/auth/add.ts +5 -1
- package/src/commands/auth/current.ts +6 -0
- package/src/commands/auth/index-commands.ts +3 -0
- package/src/commands/auth/login.ts +84 -0
- package/src/commands/auth/use.ts +21 -2
- package/src/commands/automation/bindings.ts +54 -10
- package/src/commands/automation/index-commands.ts +2 -3
- package/src/commands/blueprint/bind.ts +12 -1
- package/src/commands/blueprint/declarative.ts +6 -2
- package/src/commands/conversation/index-commands.ts +66 -0
- package/src/commands/conversation/local-runtime.ts +18 -0
- package/src/commands/kit/vendor.ts +62 -48
- package/src/commands/local/check.ts +57 -11
- package/src/commands/local/data.ts +75 -7
- package/src/commands/local/dev.ts +77 -9
- package/src/commands/local/embeddings.ts +74 -0
- package/src/commands/local/index-commands.ts +133 -11
- package/src/commands/local/init.ts +22 -2
- package/src/commands/local/new.ts +78 -0
- package/src/commands/local/publish.ts +34 -0
- package/src/commands/local/steps.ts +105 -0
- package/src/commands/local/sync-request.ts +69 -23
- package/src/commands/local/sync.ts +2 -0
- package/src/commands/media/index-commands.ts +58 -3
- package/src/commands/registry.ts +11 -3
- package/src/commands/trace/index-commands.ts +56 -0
- package/src/commands/workspace/index-commands.ts +14 -7
- package/src/config.ts +4 -1
- package/src/dev-broker.ts +15 -1
- package/src/device-login.ts +143 -0
- package/src/exit.ts +36 -8
- package/src/flag-help.ts +31 -0
- package/src/forge/claude-adapter.ts +17 -4
- package/src/forge/permission-rules.ts +158 -0
- package/src/forge/touches.ts +7 -6
- package/src/function-outside-imports.ts +13 -2
- package/src/function-source.ts +90 -0
- package/src/harness.ts +4 -4
- package/src/key-reach.ts +64 -0
- package/src/local/local-actions.ts +15 -3
- package/src/local/local-apply-error.ts +26 -0
- package/src/local/local-apps.ts +9 -3
- package/src/local/local-blueprint-schemas.ts +15 -1
- package/src/local/local-confidential.ts +81 -0
- package/src/local/local-data-load.ts +130 -0
- package/src/local/local-dev-references.ts +24 -4
- package/src/local/local-embedding-setup.ts +62 -0
- package/src/local/local-jobs.ts +108 -0
- package/src/local/local-keys.ts +5 -3
- package/src/local/local-new.ts +148 -0
- package/src/local/local-org-settings.ts +1 -1
- package/src/local/local-person-steps.ts +340 -0
- package/src/local/local-personas.ts +16 -5
- package/src/local/local-portability.ts +22 -1
- package/src/local/local-publish.ts +26 -0
- package/src/local/local-request-evidence.ts +35 -6
- package/src/local/local-runtime-history.ts +4 -3
- package/src/local/local-said-once.ts +51 -0
- package/src/local/local-scaffold.ts +40 -2
- package/src/local/local-schemas.ts +60 -11
- package/src/local/local-seed.ts +1 -1
- package/src/local/local-stand-ins.ts +44 -0
- package/src/local/local-studio-server.ts +13 -7
- package/src/local/local-supervisor.ts +22 -0
- package/src/local/local-sync-models.ts +5 -2
- package/src/local/local-sync-names.ts +3 -0
- package/src/local/local-sync-portable.ts +64 -15
- package/src/local/local-sync-precheck.ts +30 -1
- package/src/local/local-sync-push.ts +81 -8
- package/src/local/local-sync-remote.ts +10 -7
- package/src/local/local-test-models.ts +173 -0
- package/src/local/local-test-runner.ts +232 -25
- package/src/local/local-test-scope.ts +174 -0
- package/src/local/local-ungranted-reads.ts +55 -0
- package/src/local/local-writers.ts +171 -0
- package/src/main.ts +75 -17
- package/src/mcp/browser/ax-snapshot.ts +13 -1
- package/src/mcp/browser/page-scripts.ts +1 -0
- package/src/mcp/mcp-conversations.ts +171 -0
- package/src/mcp/mcp-server.ts +4 -1
- package/src/mcp/mcp-traces.ts +7 -2
- package/src/mcp/tools/agent-tools.ts +94 -2
- package/src/mcp/tools/project-tools.ts +149 -13
- package/src/mcp/tools/test-tools.ts +64 -13
- package/src/output.ts +22 -7
- package/src/pack.ts +16 -0
- package/src/project-context.ts +18 -5
- package/src/project.ts +4 -0
- package/src/remote-guard.ts +52 -0
- package/src/vendor/kit-assets.json +67 -6
|
@@ -16,7 +16,7 @@ export interface EvalsDetail {
|
|
|
16
16
|
gated: number
|
|
17
17
|
passed: number
|
|
18
18
|
failed: number
|
|
19
|
-
newlyFailing: Array<{ name: string; file: string; reason: string | null }>
|
|
19
|
+
newlyFailing: Array<{ name: string; file: string; reason: string | null; environment?: string }>
|
|
20
20
|
/**
|
|
21
21
|
* Cases that couldn't run (the model provider refused them, the runtime did
|
|
22
22
|
* not answer): evidence with any is not attached, since none newly failing
|
|
@@ -30,6 +30,8 @@ export interface EvalsDetail {
|
|
|
30
30
|
baselinePassRate: number | null
|
|
31
31
|
efficiency: { latencyMs: number | null; toolCalls: number | null; baseline: { latencyMs: number | null; toolCalls: number | null } | null }
|
|
32
32
|
judged: { ran: boolean; reason: string | null }
|
|
33
|
+
/** With `--repeat`: how many times each case ran, and the cases that passed some runs and failed others. */
|
|
34
|
+
repeat?: { runs: number; flaky: string[] }
|
|
33
35
|
ranAt: string
|
|
34
36
|
/** Where they ran. */
|
|
35
37
|
runtime: string
|
|
@@ -46,16 +48,39 @@ const mean = (values: Array<number | undefined>): number | null => {
|
|
|
46
48
|
* (newly failing cases), the pass rate either side, and efficiency per case,
|
|
47
49
|
* which is reported and never gates.
|
|
48
50
|
*/
|
|
49
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Why a case that failed here may pass in the workspace (X2, T12 step 2): it
|
|
53
|
+
* called a stand-in Plugin, which answers "not connected" on the local
|
|
54
|
+
* runtime and is the real connection in the target. Still a failure; the
|
|
55
|
+
* label says where it comes from.
|
|
56
|
+
*/
|
|
57
|
+
export function environmentOnly(result: Pick<CaseResult, 'toolNames'>, standIns: readonly string[]): string | null {
|
|
58
|
+
const used = standIns.filter((install) => (result.toolNames ?? []).some((t) => t.startsWith(`mcp__${install}__`)))
|
|
59
|
+
return used.length ? `local environment: ${used.join(', ')} ${used.length === 1 ? 'is a stand-in' : 'are stand-ins'} here (present in the target)` : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The stand-in Plugins the project declares (`mcp/<install>.stand-in.json`), by install name. */
|
|
63
|
+
export function standInInstalls(root: string): string[] {
|
|
64
|
+
const dir = join(root, 'mcp')
|
|
65
|
+
return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.stand-in.json')).map((f) => f.slice(0, -'.stand-in.json'.length)).sort() : []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function evalsDetail(input: { files: CaseFile[]; results: CaseResult[]; baseline: LastRun; runtime: string; now?: Date; standIns?: readonly string[] }): EvalsDetail {
|
|
50
69
|
const { results, baseline } = input
|
|
51
70
|
const compared = compareWithLastRun(results, baseline)
|
|
52
71
|
const before = results.map((r) => baseline[caseKey(r)]).filter((b) => b !== undefined)
|
|
53
72
|
const judgedCases = input.files.some((f) => f.cases.some((c) => c.judged?.length))
|
|
73
|
+
// Judged expectations are graded by the runtime's judge (X1); when it couldn't grade some, that is said.
|
|
74
|
+
const ungraded = results.filter((r) => r.judgedNotGraded)
|
|
75
|
+
const runs = Math.max(1, ...results.map((r) => r.repeat?.runs ?? 1))
|
|
54
76
|
return {
|
|
55
77
|
gated: results.length,
|
|
56
78
|
passed: results.filter((r) => r.passed).length,
|
|
57
79
|
failed: results.filter((r) => !r.passed && !r.notRun).length,
|
|
58
|
-
newlyFailing: compared.newlyFailing.map((r) =>
|
|
80
|
+
newlyFailing: compared.newlyFailing.map((r) => {
|
|
81
|
+
const environment = environmentOnly(r, input.standIns ?? [])
|
|
82
|
+
return { name: r.name, file: r.file, reason: r.reason ?? null, ...(environment ? { environment } : {}) }
|
|
83
|
+
}),
|
|
59
84
|
couldNotRun: compared.couldNotRun.map((r) => ({ name: r.name, what: r.notRun!.what, fix: r.notRun!.fix })),
|
|
60
85
|
fixed: compared.fixed.map((r) => r.name),
|
|
61
86
|
stillFailing: compared.stillFailing.map((r) => r.name),
|
|
@@ -67,8 +92,12 @@ export function evalsDetail(input: { files: CaseFile[]; results: CaseResult[]; b
|
|
|
67
92
|
toolCalls: mean(results.map((r) => r.toolCalls)),
|
|
68
93
|
baseline: before.length ? { latencyMs: mean(before.map((b) => b!.ms)), toolCalls: mean(before.map((b) => b!.toolCalls)) } : null,
|
|
69
94
|
},
|
|
70
|
-
|
|
71
|
-
|
|
95
|
+
judged: !judgedCases
|
|
96
|
+
? { ran: false, reason: null }
|
|
97
|
+
: ungraded.length
|
|
98
|
+
? { ran: false, reason: `the local runtime didn't grade ${ungraded.length} case${ungraded.length === 1 ? '’s' : 's’'} judged expectations: ${ungraded[0]!.judgedNotGraded}` }
|
|
99
|
+
: { ran: true, reason: null },
|
|
100
|
+
...(runs > 1 ? { repeat: { runs, flaky: results.filter((r) => r.repeat?.flaky).map((r) => r.name) } } : {}),
|
|
72
101
|
ranAt: (input.now ?? new Date()).toISOString(),
|
|
73
102
|
runtime: input.runtime,
|
|
74
103
|
}
|
|
@@ -79,7 +108,7 @@ export function evalsLine(title: string, d: EvalsDetail): string {
|
|
|
79
108
|
if (!d.gated) return `${title}: no gated cases (mark the ones that guard it with gate: true)`
|
|
80
109
|
if (d.couldNotRun.length) return `${title}: ${d.couldNotRun.length} of ${d.gated} gated case${d.gated === 1 ? '' : 's'} couldn’t run: ${[...new Set(d.couldNotRun.map((c) => c.what))].join('; ')}; ${d.couldNotRun[0]!.fix}`
|
|
81
110
|
const rate = d.passRate === null ? '' : `, ${Math.round(d.passRate * 100)}% pass${d.baselinePassRate === null ? '' : ` (was ${Math.round(d.baselinePassRate * 100)}%)`}`
|
|
82
|
-
return `${title}: ${d.gated} gated case${d.gated === 1 ? '' : 's'}, ${d.newlyFailing.length ? `${d.newlyFailing.length} newly failing (${d.newlyFailing.map((c) => c.name).join(', ')})` : 'none newly failing'}${rate}`
|
|
111
|
+
return `${title}: ${d.gated} gated case${d.gated === 1 ? '' : 's'}, ${d.newlyFailing.length ? `${d.newlyFailing.length} newly failing (${d.newlyFailing.map((c) => (c.environment ? `${c.name}: ${c.environment}` : c.name)).join(', ')})` : 'none newly failing'}${rate}`
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
/** The newest pictures a session saved (`.frontera/evidence/<session>/`), newest first, at most `limit`. */
|
|
@@ -13,12 +13,13 @@ import type { LocalProject } from './local-project'
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export interface RuntimeRecord {
|
|
16
|
-
|
|
16
|
+
/** `jobs-restarted`: the jobs engine alone was restarted (by `local restart jobs`, or the supervisor when it stopped answering). */
|
|
17
|
+
event: 'started' | 'stopped' | 'failed' | 'jobs-restarted'
|
|
17
18
|
/** When it happened: a start when the runtime was ready, a stop when it had stopped. */
|
|
18
19
|
at: string
|
|
19
20
|
/** A start: when it began starting, so its duration can be said. */
|
|
20
21
|
since?: string
|
|
21
|
-
/** A failed start: which part, and why. */
|
|
22
|
+
/** A failed start: which part, and why. A jobs restart: why, and the runs it lost. */
|
|
22
23
|
detail?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -32,7 +33,7 @@ export function readRuntimeHistory(stateDir: string): RuntimeRecord[] {
|
|
|
32
33
|
try {
|
|
33
34
|
const list = JSON.parse(readFileSync(historyPath(stateDir), 'utf8')) as unknown
|
|
34
35
|
return Array.isArray(list)
|
|
35
|
-
? list.filter((r): r is RuntimeRecord => !!r && typeof (r as RuntimeRecord).at === 'string' && ['started', 'stopped', 'failed'].includes((r as RuntimeRecord).event))
|
|
36
|
+
? list.filter((r): r is RuntimeRecord => !!r && typeof (r as RuntimeRecord).at === 'string' && ['started', 'stopped', 'failed', 'jobs-restarted'].includes((r as RuntimeRecord).event))
|
|
36
37
|
: []
|
|
37
38
|
} catch {
|
|
38
39
|
return []
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
6
|
+
import type { LocalProject } from './local-project'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lists `local dev` says on every apply, said in full once per runtime start
|
|
10
|
+
* and as one line after that while they stay the same (Forge improvement
|
|
11
|
+
* plan N9): the settings that differ from the workspace, the failures the
|
|
12
|
+
* pull brought. In one build the same 8-line settings list and the same
|
|
13
|
+
* unfixable failure were printed on each of about 30 applies.
|
|
14
|
+
*
|
|
15
|
+
* Kept in `.frontera/local/said.json`: each list's digest and when it was
|
|
16
|
+
* last said in full.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type Said = Record<string, { digest: string; at: string }>
|
|
20
|
+
|
|
21
|
+
function path(project: LocalProject): string {
|
|
22
|
+
return join(project.stateDir, 'said.json')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function read(project: LocalProject): Said {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(readFileSync(path(project), 'utf8')) as unknown
|
|
28
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Said) : {}
|
|
29
|
+
} catch {
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether to say this list in full: it changed since it was last said, or
|
|
36
|
+
* was last said before `since` (the runtime's start). Recorded when it is.
|
|
37
|
+
* Never throws: a lost record only means the list is said in full again.
|
|
38
|
+
*/
|
|
39
|
+
export function sayInFull(project: LocalProject, key: string, lines: readonly string[], since: string | undefined, now = new Date()): boolean {
|
|
40
|
+
const digest = createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 16)
|
|
41
|
+
const said = read(project)
|
|
42
|
+
const last = said[key]
|
|
43
|
+
const stale = !last || last.digest !== digest || (since !== undefined && Date.parse(last.at) < Date.parse(since))
|
|
44
|
+
if (!stale) return false
|
|
45
|
+
try {
|
|
46
|
+
writeFileAtomic(path(project), `${JSON.stringify({ ...said, [key]: { digest, at: now.toISOString() } }, null, 2)}\n`, 0o644)
|
|
47
|
+
} catch {
|
|
48
|
+
// Said in full again next time.
|
|
49
|
+
}
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
@@ -449,8 +449,26 @@ the cases that guard an Agent's behavior \`gate: true\`, and run them with
|
|
|
449
449
|
you take are saved under \`.frontera/evidence/<session>/\`, where the
|
|
450
450
|
person can attach them to the request.
|
|
451
451
|
|
|
452
|
-
|
|
453
|
-
|
|
452
|
+
Check every page (an App, a widget, the website it is embedded on) with the
|
|
453
|
+
frontera MCP's \`browser_*\` tools, not another browser plugin: they share the
|
|
454
|
+
project's tabs and sign-ins, and the person watches them in Forge.
|
|
455
|
+
|
|
456
|
+
You cannot promote a deployment, apply a change request, decide for an
|
|
457
|
+
Action, grant or read secrets. Prepare each such step for the person
|
|
458
|
+
instead: the MCP tool \`person_step\` (or \`frontera local steps add\`)
|
|
459
|
+
records the command, why it is needed, what it changes and the credential
|
|
460
|
+
it needs, and Forge shows the delivery's steps as one card the person runs
|
|
461
|
+
from. Prepare them all at once, in order, and never ask the person to paste
|
|
462
|
+
a key, token or command output into the chat.
|
|
463
|
+
|
|
464
|
+
The person may write while you work. Acknowledge each such message in your
|
|
465
|
+
next visible text: say you have it, and whether you act on it now or after
|
|
466
|
+
the step you are on; never let one go unanswered. When what is left is only
|
|
467
|
+
the person's (steps you prepared, a decision, an answer to an open
|
|
468
|
+
question), stop: end with \`Blocked on you:\` and each open item with its
|
|
469
|
+
evidence (the step on the delivery card, what you verified and how). Do not
|
|
470
|
+
retry what only the person can do, and do not call the work done while
|
|
471
|
+
those items are open.
|
|
454
472
|
|
|
455
473
|
The guides describe the product and its concepts, mostly through its
|
|
456
474
|
screens. The exact format of each file is a JSON Schema for the release the
|
|
@@ -562,6 +580,8 @@ export function migrateMcpConfigs(root: string): string[] {
|
|
|
562
580
|
|
|
563
581
|
const GITIGNORE = `node_modules/
|
|
564
582
|
.env
|
|
583
|
+
# A separate browser plugin's output (screenshots of other sites included); the frontera browser keeps its own under .frontera/.
|
|
584
|
+
.playwright-mcp/
|
|
565
585
|
`
|
|
566
586
|
|
|
567
587
|
/**
|
|
@@ -604,6 +624,24 @@ function sampleFiles(name: string, agentModel?: string): Record<string, string>
|
|
|
604
624
|
}
|
|
605
625
|
}
|
|
606
626
|
|
|
627
|
+
/**
|
|
628
|
+
* A new Agent's files, as the sample Agent's (L8, `frontera local new
|
|
629
|
+
* agent`): its agent.json with the name, description and model given, its
|
|
630
|
+
* bindings.json and a starter case. Behavior settings (operating.json) only
|
|
631
|
+
* for an Agent that serves customers: an internal Agent carries none.
|
|
632
|
+
*/
|
|
633
|
+
export function starterAgentFiles(slug: string, opts: { name: string; description?: string; model?: string; customer?: boolean }): Record<string, string> {
|
|
634
|
+
const agent = JSON.parse(AGENT('', opts.model)) as { config: Record<string, unknown> }
|
|
635
|
+
agent.config.name = opts.name
|
|
636
|
+
agent.config.description = opts.description ?? null
|
|
637
|
+
return {
|
|
638
|
+
[`agents/${slug}/agent.json`]: `${JSON.stringify(agent, null, 2)}\n`,
|
|
639
|
+
[`agents/${slug}/bindings.json`]: BINDINGS,
|
|
640
|
+
[`agents/${slug}/evals/regression.yaml`]: EVALS.replace('for the assistant', `for ${opts.name}`),
|
|
641
|
+
...(opts.customer ? { [`agents/${slug}/operating.json`]: OPERATING } : {}),
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
607
645
|
/** The sample Agent as compared: its model and the project's name in its description are set per project. */
|
|
608
646
|
function sampleAgent(text: string): unknown {
|
|
609
647
|
try {
|
|
@@ -33,6 +33,7 @@ export const SCHEMA_KINDS = [
|
|
|
33
33
|
'bindings',
|
|
34
34
|
'theme',
|
|
35
35
|
'chat-app',
|
|
36
|
+
'app',
|
|
36
37
|
'knowledge',
|
|
37
38
|
'skill',
|
|
38
39
|
'blueprint-object-type',
|
|
@@ -58,6 +59,7 @@ export const SCHEMA_FILES: Record<SchemaKind, string> = {
|
|
|
58
59
|
bindings: 'agents/<slug>/bindings.json',
|
|
59
60
|
theme: 'themes/<name>/theme.json',
|
|
60
61
|
'chat-app': 'chat-apps/<slug>.json',
|
|
62
|
+
app: 'apps/<slug>.json',
|
|
61
63
|
knowledge: 'knowledge/<name>/knowledge.json',
|
|
62
64
|
skill: 'skills/<name>/SKILL.md (its front matter)',
|
|
63
65
|
secrets: 'secrets.json',
|
|
@@ -76,13 +78,17 @@ type JsonSchema = Record<string, unknown>
|
|
|
76
78
|
* One check on a case's last reply, by its `type` (`runCheck` in `local-test-runner.ts`).
|
|
77
79
|
* Open to other keys: a check pulled from the platform may carry the platform's own.
|
|
78
80
|
*/
|
|
79
|
-
|
|
81
|
+
/** Which turn a check reads (L3): a person turn's number from 1, `last`, `any` or `all`. */
|
|
82
|
+
const TURN = (what: string) => ({ anyOf: [{ type: 'integer', minimum: 1 }, { enum: ['last', 'any', 'all'] }], description: what })
|
|
83
|
+
const REPLY_TURN = TURN('Which reply the check reads: the last (the default), turn n (a person turn’s number from 1), `any` (one turn’s reply passes) or `all` (every turn’s passes). A turn the case didn’t reach fails the check.')
|
|
84
|
+
|
|
85
|
+
function CHECK(type: 'contains' | 'not_contains' | 'regex' | 'tool_called' | 'tool_not_called'): JsonSchema {
|
|
80
86
|
const text = { type: 'string', description: 'What the check means, in words; shown when it fails.' }
|
|
81
|
-
if (type === 'contains') {
|
|
87
|
+
if (type === 'contains' || type === 'not_contains') {
|
|
82
88
|
return {
|
|
83
|
-
title:
|
|
89
|
+
title: type,
|
|
84
90
|
type: 'object',
|
|
85
|
-
properties: { type: { const:
|
|
91
|
+
properties: { type: { const: type }, value: { type: 'string', minLength: 1, description: type === 'contains' ? 'The phrase the reply must hold, ignoring case. Text: quote a number (`"12"`).' : 'A phrase the reply must not hold, ignoring case.' }, text, turn: REPLY_TURN },
|
|
86
92
|
required: ['type', 'value'],
|
|
87
93
|
}
|
|
88
94
|
}
|
|
@@ -92,9 +98,10 @@ function CHECK(type: 'contains' | 'regex' | 'tool_called' | 'tool_not_called'):
|
|
|
92
98
|
type: 'object',
|
|
93
99
|
properties: {
|
|
94
100
|
type: { const: 'regex' },
|
|
95
|
-
value: { type: 'string', minLength: 1, maxLength: 500, description: 'The pattern the reply must match
|
|
101
|
+
value: { type: 'string', minLength: 1, maxLength: 500, description: 'The pattern the reply must match: a JavaScript regular expression (ECMAScript, as `new RegExp(value, flags)` reads it), at most 500 characters, searched for anywhere in the first 20,000 characters of the reply. A group that is itself quantified, such as `(a+)+`, is refused, here and on the platform. Lookbehind, named groups and `\\b` work; a negative lookahead that never matches passes every reply, so test it against a passing and a failing reply.' },
|
|
96
102
|
flags: { type: 'string', pattern: '^[dgimsuvy]*$', description: 'Regex flags; `i` when absent.' },
|
|
97
103
|
text,
|
|
104
|
+
turn: REPLY_TURN,
|
|
98
105
|
},
|
|
99
106
|
required: ['type', 'value'],
|
|
100
107
|
}
|
|
@@ -105,8 +112,8 @@ function CHECK(type: 'contains' | 'regex' | 'tool_called' | 'tool_not_called'):
|
|
|
105
112
|
properties: {
|
|
106
113
|
type: { const: type },
|
|
107
114
|
tool: { type: 'string', minLength: 1, description: 'The tool’s name as the trace shows it (`function_<slug>` for a Function, `searchKnowledge`; a Plugin’s tool by its capability name, `mcp__…`, never its bare name).' },
|
|
108
|
-
argsSubset: { type: 'object', description: 'The arguments the call must carry.' },
|
|
109
|
-
turn:
|
|
115
|
+
argsSubset: { type: 'object', description: 'The arguments the call must carry. `{ absent: true }` as a value says the call must not carry that argument (`phone: { absent: true }`; an empty value counts as not there).' },
|
|
116
|
+
turn: TURN('Which turns’ calls the check reads: every turn of the case (`all`, the default, or `any`), only the last one (the turn whose reply the case grades), or turn n.'),
|
|
110
117
|
mode: { enum: ['exact', 'ignore', 'subset', 'superset'], description: 'How argsSubset is compared; superset when absent. The `action` narration an Agent may add to any call is left out of the comparison unless argsSubset names it.' },
|
|
111
118
|
text,
|
|
112
119
|
},
|
|
@@ -134,7 +141,7 @@ const EVALS_SCHEMA: JsonSchema = {
|
|
|
134
141
|
conversation: {
|
|
135
142
|
type: 'array',
|
|
136
143
|
minItems: 1,
|
|
137
|
-
description: 'One turn per item. Only the user turns are sent, in order, each as soon as the reply before it ends; an assistant turn is kept for whoever reads the case. `waitSeconds` on a user turn keeps a gap before it (`- { user: "Still there?", waitSeconds: 60 }` sends it a minute after the reply before). A case of more than one turn
|
|
144
|
+
description: 'One turn per item. Only the user turns are sent, in order, each as soon as the reply before it ends; an assistant turn is kept for whoever reads the case. `waitSeconds` on a user turn keeps a gap before it (`- { user: "Still there?", waitSeconds: 60 }` sends it a minute after the reply before). A case of more than one turn goes to a workspace whose evaluation datasets hold conversations, whose runner sends each user turn as soon as the reply before it ends; one with `waitSeconds` runs on this computer only, and an older workspace, which holds single questions, gets only the single-question cases.',
|
|
138
145
|
items: {
|
|
139
146
|
type: 'object',
|
|
140
147
|
properties: {
|
|
@@ -164,12 +171,18 @@ const EVALS_SCHEMA: JsonSchema = {
|
|
|
164
171
|
items: { type: 'string', minLength: 1 },
|
|
165
172
|
description: 'Phrases the last reply must contain, ignoring case. When present, expected is not checked.',
|
|
166
173
|
},
|
|
174
|
+
notContains: {
|
|
175
|
+
type: 'array',
|
|
176
|
+
minItems: 1,
|
|
177
|
+
items: { type: 'string', minLength: 1 },
|
|
178
|
+
description: 'Phrases the last reply must not contain, ignoring case: what the Agent must never say. A check of type not_contains with a `turn` reads another reply.',
|
|
179
|
+
},
|
|
167
180
|
checks: {
|
|
168
181
|
type: 'array',
|
|
169
182
|
description: 'Checks on the last reply and the tools the Agent called, graded as the platform grades them; every one must pass. `type` says which keys a check takes.',
|
|
170
|
-
items: { oneOf: [CHECK('contains'), CHECK('regex'), CHECK('tool_called'), CHECK('tool_not_called')] },
|
|
183
|
+
items: { oneOf: [CHECK('contains'), CHECK('not_contains'), CHECK('regex'), CHECK('tool_called'), CHECK('tool_not_called')] },
|
|
171
184
|
},
|
|
172
|
-
judged: { type: 'array', items: { type: 'string' }, description: 'Expectations
|
|
185
|
+
judged: { type: 'array', items: { type: 'string' }, description: 'Expectations in words, graded by a judge model once the case’s other checks pass: on the platform by its judge, locally by the expectation judge on the grading model (`frontera local model-default <model> --for tests`).' },
|
|
173
186
|
gold: { type: 'boolean', description: 'Human-certified on the platform.' },
|
|
174
187
|
decision: {
|
|
175
188
|
enum: ['approve', 'deny', 'expect'],
|
|
@@ -204,6 +217,7 @@ const EVALS_SCHEMA: JsonSchema = {
|
|
|
204
217
|
{ required: ['conversation', 'expected'] },
|
|
205
218
|
{ required: ['conversation', 'expectedContains'] },
|
|
206
219
|
{ required: ['conversation', 'checks'] },
|
|
220
|
+
{ required: ['conversation', 'notContains'] },
|
|
207
221
|
{ required: ['scenario'] },
|
|
208
222
|
],
|
|
209
223
|
additionalProperties: false,
|
|
@@ -308,7 +322,7 @@ const DATASET_SCHEMA: JsonSchema = {
|
|
|
308
322
|
$schema: DRAFT,
|
|
309
323
|
title: 'Dataset definition (datasets/<apiName>.json)',
|
|
310
324
|
description:
|
|
311
|
-
'Data the Blueprint reads. kind says where the columns come from: blank declares them, file reads a CSV, source pulls a relation from a connected Source. A key another kind reads is refused.',
|
|
325
|
+
'Data the Blueprint reads. kind says where the columns come from: blank declares them, file reads a CSV, source pulls a relation from a connected Source. A key another kind reads is refused. A change request carries a dataset as its columns, never its rows: a blank one, or a file one whose `columns` give every included column its databaseType and nullable.',
|
|
312
326
|
type: 'object',
|
|
313
327
|
oneOf: [
|
|
314
328
|
{
|
|
@@ -723,6 +737,40 @@ const SECRETS_SCHEMA: JsonSchema = {
|
|
|
723
737
|
additionalProperties: false,
|
|
724
738
|
}
|
|
725
739
|
|
|
740
|
+
/** `apps/<slug>.json`, as a pull writes it (`appDocument` in `local-apps.ts`; `appSettingsCandidate` in the service). */
|
|
741
|
+
const APP_SCHEMA: JsonSchema = {
|
|
742
|
+
$schema: DRAFT,
|
|
743
|
+
title: 'Custom App (apps/<slug>.json)',
|
|
744
|
+
description:
|
|
745
|
+
'What a Custom App is and the Functions it may start. Its code is its own project in apps/<slug>/, deployed with `frontera app deploy`; who may open it and its addresses are set on the platform. `frontera local dev` saves the App by its file name (the slug) and binds the Functions listed; `frontera local sync push` proposes its name, description, icon and Functions, and a Function bound in the workspace that the file no longer lists is unbound when the request is applied. `hosting`, `version`, `routing` and the domains are what the workspace reports, read only here.',
|
|
746
|
+
type: 'object',
|
|
747
|
+
properties: {
|
|
748
|
+
name: { type: 'string', minLength: 1, maxLength: 120, description: 'The App’s name; the file name when absent.' },
|
|
749
|
+
description: { type: 'string', maxLength: 500 },
|
|
750
|
+
icon: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,119}$', description: 'An icon from the platform’s set, by name (`chart-bar`); the App’s card shows its first letter when the name is not one. Left out, the App keeps its icon.' },
|
|
751
|
+
functions: {
|
|
752
|
+
type: 'array',
|
|
753
|
+
description: 'Every Function the App may start, through a Function bound to it; the App holds no secrets.',
|
|
754
|
+
items: {
|
|
755
|
+
type: 'object',
|
|
756
|
+
properties: {
|
|
757
|
+
function: { type: 'string', pattern: refPattern(['function']), description: '`function/<slug>`: functions/<slug>.ts, or one the workspace has.' },
|
|
758
|
+
enabled: { type: 'boolean', default: true, description: 'false keeps the binding switched off.' },
|
|
759
|
+
},
|
|
760
|
+
required: ['function'],
|
|
761
|
+
additionalProperties: false,
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
hosting: { enum: ['external'], description: 'Read only: hosted on its own domain.' },
|
|
765
|
+
version: { type: 'string', description: 'Read only: the deployment production serves.' },
|
|
766
|
+
routing: { type: 'string', description: 'Read only.' },
|
|
767
|
+
connectDomains: { type: 'array', items: { type: 'string' }, description: 'Read only: set in the App’s frontera.config.json.' },
|
|
768
|
+
resourceDomains: { type: 'array', items: { type: 'string' }, description: 'Read only: set in the App’s frontera.config.json.' },
|
|
769
|
+
},
|
|
770
|
+
required: ['name'],
|
|
771
|
+
additionalProperties: false,
|
|
772
|
+
}
|
|
773
|
+
|
|
726
774
|
/** The schema this CLI owns for a kind, or null when the service owns it. */
|
|
727
775
|
export function cliSchema(kind: SchemaKind): JsonSchema | null {
|
|
728
776
|
if (kind === 'evals') return EVALS_SCHEMA
|
|
@@ -731,6 +779,7 @@ export function cliSchema(kind: SchemaKind): JsonSchema | null {
|
|
|
731
779
|
if (kind === 'bindings') return BINDINGS_SCHEMA
|
|
732
780
|
if (kind === 'theme') return THEME_SCHEMA
|
|
733
781
|
if (kind === 'chat-app') return CHAT_APP_SCHEMA
|
|
782
|
+
if (kind === 'app') return APP_SCHEMA
|
|
734
783
|
if (kind === 'knowledge') return KNOWLEDGE_SCHEMA
|
|
735
784
|
if (kind === 'skill') return SKILL_SCHEMA
|
|
736
785
|
if (kind === 'secrets') return SECRETS_SCHEMA
|
package/src/local/local-seed.ts
CHANGED
|
@@ -166,7 +166,7 @@ export async function seedLocalRuntime(args: SeedArgs): Promise<LocalCredentials
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/** Provider ids the service stores as organization credentials, each with an `apiKey` field. */
|
|
169
|
-
const CREDENTIAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google'])
|
|
169
|
+
const CREDENTIAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google', 'openrouter-web-search'])
|
|
170
170
|
|
|
171
171
|
/** Embedding defaults by provider, in preference order; the service requires 1536-wide vectors. */
|
|
172
172
|
const EMBEDDING: Array<{ provider: string; model: string }> = [
|
|
@@ -225,6 +225,50 @@ export async function writeStandIns(root: string, names: string[], ws?: SyncClie
|
|
|
225
225
|
return out
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The Plugin installs a Function's deploy was refused for: its `grants`
|
|
230
|
+
* name `plugin:<install>:<capability>` and the runtime has no install of
|
|
231
|
+
* that name (`grant "plugin:crm:createLead" names no plugin install called
|
|
232
|
+
* "crm" in this workspace`). Read from the deploy's error.
|
|
233
|
+
*/
|
|
234
|
+
export function missingGrantInstalls(output: string): Array<{ install: string; capability: string }> {
|
|
235
|
+
const out: Array<{ install: string; capability: string }> = []
|
|
236
|
+
for (const m of output.matchAll(/grant "plugin:([^:"\s]+):([^"\s]+)" names no plugin install called "([^"]+)"/g)) {
|
|
237
|
+
if (!out.some((g) => g.install === m[1] && g.capability === m[2])) out.push({ install: m[1]!, capability: m[2]! })
|
|
238
|
+
}
|
|
239
|
+
return out
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A stand-in for a workspace Plugin a Function grants itself (Forge
|
|
244
|
+
* improvement plan X2; P3 F17, P4 2.7: a pulled Function's grant named an
|
|
245
|
+
* install the local runtime lacks, so it failed on every apply). Written as
|
|
246
|
+
* `mcp/<install>.stand-in.json` with the granted capability among its tools,
|
|
247
|
+
* like the stand-ins a pull writes for built-in connections: the Function
|
|
248
|
+
* deploys here, and each call it makes says it is not connected. Never one
|
|
249
|
+
* where the project has the Plugin's own file. A stand-in already there
|
|
250
|
+
* gains the capability. Returns whether the file changed.
|
|
251
|
+
*/
|
|
252
|
+
export function writeGrantStandIn(root: string, install: string, capabilities: string[]): boolean {
|
|
253
|
+
if (existsSync(join(root, 'mcp', `${install}.plugin.json`))) return false
|
|
254
|
+
const before = readStandIn(root, install)
|
|
255
|
+
const tools = new Map((before?.tools ?? []).map((t) => [t.name, t]))
|
|
256
|
+
for (const c of capabilities) if (!tools.has(c)) tools.set(c, { name: c, description: `Granted to a Function of this project; answers “not connected” here` })
|
|
257
|
+
const doc: StandInFile = {
|
|
258
|
+
standIn: true,
|
|
259
|
+
connection: before?.connection ?? install,
|
|
260
|
+
...(before?.serves ? { serves: before.serves } : {}),
|
|
261
|
+
note: before?.note ?? NOTE,
|
|
262
|
+
tools: [...tools.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
263
|
+
}
|
|
264
|
+
const text = `${JSON.stringify(doc, null, 2)}\n`
|
|
265
|
+
const path = join(root, standInPath(install))
|
|
266
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === text) return false
|
|
267
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
268
|
+
writeFileSync(path, text)
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
|
|
228
272
|
/**
|
|
229
273
|
* The tools a stand-in lists: the connection's, as the workspace listed
|
|
230
274
|
* them, and every tool the project's Agents and workflows name on it (a
|
|
@@ -386,14 +386,20 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
386
386
|
if (state?.credentials?.workspaceId && !headers.has('x-workspace-id')) {
|
|
387
387
|
headers.set('x-workspace-id', state.credentials.workspaceId)
|
|
388
388
|
}
|
|
389
|
+
// A page sends the session token it kept (`bearer_token`) itself. After
|
|
390
|
+
// `frontera local reset` that session is gone, so a session the service
|
|
391
|
+
// refuses is answered as the owner; an API key or a signed token is not.
|
|
392
|
+
const kept = headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9]{32})$/)?.[1]
|
|
393
|
+
const fallback = kept && bearer && kept !== bearer ? bearer : null
|
|
394
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
|
|
395
|
+
const body = hasBody && fallback ? await req.arrayBuffer() : hasBody ? req.body : undefined
|
|
389
396
|
try {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
})
|
|
397
|
+
const send = () => fetch(target, { method: req.method, headers, body, redirect: 'manual', duplex: 'half' })
|
|
398
|
+
let res = await send()
|
|
399
|
+
if (res.status === 401 && fallback) {
|
|
400
|
+
headers.set('authorization', `Bearer ${fallback}`)
|
|
401
|
+
res = await send()
|
|
402
|
+
}
|
|
397
403
|
const out = new Headers(res.headers)
|
|
398
404
|
out.delete('content-encoding')
|
|
399
405
|
out.delete('content-length')
|
|
@@ -37,6 +37,8 @@ import { localEmbeddingsReachable, providerEnv } from './local-keys'
|
|
|
37
37
|
import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
|
|
38
38
|
import type { PortBlock } from './local-ports'
|
|
39
39
|
import { startProcess, stopProcess, waitForHttp } from './local-process'
|
|
40
|
+
import { restartJobs, watchJobs } from './local-jobs'
|
|
41
|
+
import { portAnswers } from './local-component-health'
|
|
40
42
|
import type { LocalProject } from './local-project'
|
|
41
43
|
import { startLocalEmbeddings } from './local-embeddings'
|
|
42
44
|
import { LOCAL_TRACE_KEYS, startLocalTraces } from './local-traces'
|
|
@@ -299,6 +301,26 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
299
301
|
set(name, { status: 'failed', pid: undefined, detail: `exited with code ${code ?? 'signal'}; see \`frontera local logs ${name}\`` })
|
|
300
302
|
})
|
|
301
303
|
}
|
|
304
|
+
|
|
305
|
+
// Watch the jobs engine: one that stops answering is restarted alone, and the runs it lost failed (X2).
|
|
306
|
+
if (enabled('jobs')) {
|
|
307
|
+
watchJobs({
|
|
308
|
+
probe: () => portAnswers(ports.jobs, 3_000),
|
|
309
|
+
restart: async () => {
|
|
310
|
+
if (stopping) return
|
|
311
|
+
const restarted = await restartJobs(project, state, 'it stopped answering, so the supervisor restarted it')
|
|
312
|
+
set('jobs', { status: 'running', startedAt: new Date().toISOString(), detail: undefined })
|
|
313
|
+
appendFileSync(logPath(project, 'studio'), `[supervisor] jobs engine restarted in ${restarted.ms} ms${restarted.lost ? `; ${restarted.lost} lost runs marked failed` : ''}${restarted.note ? `; ${restarted.note}` : ''}\n`)
|
|
314
|
+
},
|
|
315
|
+
log: (line) => {
|
|
316
|
+
try {
|
|
317
|
+
appendFileSync(logPath(project, 'studio'), `[supervisor] ${line}\n`)
|
|
318
|
+
} catch {
|
|
319
|
+
// Logging never fails the watch.
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
}
|
|
302
324
|
}
|
|
303
325
|
|
|
304
326
|
type SetFn = (name: ComponentName, patch: Partial<ComponentState>) => void
|
|
@@ -63,6 +63,8 @@ export interface ProjectModels {
|
|
|
63
63
|
workspace: Set<string> | null
|
|
64
64
|
/** The workspace's models by slug, for words. */
|
|
65
65
|
names: Record<string, string>
|
|
66
|
+
/** The workspace's evaluation datasets hold conversations of several turns (its `evaluation-conversations` capability, L3). */
|
|
67
|
+
conversations?: boolean
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
const cached = new Map<string, { stamp: string; value: ProjectModels }>()
|
|
@@ -84,10 +86,11 @@ export function projectModels(root: string): ProjectModels {
|
|
|
84
86
|
const stamp = stampOf(...files)
|
|
85
87
|
const known = cached.get(root)
|
|
86
88
|
if (known && known.stamp === stamp) return known.value
|
|
87
|
-
const remote = readJson<{ models?: CatalogEntry[]; names?: { model?: Record<string, string> } }>(join(root, REMOTE_JSON))
|
|
89
|
+
const remote = readJson<{ models?: CatalogEntry[]; names?: { model?: Record<string, string> }; capabilities?: string[] }>(join(root, REMOTE_JSON))
|
|
88
90
|
const theirs = remote?.models ?? Object.keys(remote?.names?.model ?? {}).map((slug) => ({ slug }) as CatalogEntry)
|
|
89
91
|
const ours = readJson<{ models?: CatalogEntry[] }>(join(root, LOCAL_CATALOG))?.models ?? []
|
|
90
|
-
const
|
|
92
|
+
const matched = matchModels(ours, theirs.length ? theirs : null, modelMap(root))
|
|
93
|
+
const value = remote?.capabilities?.includes('evaluation-conversations') ? { ...matched, conversations: true } : matched
|
|
91
94
|
cached.set(root, { stamp, value })
|
|
92
95
|
return value
|
|
93
96
|
}
|
|
@@ -114,6 +114,7 @@ export function namingPaths(kind: string, name: string): string[] {
|
|
|
114
114
|
if (kind === 'chat-app') return [`chat-apps/${name}.json`]
|
|
115
115
|
if (kind === 'app') return [`apps/${name}.json`]
|
|
116
116
|
if (kind === 'knowledge') return [`knowledge/${name}/knowledge.json`]
|
|
117
|
+
if (kind === 'dataset') return [`datasets/${name}.json`]
|
|
117
118
|
return []
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -139,6 +140,8 @@ export function itemTitle(kind: string, name: string, files: Record<string, stri
|
|
|
139
140
|
const own = yamlField(file, 'displayName') ?? declared(parseJson(file), 'displayName')
|
|
140
141
|
return own ?? (dir === 'object-sets' ? readableName(apiName ?? name) : (apiName ?? name))
|
|
141
142
|
}
|
|
143
|
+
// A dataset by its display name, else its API name as object types name it.
|
|
144
|
+
if (kind === 'dataset') return declared(parseJson(files[`datasets/${name}.json`]), 'displayName') ?? name
|
|
142
145
|
if (kind === 'skill') {
|
|
143
146
|
const own = yamlField(files[`skills/${name}/SKILL.md`], 'displayName')
|
|
144
147
|
if (own) return own
|