@frontera-sdk/cli 1.52.39 → 1.52.41
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/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -5,11 +5,15 @@ import { extname, join, normalize } from 'node:path'
|
|
|
5
5
|
import type { LocalProject } from './local-project'
|
|
6
6
|
import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
|
|
7
7
|
import { solutionStatus } from './local-applied'
|
|
8
|
+
import { appCodeStates } from './local-apps'
|
|
8
9
|
import { projectBlueprintOutline } from './local-blueprint-outline'
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { currentBuildRows, currentBuildSummary } from './local-build-inputs'
|
|
11
|
+
import { appStatusRow, itemBuild } from './local-build-summary'
|
|
12
|
+
import { KnowledgeDocumentError, KNOWLEDGE_UPLOAD_EXTENSIONS, MAX_KNOWLEDGE_UPLOAD_BYTES, checkUpload, documentInFolder, knowledgeFolderOf, projectDocuments, removeKnowledgeDocument, removeRuntimeCopies, runtimeDocuments, saveKnowledgeDocument, type ServiceCall } from './local-knowledge-uploads'
|
|
13
|
+
import { isAlive, logPath, readApplied, readState, type ComponentName } from './local-state'
|
|
14
|
+
import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, redactedVariablesOfTimeline, renderTestCase, variablesOfTimeline } from './local-test-cases'
|
|
12
15
|
import { findCaseFiles } from './local-test-runner'
|
|
16
|
+
import { workflowTestListing } from './local-workflow-tests'
|
|
13
17
|
import { createTestRuns } from './local-test-runs'
|
|
14
18
|
import { docsCopyPath } from './local-docs'
|
|
15
19
|
import { standInMcp } from './local-stand-ins'
|
|
@@ -62,6 +66,16 @@ export function newSessionToken(): string {
|
|
|
62
66
|
return randomBytes(24).toString('base64url')
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/** An Agent's display name from `agents/<slug>/agent.json` (`config.name`), or null when it gives none. */
|
|
70
|
+
export function agentFileName(root: string, slug: string): string | null {
|
|
71
|
+
try {
|
|
72
|
+
const name = (JSON.parse(readFileSync(join(root, 'agents', slug, 'agent.json'), 'utf8')) as { config?: { name?: unknown } }).config?.name
|
|
73
|
+
return typeof name === 'string' && name.trim() ? name.trim() : null
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
function sameToken(a: string, b: string): boolean {
|
|
66
80
|
const ab = Buffer.from(a)
|
|
67
81
|
const bb = Buffer.from(b)
|
|
@@ -95,7 +109,7 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
95
109
|
const json = (data: unknown, status = 200) =>
|
|
96
110
|
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8' } })
|
|
97
111
|
|
|
98
|
-
const localRoute = (url: URL): Response => {
|
|
112
|
+
const localRoute = (url: URL): Response | Promise<Response> => {
|
|
99
113
|
if (url.pathname === '/__local/state') {
|
|
100
114
|
const state = readState(project)
|
|
101
115
|
if (!state) return json({ error: 'no local runtime state' }, 404)
|
|
@@ -125,6 +139,36 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
125
139
|
if (!/^[a-z-]+\/[A-Za-z0-9][\w./-]{0,200}$/.test(ref)) return json({ error: 'name an item, as agent/<slug>' }, 400)
|
|
126
140
|
return json({ build: itemBuild(project.root, ref, project.id) })
|
|
127
141
|
}
|
|
142
|
+
// The build in a few numbers, counted as `local status` counts it (the runtime's Action deployments and
|
|
143
|
+
// secrets read the same way): Studio's footer says its failures, the number `local status` prints.
|
|
144
|
+
if (url.pathname === '/__local/build-summary') {
|
|
145
|
+
return currentBuildSummary(project.root, project.id, readState(project)).then((summary) =>
|
|
146
|
+
json({ summary: summary ? { problems: summary.problems, counts: summary.counts, headline: summary.headline } : null }),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
// The build's rows of one kind (`?kind=function`), each with its state, reason and fix, as `local status` lists
|
|
150
|
+
// them: Studio's lists mark the ones that didn't apply or need setup, with the same words.
|
|
151
|
+
if (url.pathname === '/__local/builds') {
|
|
152
|
+
const kind = url.searchParams.get('kind') ?? ''
|
|
153
|
+
if (!/^[a-zA-Z-]{1,40}$/.test(kind)) return json({ error: 'name a kind, as function' }, 400)
|
|
154
|
+
return currentBuildRows(project.root, project.id, readState(project)).then((rows) =>
|
|
155
|
+
json({
|
|
156
|
+
rows: (rows ?? [])
|
|
157
|
+
.filter((r) => r.ref.startsWith(`${kind}/`))
|
|
158
|
+
.map((r) => ({ ref: r.ref, state: r.state, label: r.label, reason: r.reason, fix: r.fix ?? null, ...(r.warnings?.length ? { warnings: r.warnings } : {}) })),
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
// Where each Custom App's code is (its folder, not pulled, or none yet), with its one status as `local status` says
|
|
163
|
+
// it (deployed to this runtime, built, not built, no code): Studio's Apps page says it, and how to work on it.
|
|
164
|
+
if (url.pathname === '/__local/apps') {
|
|
165
|
+
return json({
|
|
166
|
+
apps: appCodeStates(project.root).map((code) => {
|
|
167
|
+
const row = appStatusRow(project.root, code)
|
|
168
|
+
return { ...code, status: row.appStatus, label: row.label, fix: row.fix ?? null }
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
128
172
|
// The Blueprint's definitions as the files state them, so they show before anything is applied.
|
|
129
173
|
if (url.pathname === '/__local/blueprint') {
|
|
130
174
|
return json({ objectTypes: projectBlueprintOutline(project.root) })
|
|
@@ -132,11 +176,17 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
132
176
|
// The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
|
|
133
177
|
if (url.pathname === '/__local/test-cases') {
|
|
134
178
|
try {
|
|
135
|
-
|
|
179
|
+
// Each Agent by the name its agent.json gives it, as the home and Agents say it, not its folder.
|
|
180
|
+
return json({ files: findCaseFiles(project.root).map((f) => ({ ...f, agentName: agentFileName(project.root, f.agent) })) })
|
|
136
181
|
} catch (err) {
|
|
137
182
|
return json({ error: `a case file could not be read: ${(err as Error).message}` }, 422)
|
|
138
183
|
}
|
|
139
184
|
}
|
|
185
|
+
// The workflows' test files (`workflows/tests/*.yaml`): `frontera local test` runs them; Evaluations lists them.
|
|
186
|
+
if (url.pathname === '/__local/workflow-tests') {
|
|
187
|
+
// Each case by name, with its last run (`frontera local test` keeps it), so Evaluations lists them.
|
|
188
|
+
return json({ files: workflowTestListing(project.root, project.stateDir) })
|
|
189
|
+
}
|
|
140
190
|
if (url.pathname === '/__local/test-runs') return json({ runs: testRuns.list() })
|
|
141
191
|
const runMatch = /^\/__local\/test-runs\/([0-9a-f-]+)$/.exec(url.pathname)
|
|
142
192
|
if (runMatch) {
|
|
@@ -171,10 +221,122 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
171
221
|
const agent = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}`)) as { agentId?: string }
|
|
172
222
|
const messages = (await get(`/v1/sessions/${encodeURIComponent(body.conversationId)}/messages`)) as unknown
|
|
173
223
|
const list = Array.isArray(messages) ? messages : ((messages as { messages?: unknown[] })?.messages ?? [])
|
|
174
|
-
|
|
224
|
+
// The variables the conversation started with, when its Test setup recorded them: the case replays as the same customer.
|
|
225
|
+
const session = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}/try/conversations/${encodeURIComponent(body.conversationId)}/session`).catch(() => null)) as { timeline?: Array<{ payload?: Record<string, unknown> | null }> } | null
|
|
226
|
+
const variables = variablesOfTimeline(session?.timeline ?? [])
|
|
227
|
+
// A sensitive variable is named in the setup, never its value: the case says to fill it in by hand.
|
|
228
|
+
const redacted = redactedVariablesOfTimeline(session?.timeline ?? [])
|
|
229
|
+
// The tool approvals it stopped on, as the person decided them: the case answers them the same way.
|
|
230
|
+
const cards = (await get(`/v1/chat/approvals?conversationId=${encodeURIComponent(body.conversationId)}`).catch(() => [])) as unknown
|
|
231
|
+
const { decision, mixed } = decisionOfApprovals(Array.isArray(cards) ? cards : [])
|
|
232
|
+
const yaml = noteRedactedVariables(renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString(), ...(variables ? { variables } : {}), ...(decision ? { decision } : {}) }), redacted)
|
|
175
233
|
const path = appendTestCase(project.root, agent.agentId ?? body.agentId, yaml)
|
|
176
|
-
|
|
234
|
+
const notes = [...(redacted.length ? [redactedVariablesLine(redacted, path)] : []), ...(mixed ? [MIXED_DECISIONS_LINE] : [])]
|
|
235
|
+
return json({ path, ...(decision ? { decision } : {}), ...(redacted.length ? { redactedVariables: redacted } : {}), ...(notes.length ? { note: notes.join(' ') } : {}) })
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return json({ error: (err as Error).message }, 502)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The local service as the seeded owner, for the routes that act on the runtime themselves; null before it is seeded. */
|
|
242
|
+
const serviceCall = (): ServiceCall | null => {
|
|
243
|
+
const creds = readState(project)?.credentials
|
|
244
|
+
const bearer = creds?.session ?? creds?.workspaceKey
|
|
245
|
+
if (!creds || !bearer) return null
|
|
246
|
+
return async (method, path) => {
|
|
247
|
+
const res = await fetch(new URL(path, opts.serviceUrl), { method, headers: { authorization: `Bearer ${bearer}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId } })
|
|
248
|
+
const text = await res.text()
|
|
249
|
+
if (!res.ok) throw new Error(`the runtime answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
|
|
250
|
+
return text ? (JSON.parse(text) as unknown) : null
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Whether a `local dev` watcher is applying this project's saved files (Forge keeps one running). */
|
|
255
|
+
const watching = (): boolean => {
|
|
256
|
+
try {
|
|
257
|
+
return isAlive(Number(readFileSync(join(project.stateDir, 'dev.pid'), 'utf8').trim()))
|
|
258
|
+
} catch {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A knowledge base's documents as the project's files (`local-knowledge-uploads.ts`):
|
|
265
|
+
* GET /__local/knowledge?base=<name>&id=<knowledge id> its folder, its documents and whether the runtime has each, its apply state
|
|
266
|
+
* POST /__local/knowledge/documents?base=&id=[&replace=1] save one document (multipart `file`) into its folder
|
|
267
|
+
* DELETE /__local/knowledge/documents?base=&id=&sourceId= take a document off the runtime and out of the folder
|
|
268
|
+
*/
|
|
269
|
+
const knowledgeRoute = async (req: Request, url: URL): Promise<Response> => {
|
|
270
|
+
const base = (url.searchParams.get('base') ?? '').trim()
|
|
271
|
+
const id = url.searchParams.get('id') ?? ''
|
|
272
|
+
if (!base || base.length > 200) return json({ error: 'name the knowledge base, as base=<name>' }, 400)
|
|
273
|
+
if (id && !/^[\w-]{1,80}$/.test(id)) return json({ error: 'id is not a knowledge base id' }, 400)
|
|
274
|
+
const folder = knowledgeFolderOf(project.root, base)
|
|
275
|
+
try {
|
|
276
|
+
if (url.pathname === '/__local/knowledge' && req.method === 'GET') {
|
|
277
|
+
const accepted = { extensions: KNOWLEDGE_UPLOAD_EXTENSIONS, maxBytes: MAX_KNOWLEDGE_UPLOAD_BYTES }
|
|
278
|
+
if (!folder) return json({ folder: null, documents: [], apply: null, watching: watching(), accepted })
|
|
279
|
+
const call = id ? serviceCall() : null
|
|
280
|
+
const onRuntime = call ? await runtimeDocuments(call, id).then((docs) => new Set(docs.map((d) => d.path)), () => null) : null
|
|
281
|
+
const status = solutionStatus(project, readState(project)?.credentials?.organizationId ?? null, (t) => t.kind === 'knowledge' && t.name === folder)[0]
|
|
282
|
+
return json({
|
|
283
|
+
folder: `knowledge/${folder}/`,
|
|
284
|
+
documents: projectDocuments(project.root, folder, onRuntime),
|
|
285
|
+
apply: status ? { state: status.state, ...(status.detail ? { detail: status.detail } : {}), ...(status.fix ? { fix: status.fix } : {}), ...(status.waiting ? { waiting: true } : {}) } : null,
|
|
286
|
+
watching: watching(),
|
|
287
|
+
accepted,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (url.pathname !== '/__local/knowledge/documents') return json({ error: 'not found' }, 404)
|
|
291
|
+
if (req.method === 'POST') {
|
|
292
|
+
if (!folder) return json({ error: `${base} has no folder in the project (knowledge/<name>/knowledge.json), so a document can't be saved to it here` }, 404)
|
|
293
|
+
// Refused before the body is read: the form's boundary and fields add little beyond the file.
|
|
294
|
+
if (Number(req.headers.get('content-length') ?? 0) > MAX_KNOWLEDGE_UPLOAD_BYTES + 64 * 1024) {
|
|
295
|
+
return json({ error: `the document is larger than ${MAX_KNOWLEDGE_UPLOAD_BYTES / 1024 / 1024} MB, the most a knowledge base takes` }, 413)
|
|
296
|
+
}
|
|
297
|
+
const form = await req.formData().catch(() => null)
|
|
298
|
+
const file = form?.get('file')
|
|
299
|
+
if (!(file instanceof Blob)) return json({ error: 'send the document as the multipart field "file"' }, 400)
|
|
300
|
+
const name = checkUpload((file as File).name ?? '', file.size)
|
|
301
|
+
const replace = url.searchParams.get('replace') === '1'
|
|
302
|
+
// Replacing: the runtime's copy goes first, so the apply uploads the new content (it only adds what the runtime lacks).
|
|
303
|
+
if (replace && id && documentInFolder(project.root, folder, name)) {
|
|
304
|
+
const call = serviceCall()
|
|
305
|
+
if (call) {
|
|
306
|
+
try {
|
|
307
|
+
await removeRuntimeCopies(call, id, name)
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return json({ error: `the runtime's copy of ${name} couldn't be taken off (${(err as Error).message}), so knowledge/${folder}/${name} is left as it was` }, 502)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const saved = saveKnowledgeDocument(project.root, folder, name, new Uint8Array(await file.arrayBuffer()), { replace })
|
|
314
|
+
return json({ ...saved, watching: watching() })
|
|
315
|
+
}
|
|
316
|
+
if (req.method === 'DELETE') {
|
|
317
|
+
const sourceId = url.searchParams.get('sourceId') ?? ''
|
|
318
|
+
if (!id || !/^[\w-]{1,80}$/.test(sourceId)) return json({ error: 'name the document, as id=<knowledge id>&sourceId=<document id>' }, 400)
|
|
319
|
+
const call = serviceCall()
|
|
320
|
+
if (!call) return json({ error: 'the runtime is not seeded yet' }, 409)
|
|
321
|
+
const source = (await runtimeDocuments(call, id)).find((d) => d.id === sourceId)
|
|
322
|
+
if (!source) return json({ error: 'the runtime has no such document in this knowledge base' }, 404)
|
|
323
|
+
await call('DELETE', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources/${encodeURIComponent(sourceId)}`)
|
|
324
|
+
// Its file too, so the next apply does not upload it again; a base with no folder had only the runtime's copy.
|
|
325
|
+
// A runtime document of a kind or name the folder can't hold (a spreadsheet uploaded on the runtime) has no file to remove.
|
|
326
|
+
const removed = (() => {
|
|
327
|
+
if (!folder) return { path: null, removed: false }
|
|
328
|
+
try {
|
|
329
|
+
return removeKnowledgeDocument(project.root, folder, source.path)
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err instanceof KnowledgeDocumentError && err.status !== 409) return { path: null, removed: false }
|
|
332
|
+
throw err
|
|
333
|
+
}
|
|
334
|
+
})()
|
|
335
|
+
return json({ path: removed.path, removedFile: removed.removed })
|
|
336
|
+
}
|
|
337
|
+
return json({ error: 'not found' }, 404)
|
|
177
338
|
} catch (err) {
|
|
339
|
+
if (err instanceof KnowledgeDocumentError) return json({ error: err.message, ...err.extra }, err.status)
|
|
178
340
|
return json({ error: (err as Error).message }, 502)
|
|
179
341
|
}
|
|
180
342
|
}
|
|
@@ -316,6 +478,7 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
316
478
|
const started = testRuns.start({ agent: typeof body.agent === 'string' ? body.agent : undefined, live: body.live === true })
|
|
317
479
|
return 'run' in started ? json(started.run, 202) : json({ error: started.error }, started.status)
|
|
318
480
|
}
|
|
481
|
+
if (url.pathname === '/__local/knowledge' || url.pathname === '/__local/knowledge/documents') return knowledgeRoute(req, url)
|
|
319
482
|
if (url.pathname.startsWith('/__local/')) return localRoute(url)
|
|
320
483
|
// `/api/…` is the service; `/api` alone is Studio's API page.
|
|
321
484
|
if (url.pathname.startsWith('/api/')) return proxy(req, url)
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import {
|
|
6
6
|
containerState,
|
|
7
7
|
copyFromContainer,
|
|
8
|
+
dbVolumeName,
|
|
8
9
|
execInContainer,
|
|
9
10
|
followLogs,
|
|
10
11
|
imagePresent,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
runContainer,
|
|
14
15
|
startContainer,
|
|
15
16
|
stopContainer,
|
|
17
|
+
storageVolumeName,
|
|
16
18
|
} from './local-docker'
|
|
17
19
|
import {
|
|
18
20
|
CATALOG_DB,
|
|
@@ -30,14 +32,15 @@ import {
|
|
|
30
32
|
type LocalConfig,
|
|
31
33
|
type LocalSecrets,
|
|
32
34
|
} from './local-env'
|
|
33
|
-
import {
|
|
35
|
+
import { localEmbeddingsReachable, providerEnv } from './local-keys'
|
|
34
36
|
import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
|
|
35
37
|
import type { PortBlock } from './local-ports'
|
|
36
38
|
import { startProcess, stopProcess, waitForHttp } from './local-process'
|
|
37
39
|
import type { LocalProject } from './local-project'
|
|
38
40
|
import { startLocalEmbeddings } from './local-embeddings'
|
|
39
41
|
import { LOCAL_TRACE_KEYS, startLocalTraces } from './local-traces'
|
|
40
|
-
import {
|
|
42
|
+
import { recordRuntime } from './local-runtime-history'
|
|
43
|
+
import { buildDocs, checkoutDocsStale, copyDocs, customerDocsBundle, IMAGE_DOCS_PATH, writeDocsBuildMarker } from './local-docs'
|
|
41
44
|
import { keepSchemas, type SchemaSource } from './local-schemas'
|
|
42
45
|
import { applyModelKeys, ensureBlueprint, ensureOrgKey, ensureProviderModels, retireLocalModel, seedLocalRuntime, signIn } from './local-seed'
|
|
43
46
|
import {
|
|
@@ -80,7 +83,7 @@ export const IMAGES = {
|
|
|
80
83
|
export const containerName = (project: LocalProject, component: ComponentName) =>
|
|
81
84
|
`frontera-${project.id}-${component}`
|
|
82
85
|
|
|
83
|
-
export const volumeName = (project: LocalProject) =>
|
|
86
|
+
export const volumeName = (project: LocalProject) => dbVolumeName(project.id)
|
|
84
87
|
|
|
85
88
|
interface Running {
|
|
86
89
|
children: Map<ComponentName, ChildProcess>
|
|
@@ -144,6 +147,8 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
144
147
|
}
|
|
145
148
|
for (const name of [...running.containers].reverse()) stopContainer(name)
|
|
146
149
|
for (const c of state.components) if (c.status === 'running' || c.status === 'starting') set(c.name, { status: 'stopped' })
|
|
150
|
+
// A failed start says so where it failed (`fail`); this is the stop of a runtime that was up.
|
|
151
|
+
if (code === 0) recordRuntime(project, { event: 'stopped', at: new Date().toISOString() })
|
|
147
152
|
state = { ...state, phase: 'stopped', supervisorPid: undefined }
|
|
148
153
|
save()
|
|
149
154
|
process.exit(code)
|
|
@@ -155,6 +160,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
155
160
|
set(name, { status: 'failed', detail })
|
|
156
161
|
state = { ...state, phase: 'failed', error: `${name}: ${detail}` }
|
|
157
162
|
save()
|
|
163
|
+
recordRuntime(project, { event: 'failed', at: new Date().toISOString(), since: state.startedAt, detail: `${name}: ${detail}` })
|
|
158
164
|
await shutdown(1)
|
|
159
165
|
}
|
|
160
166
|
|
|
@@ -278,6 +284,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
278
284
|
set('studio', { status: 'running', startedAt: new Date().toISOString(), pid: process.pid })
|
|
279
285
|
state = { ...state, phase: 'ready', error: undefined }
|
|
280
286
|
save()
|
|
287
|
+
recordRuntime(project, { event: 'started', at: new Date().toISOString(), since: state.startedAt })
|
|
281
288
|
} catch (err) {
|
|
282
289
|
const failing = state.components.find((c) => c.status === 'starting')?.name ?? 'service'
|
|
283
290
|
await fail(failing, (err as Error).message)
|
|
@@ -393,7 +400,7 @@ async function startStorage(project: LocalProject, ports: PortBlock, secrets: Lo
|
|
|
393
400
|
projectId: project.id,
|
|
394
401
|
ports: [{ host: ports.storage, container: 9000 }],
|
|
395
402
|
env: { RUSTFS_ACCESS_KEY: secrets.storageAccessKey, RUSTFS_SECRET_KEY: secrets.storageSecretKey },
|
|
396
|
-
volumes: [{ name:
|
|
403
|
+
volumes: [{ name: storageVolumeName(project.id), path: '/data' }],
|
|
397
404
|
memory: '256m',
|
|
398
405
|
cpus: '0.5',
|
|
399
406
|
args: ['server', '/data'],
|
|
@@ -462,7 +469,7 @@ async function startService(
|
|
|
462
469
|
runnerEnabled,
|
|
463
470
|
jobsEnabled,
|
|
464
471
|
storageEnabled,
|
|
465
|
-
keylessEmbeddings:
|
|
472
|
+
keylessEmbeddings: localEmbeddingsReachable(),
|
|
466
473
|
documentationBundle: config.mode === 'source' ? customerDocsBundle(config.platformSource) : null,
|
|
467
474
|
})
|
|
468
475
|
const log = logPath(project, 'service')
|
|
@@ -479,6 +486,7 @@ async function startService(
|
|
|
479
486
|
if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
|
|
480
487
|
set('service', { status: 'running', pid: child.pid, startedAt: new Date().toISOString() })
|
|
481
488
|
keepDocs(project, { bundle: customerDocsBundle(config.platformSource) }, log)
|
|
489
|
+
refreshCheckoutDocs(project, config.platformSource, log)
|
|
482
490
|
return
|
|
483
491
|
}
|
|
484
492
|
const name = containerName(project, 'service')
|
|
@@ -504,7 +512,7 @@ async function startService(
|
|
|
504
512
|
keepDocs(project, { fromContainer: (dest) => copyFromContainer(name, IMAGE_DOCS_PATH, dest) }, log)
|
|
505
513
|
}
|
|
506
514
|
|
|
507
|
-
/** The release's documentation, for the solution's coding agents (`
|
|
515
|
+
/** The release's documentation, for the solution's coding agents (`frontera mcp`). */
|
|
508
516
|
function keepDocs(project: LocalProject, from: { bundle?: string | null; fromContainer?: (dest: string) => boolean }, log: string): void {
|
|
509
517
|
if (!from.bundle && !from.fromContainer) {
|
|
510
518
|
appendFileSync(log, '[docs] no customer documentation in the platform checkout; run `bun run docs:build` there\n')
|
|
@@ -514,6 +522,36 @@ function keepDocs(project: LocalProject, from: { bundle?: string | null; fromCon
|
|
|
514
522
|
if (problem) appendFileSync(log, `[docs] the documentation for coding agents was not copied: ${problem}\n`)
|
|
515
523
|
}
|
|
516
524
|
|
|
525
|
+
/**
|
|
526
|
+
* From a checkout whose documentation build is behind it (no build, guides
|
|
527
|
+
* changed since, another version): build it in the background, at low
|
|
528
|
+
* priority, and refresh the copy when it is done. Start never waits for it:
|
|
529
|
+
* the copy from the previous build serves until then, and the service rereads
|
|
530
|
+
* the checkout's publication when it changes on disk.
|
|
531
|
+
*/
|
|
532
|
+
function refreshCheckoutDocs(project: LocalProject, platformSource: string | undefined, log: string): void {
|
|
533
|
+
if (!platformSource) return
|
|
534
|
+
const why = checkoutDocsStale(platformSource)
|
|
535
|
+
if (!why) {
|
|
536
|
+
// Current: a failure an earlier start recorded no longer applies.
|
|
537
|
+
writeDocsBuildMarker(project, null)
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
appendFileSync(log, `[docs] ${why}; building it in the background (nice -n 10 bun run docs:build); the previous copy serves until it is done\n`)
|
|
541
|
+
// What `local status` and Forge's Docs say while it builds ("rebuilding"), and if it fails.
|
|
542
|
+
writeDocsBuildMarker(project, { state: 'building', at: new Date().toISOString(), pid: process.pid, why })
|
|
543
|
+
void buildDocs(platformSource, log).then((built) => {
|
|
544
|
+
if (!built) {
|
|
545
|
+
writeDocsBuildMarker(project, { state: 'failed', at: new Date().toISOString(), why })
|
|
546
|
+
appendFileSync(log, '[docs] the documentation build did not finish; coding agents keep the previous copy (`bun run docs:build` in the checkout shows why)\n')
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
keepDocs(project, { bundle: customerDocsBundle(platformSource) }, log)
|
|
550
|
+
writeDocsBuildMarker(project, null)
|
|
551
|
+
appendFileSync(log, '[docs] built the documentation and refreshed the copy for coding agents\n')
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
|
|
517
555
|
/** The formats of the project's files, for its coding agents and editors (`local schema`). Logged, never a reason to fail start. */
|
|
518
556
|
async function keepFileSchemas(project: LocalProject, source: SchemaSource, log: string): Promise<void> {
|
|
519
557
|
for (const problem of await keepSchemas(project, source)) appendFileSync(log, `[schemas] not kept: ${problem}\n`)
|
|
@@ -372,7 +372,7 @@ export function mergeItemFiles(
|
|
|
372
372
|
theirs: Record<string, string | null>,
|
|
373
373
|
opts: Omit<MergeOptions, 'offset'> = {},
|
|
374
374
|
): { files: FileMerge[]; conflicts: number } {
|
|
375
|
-
const paths = [...new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])].sort()
|
|
375
|
+
const paths = [...new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
376
376
|
const files: FileMerge[] = []
|
|
377
377
|
let offset = 0
|
|
378
378
|
for (const path of paths) {
|
|
@@ -131,6 +131,8 @@ export function itemTitle(kind: string, name: string, files: Record<string, stri
|
|
|
131
131
|
const own = declared(doc?.config, 'name') ?? declared(doc, 'name')
|
|
132
132
|
if (own) return own
|
|
133
133
|
}
|
|
134
|
+
// The workspace's own grants beyond read (`blueprint/workspace-grants.yaml`): one file, no display name.
|
|
135
|
+
if (kind === 'blueprint' && name === 'workspace-grants') return 'Workspace grants'
|
|
134
136
|
if (kind === 'blueprint') {
|
|
135
137
|
const [dir, apiName] = name.split('/')
|
|
136
138
|
const file = Object.entries(files).find(([p]) => p.startsWith(`blueprint/${dir}/${apiName}.`))?.[1]
|
|
@@ -84,6 +84,13 @@ export interface RemoteItem {
|
|
|
84
84
|
* is being read; `from`, the snapshot's copy, once fetched.
|
|
85
85
|
*/
|
|
86
86
|
assets?: ItemAsset[]
|
|
87
|
+
/**
|
|
88
|
+
* Files the pull recovered from the item's deployed bundle because its
|
|
89
|
+
* source lacks them (a Function's imports from beside its folder), by
|
|
90
|
+
* project path. Pulled like any other file; listed so it is never mistaken
|
|
91
|
+
* for the original source.
|
|
92
|
+
*/
|
|
93
|
+
recovered?: string[]
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
export interface ItemAsset {
|
|
@@ -193,6 +200,8 @@ export interface LockEntry {
|
|
|
193
200
|
pulledAt: string
|
|
194
201
|
/** Pulled with conflicts not yet resolved; the base above is still the last clean pull. */
|
|
195
202
|
conflict?: LockConflict
|
|
203
|
+
/** Its files the pull recovered from the deployed bundle, not its source (`RemoteItem.recovered`). */
|
|
204
|
+
recovered?: string[]
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
/**
|
|
@@ -214,7 +223,7 @@ export function hash(text: string): string {
|
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
export function itemHash(files: Record<string, string>): string {
|
|
217
|
-
return hash(Object.keys(files).sort().map((p) => `${p}\0${files[p]}`).join('\0\0'))
|
|
226
|
+
return hash(Object.keys(files).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((p) => `${p}\0${files[p]}`).join('\0\0'))
|
|
218
227
|
}
|
|
219
228
|
|
|
220
229
|
export function readLock(root: string): SyncLock | null {
|
|
@@ -231,7 +240,7 @@ export function readLock(root: string): SyncLock | null {
|
|
|
231
240
|
|
|
232
241
|
/** Sorted keys, two spaces, a final newline: a lock diffs cleanly in review. Always written as v2. */
|
|
233
242
|
export function writeLock(root: string, lock: SyncLock): void {
|
|
234
|
-
const items = Object.fromEntries(Object.keys(lock.items).sort().map((k) => [k, sortEntry(lock.items[k]!)]))
|
|
243
|
+
const items = Object.fromEntries(Object.keys(lock.items).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((k) => [k, sortEntry(lock.items[k]!)]))
|
|
235
244
|
writeFileSync(join(root, LOCK_FILE), `${JSON.stringify({ version: 2, workspace: lock.workspace, ...(lock.pulledAt ? { pulledAt: lock.pulledAt } : {}), items }, null, 2)}\n`)
|
|
236
245
|
}
|
|
237
246
|
|
|
@@ -240,11 +249,12 @@ function sortEntry(entry: LockEntry): LockEntry {
|
|
|
240
249
|
...(entry.id ? { id: entry.id } : {}),
|
|
241
250
|
...(entry.revision ? { revision: entry.revision } : {}),
|
|
242
251
|
remote: entry.remote,
|
|
243
|
-
files: Object.fromEntries(Object.keys(entry.files).sort().map((k) => [k, entry.files[k]!])),
|
|
252
|
+
files: Object.fromEntries(Object.keys(entry.files).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((k) => [k, entry.files[k]!])),
|
|
244
253
|
...(entry.version ? { version: entry.version } : {}),
|
|
245
254
|
...(entry.state ? { state: entry.state } : {}),
|
|
246
255
|
pulledAt: entry.pulledAt,
|
|
247
256
|
...(entry.conflict ? { conflict: sortConflict(entry.conflict) } : {}),
|
|
257
|
+
...(entry.recovered?.length ? { recovered: [...entry.recovered].sort() } : {}),
|
|
248
258
|
}
|
|
249
259
|
}
|
|
250
260
|
|
|
@@ -252,7 +262,7 @@ function sortConflict(c: LockConflict): LockConflict {
|
|
|
252
262
|
return {
|
|
253
263
|
remote: c.remote,
|
|
254
264
|
...(c.revision ? { revision: c.revision } : {}),
|
|
255
|
-
files: [...c.files].sort(),
|
|
265
|
+
files: [...c.files].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)),
|
|
256
266
|
at: c.at,
|
|
257
267
|
...(c.count !== undefined ? { count: c.count } : {}),
|
|
258
268
|
...(c.id ? { id: c.id } : {}),
|
|
@@ -390,7 +400,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
|
|
|
390
400
|
for (const item of remote) {
|
|
391
401
|
seen.add(item.ref)
|
|
392
402
|
const entry = lock?.items[item.ref]
|
|
393
|
-
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort()
|
|
403
|
+
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
394
404
|
const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base), remote: item.files[path] ?? null }))
|
|
395
405
|
// An Agent's file that neither the base nor the workspace has but the folder does (its first tools, in a new
|
|
396
406
|
// `bindings.json`) is a change here too, as push reads it: otherwise status says nothing while push proposes it.
|
|
@@ -475,6 +485,7 @@ function entryFor(item: RemoteItem, now: string): LockEntry {
|
|
|
475
485
|
...(item.version ? { version: item.version } : {}),
|
|
476
486
|
...(item.state ? { state: item.state } : {}),
|
|
477
487
|
pulledAt: now,
|
|
488
|
+
...(item.recovered?.length ? { recovered: [...item.recovered].sort() } : {}),
|
|
478
489
|
}
|
|
479
490
|
}
|
|
480
491
|
|
|
@@ -542,7 +553,7 @@ export function applyItems(root: string, workspace: string, items: RemoteItem[],
|
|
|
542
553
|
for (const item of items) {
|
|
543
554
|
const previous = next.items[item.ref]
|
|
544
555
|
if (opts.merge?.has(item.ref)) {
|
|
545
|
-
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {})])].sort()
|
|
556
|
+
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {})])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
546
557
|
const result = mergeItemFiles(baseFiles(previous, opts.base), localFiles(root, paths), item.files)
|
|
547
558
|
if (result.conflicts) {
|
|
548
559
|
const files = result.files.filter((f) => f.conflicts.length).map((f) => f.path)
|
|
@@ -622,7 +633,7 @@ export function refreshConflict(root: string, workspace: string, item: RemoteIte
|
|
|
622
633
|
const previous = next.items[item.ref]
|
|
623
634
|
const remote = itemHash(item.files)
|
|
624
635
|
if (!previous?.conflict || previous.conflict.remote === remote) return { lock: next, outcome: 'unchanged', conflicts: previous?.conflict?.count ?? 0, written: [], removed: [] }
|
|
625
|
-
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous.files), ...previous.conflict.files])].sort()
|
|
636
|
+
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous.files), ...previous.conflict.files])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
626
637
|
const result = mergeItemFiles(baseFiles(previous, base), localFiles(root, paths), item.files)
|
|
627
638
|
const now = new Date().toISOString()
|
|
628
639
|
const written: string[] = []
|
|
@@ -662,7 +673,7 @@ export type Resolution = { edited: true } | { keep?: Side; picks?: Map<number, C
|
|
|
662
673
|
export function resolveItem(root: string, workspace: string, item: RemoteItem, lock: SyncLock | null, how: Resolution, base?: (path: string) => string | null): { written: string[]; removed: string[]; lock: SyncLock } {
|
|
663
674
|
const next: SyncLock = { version: 2, workspace, items: { ...(lock?.workspace === workspace ? lock.items : {}) } }
|
|
664
675
|
const previous = next.items[item.ref]
|
|
665
|
-
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {}), ...(previous?.conflict?.files ?? [])])].sort()
|
|
676
|
+
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {}), ...(previous?.conflict?.files ?? [])])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
666
677
|
const written: string[] = []
|
|
667
678
|
const removed: string[] = []
|
|
668
679
|
if (!('edited' in how)) {
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, join, posix } from 'node:path'
|
|
4
4
|
|
|
5
|
+
import { ignoreInFronteraDir } from '../project-context'
|
|
5
6
|
import matter from 'gray-matter'
|
|
6
7
|
import { parse as parseYaml } from 'yaml'
|
|
7
8
|
|
|
8
9
|
import { mimeFromPath } from '../commands/skill/bundle-commands'
|
|
9
10
|
import { writeFileAtomic } from '../fs-atomic'
|
|
10
11
|
import { CliError } from '../errors'
|
|
12
|
+
import { readWorkspaceGrants, WORKSPACE_GRANTS_FILE, WORKSPACE_GRANTS_NAME, WORKSPACE_GRANTS_REF } from './local-workspace-grants'
|
|
11
13
|
import { documentChanges, localDocuments, splitDocumentPath, type PulledDocument } from './local-knowledge-documents'
|
|
12
14
|
import { QUALITY_CHECKS_FILE, qualityChecksPath, readQualityChecks } from './local-quality-checks'
|
|
13
15
|
import { lessonsPath, lessonsProposal } from './local-lessons'
|
|
@@ -226,7 +228,7 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
|
|
|
226
228
|
// What the last pull wrote for it comes along when it is still here (an agent's files are fixed; the rest may not be).
|
|
227
229
|
if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
|
|
228
230
|
const files: Record<string, string> = {}
|
|
229
|
-
for (const path of [...new Set(paths)].sort()) {
|
|
231
|
+
for (const path of [...new Set(paths)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
|
|
230
232
|
if (path.endsWith('.workspace') || assets.includes(path)) continue
|
|
231
233
|
const text = readLocal(root, path)
|
|
232
234
|
if (text === null) continue
|
|
@@ -266,6 +268,8 @@ export function projectRefs(root: string): Map<string, { kind: SyncKind; name: s
|
|
|
266
268
|
for (const n of dirs('skills')) if (existsSync(join(root, 'skills', n, 'SKILL.md'))) add('skill', n)
|
|
267
269
|
for (const n of dirs('knowledge')) if (existsSync(join(root, 'knowledge', n, 'knowledge.json'))) add('knowledge', n)
|
|
268
270
|
for (const dir of BLUEPRINT_DIRS) for (const f of dirs(`blueprint/${dir}`)) if (/\.(ya?ml|json)$/.test(f)) add('blueprint', `${dir}/${f.replace(/\.(ya?ml|json)$/, '')}`)
|
|
271
|
+
// The workspace's own grants beyond read: one Blueprint item, `blueprint/workspace-grants`.
|
|
272
|
+
if (existsSync(join(root, WORKSPACE_GRANTS_FILE))) add('blueprint', WORKSPACE_GRANTS_NAME)
|
|
269
273
|
for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length))
|
|
270
274
|
for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length))
|
|
271
275
|
for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug)
|
|
@@ -368,7 +372,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
|
|
|
368
372
|
const here = projectRefs(root)
|
|
369
373
|
const refs = new Set<string>([...plan.map((p) => p.ref), ...here.keys()])
|
|
370
374
|
const out: PushItem[] = []
|
|
371
|
-
for (const ref of [...refs].sort()) {
|
|
375
|
+
for (const ref of [...refs].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
|
|
372
376
|
const slash = ref.indexOf('/')
|
|
373
377
|
const kind = (byPlan.get(ref)?.kind ?? here.get(ref)?.kind ?? ref.slice(0, slash)) as SyncKind
|
|
374
378
|
const name = byPlan.get(ref)?.name ?? here.get(ref)?.name ?? ref.slice(slash + 1)
|
|
@@ -400,7 +404,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
|
|
|
400
404
|
if (status === 'same' && Object.keys(files).length === 0) continue
|
|
401
405
|
const isNew = !entry && !theirs
|
|
402
406
|
const baseFiles: Record<string, string> = !isNew && theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.files : {}
|
|
403
|
-
const diffPaths = [...new Set([...Object.keys(baseFiles), ...Object.keys(files)])].sort()
|
|
407
|
+
const diffPaths = [...new Set([...Object.keys(baseFiles), ...Object.keys(files)])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
404
408
|
const diff: PushFile[] = diffPaths.map((path) => ({ path, base: baseFiles[path] ?? null, local: files[path] ?? null })).filter((f) => f.base !== f.local)
|
|
405
409
|
const item: PushItem = {
|
|
406
410
|
ref,
|
|
@@ -441,12 +445,15 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
|
|
|
441
445
|
else if (status === 'conflict') block('moved', BLOCK_REASON.moved)
|
|
442
446
|
else if (status === 'unresolved') block('conflicts', BLOCK_REASON.conflicts)
|
|
443
447
|
else if (deleted && NOT_DELETED[kind]) block('shape', NOT_DELETED[kind]!)
|
|
448
|
+
// Never proposed as a deletion: the platform refuses one, since deleting the file changes no grant.
|
|
449
|
+
else if (deleted && ref === WORKSPACE_GRANTS_REF) block('shape', `Deleting ${WORKSPACE_GRANTS_FILE} changes no grant: to take read_sensitive away, put the file back and list the type with \`permissions: [read]\``)
|
|
444
450
|
else if (status === 'deleted-remote') block('workspace-deleted', BLOCK_REASON['workspace-deleted'])
|
|
445
451
|
else if (deleted) {
|
|
446
452
|
// Proposable: Apply deletes it there, after everything that names it is updated.
|
|
447
453
|
} else if (problem) block('shape', problem)
|
|
448
454
|
else if (!Object.keys(files).length) block('shape', 'No files to propose')
|
|
449
455
|
else if (kind === 'blueprint' && Object.keys(files).length > 1) block('shape', `${Object.keys(files).join(' and ')}: keep one file per Blueprint artifact`)
|
|
456
|
+
else if (ref === WORKSPACE_GRANTS_REF && readWorkspaceGrants(root)?.problems.length) block('shape', readWorkspaceGrants(root)!.problems.join('; '))
|
|
450
457
|
else if (assetState?.changed && entry?.revision && theirs?.revision && entry.revision !== theirs.revision && textSame) {
|
|
451
458
|
// A pull never writes over an asset the folder has, so one the workspace changed since reads as a change here.
|
|
452
459
|
block('moved', 'Its assets changed in the workspace since your last pull: delete the copies here to bring the workspace’s, then propose')
|
|
@@ -577,12 +584,8 @@ const REQUESTS_DIR = join(SYNC_DIR, 'requests')
|
|
|
577
584
|
|
|
578
585
|
/** `.frontera/sync/` stays out of git, the requests with it. */
|
|
579
586
|
function ignoreSync(root: string): void {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : ''
|
|
583
|
-
if (text.split(/\r?\n/).some((l) => l.trim() === 'sync/' || l.trim() === 'sync')) return
|
|
584
|
-
if (text) appendFileSync(ignore, `${text.endsWith('\n') ? '' : '\n'}sync/\n`)
|
|
585
|
-
else writeFileSync(ignore, 'local/\nsync/\n')
|
|
587
|
+
// A pulled or linked project: its sync state, and this machine's profile binding, stay out of git.
|
|
588
|
+
ignoreInFronteraDir(root, ['local/', 'sync/'])
|
|
586
589
|
}
|
|
587
590
|
|
|
588
591
|
/** The requests file for this workspace; another workspace's (a relinked project) reads as none. */
|
|
@@ -626,7 +629,7 @@ export function recordRequest(root: string, workspace: string, view: RequestView
|
|
|
626
629
|
const file = readRequests(root, workspace)
|
|
627
630
|
const now = (opts.now ?? new Date()).toISOString()
|
|
628
631
|
const before = file.requests[view.request.id]
|
|
629
|
-
const refs = view.items.map((i) => i.ref).sort()
|
|
632
|
+
const refs = view.items.map((i) => i.ref).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
630
633
|
const titles = Object.fromEntries(refs.map((r) => [r, opts.titles?.[r] ?? before?.titles[r] ?? r]))
|
|
631
634
|
file.requests[view.request.id] = {
|
|
632
635
|
id: view.request.id,
|
|
@@ -713,7 +716,7 @@ export function sameDocument(path: string, a: string, b: string): boolean {
|
|
|
713
716
|
/** Quality checks as a set: by question, each one's lists in order of their words. */
|
|
714
717
|
function checkSet(text: string): unknown {
|
|
715
718
|
return readQualityChecks(text)
|
|
716
|
-
.map((c) => ({ ...c, documents: [...(c.documents ?? [])].sort(), keywords: [...(c.keywords ?? [])].sort() }))
|
|
719
|
+
.map((c) => ({ ...c, documents: [...(c.documents ?? [])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)), keywords: [...(c.keywords ?? [])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)) }))
|
|
717
720
|
.sort((x, y) => x.question.localeCompare(y.question))
|
|
718
721
|
}
|
|
719
722
|
|