@frontera-sdk/cli 1.53.5 → 1.53.6
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 +2 -2
- package/src/api/platform-api.ts +20 -0
- package/src/commands/app/checks.ts +1 -1
- package/src/commands/app/deploy.ts +9 -9
- package/src/commands/app/deployments.ts +1 -1
- package/src/commands/app/dev.ts +79 -14
- package/src/commands/app/promote.ts +1 -1
- package/src/commands/app/pull.ts +4 -2
- package/src/commands/app/rollback.ts +1 -1
- package/src/commands/app/save.ts +3 -7
- package/src/commands/app/shared.ts +59 -2
- package/src/commands/app/unpin.ts +1 -1
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/knowledge/index-commands.ts +15 -2
- package/src/commands/local/check.ts +21 -13
- package/src/commands/local/data.ts +1 -1
- package/src/commands/local/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/sync-request.ts +17 -3
- package/src/commands/local/sync.ts +4 -1
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +29 -11
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +6 -1
- package/src/mcp/mcp-apps.ts +96 -20
- package/src/mcp/tools/agent-tools.ts +29 -7
- package/src/mcp/tools/app-tools.ts +3 -3
- package/src/mcp/tools/project-tools.ts +2 -1
- package/src/project.ts +47 -11
- package/src/remote-guard.ts +4 -1
- package/src/templates/next-app-files.ts +4 -1
- package/src/templates/next-skills.ts +9 -5
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.53.
|
|
3
|
+
"version": "1.53.6",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
42
|
"@faker-js/faker": "10.6.0",
|
|
43
|
-
"@frontera-sdk/core": "1.53.
|
|
44
|
-
"@frontera-sdk/functions": "1.53.
|
|
43
|
+
"@frontera-sdk/core": "1.53.6",
|
|
44
|
+
"@frontera-sdk/functions": "1.53.6",
|
|
45
45
|
"ai": "^6.0.116",
|
|
46
46
|
"axe-core": "4.12.1",
|
|
47
47
|
"gray-matter": "^4.0.3",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"yaml": "^2.9.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@frontera-sdk/forge-contracts": "1.53.
|
|
52
|
+
"@frontera-sdk/forge-contracts": "1.53.6",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
package/src/api/apps-api.ts
CHANGED
|
@@ -159,8 +159,8 @@ export class AppsApi {
|
|
|
159
159
|
* `pull` used `ensureApp`, so a typo or a slug the platform had since
|
|
160
160
|
* renamed produced a brand new empty app instead of an error.
|
|
161
161
|
*/
|
|
162
|
-
resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string }> {
|
|
163
|
-
return this.client.request<{ id: string; slug: string; displayName: string }>(
|
|
162
|
+
resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }> {
|
|
163
|
+
return this.client.request<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }>(
|
|
164
164
|
`/v1/platform-apps/resolve/${encodeURIComponent(slugOrId)}`,
|
|
165
165
|
)
|
|
166
166
|
}
|
package/src/api/platform-api.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { MetricQueryBody, MetricQueryResult } from '../blueprint/metric-que
|
|
|
4
4
|
import { organizationHeaders, selectedOrganization } from '../organization'
|
|
5
5
|
|
|
6
6
|
import { CliError } from '../errors'
|
|
7
|
+
import type { SavedServer } from '../local/local-mcp-servers'
|
|
7
8
|
import { BLUEPRINT_SCHEMA_CAPABILITIES } from '../blueprint-types'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -291,6 +292,11 @@ export class PlatformApi {
|
|
|
291
292
|
return this.get<{ server: unknown }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`)
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
/** Created, or replaced in place: `created` says which. */
|
|
296
|
+
saveMcpServer(slug: string, definition: Record<string, unknown>): Promise<SavedServer> {
|
|
297
|
+
return this.client.request<SavedServer>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'PUT', body: definition })
|
|
298
|
+
}
|
|
299
|
+
|
|
294
300
|
/** Archived, not deleted: the Function runs its tools started still name it. */
|
|
295
301
|
archiveMcpServer(slug: string): Promise<{ slug: string; archived: boolean }> {
|
|
296
302
|
return this.client.request<{ slug: string; archived: boolean }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'DELETE' })
|
|
@@ -721,6 +727,20 @@ export class PlatformApi {
|
|
|
721
727
|
|
|
722
728
|
// ── Knowledge ─────────────────────────────────────────────────────────────
|
|
723
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Whether the platform runs the Knowledge app. Its routes answer 404 before any sign-in check where it does not, so
|
|
732
|
+
* any other answer, a refusal of this key included, means it runs. No answer counts as not running.
|
|
733
|
+
*/
|
|
734
|
+
async knowledgeAppRuns(): Promise<boolean> {
|
|
735
|
+
try {
|
|
736
|
+
await this.get<unknown>('/v1/knowledge-app/me')
|
|
737
|
+
return true
|
|
738
|
+
} catch (err) {
|
|
739
|
+
const status = (err as { status?: number }).status
|
|
740
|
+
return status !== undefined && status !== 404
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
724
744
|
knowledgeBases(workspaceId: string) {
|
|
725
745
|
return this.getList<unknown>(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`)
|
|
726
746
|
}
|
|
@@ -37,7 +37,7 @@ export const appChecks: Command = {
|
|
|
37
37
|
if (!deployment) throw new UsageError('say which deployment', 'frontera app checks <deployment id or label>')
|
|
38
38
|
const project = requireProjectFrom(ctx)
|
|
39
39
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
40
|
-
const app = await projectApp(client, project)
|
|
40
|
+
const app = await projectApp(ctx, client, project)
|
|
41
41
|
const res = await client.checks(app.id, deployment)
|
|
42
42
|
return { data: res, text: renderChecks(res.deployment, res.checks) }
|
|
43
43
|
},
|
|
@@ -9,14 +9,14 @@ import { resolveManifest } from '../../manifest'
|
|
|
9
9
|
import { collectSourceFiles, formatBytes } from '../../packaging'
|
|
10
10
|
import { packDirectory } from '../../pack'
|
|
11
11
|
import { collectAppContract } from '../../app-contract'
|
|
12
|
-
import {
|
|
12
|
+
import { writeState } from '../../project'
|
|
13
13
|
import { collectBuildProvenance } from '../../provenance'
|
|
14
14
|
import { validateStaticAppProject } from '../../static-app-validation'
|
|
15
15
|
import { describeRenderEvidence, readRenderEvidence, readRenderThumbnail, type RenderEvidence } from '../../render-evidence'
|
|
16
16
|
import { createTarGz } from '../../tar'
|
|
17
17
|
import { keepBuildWrittenFiles } from '../../local/local-app-pulled'
|
|
18
18
|
import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
|
|
19
|
-
import { requireProjectFrom } from './shared'
|
|
19
|
+
import { ensureProjectApp, requireProjectFrom } from './shared'
|
|
20
20
|
import { canPrompt } from '../../prompt'
|
|
21
21
|
import { flagBool, flagString, type Command } from '../types'
|
|
22
22
|
|
|
@@ -196,12 +196,14 @@ export const appDeploy: Command = {
|
|
|
196
196
|
noun: 'app',
|
|
197
197
|
verb: 'deploy',
|
|
198
198
|
args: [],
|
|
199
|
-
flags: { label: 'string', version: 'string', prod: 'boolean', preview: 'boolean', 'no-promote': 'boolean', 'skip-build-check': 'boolean', build: 'boolean' },
|
|
199
|
+
flags: { label: 'string', version: 'string', prod: 'boolean', preview: 'boolean', 'no-promote': 'boolean', 'skip-build-check': 'boolean', build: 'boolean', app: 'string' },
|
|
200
|
+
flagHelp: { app: 'an App this workspace already has, by its slug: deploy to it, and keep this folder deploying to it here' },
|
|
200
201
|
summary: 'Upload the build as a preview deployment; --build to build first, --prod to go live',
|
|
201
202
|
details: [
|
|
202
203
|
'A deploy is a preview unless --prod is given. A pipeline (a key, no terminal) must say which it wants: --prod or --preview. Without either it exits with an error, so a pipeline written when a deploy went live stops instead of silently making previews.',
|
|
204
|
+
'The folder deploys to the App it deployed to before in the same workspace, whatever its package.json name says now: a deploy never renames an App. In a workspace it has not deployed to, it deploys to the App named like the folder, and creates that App when there is none, which the output says.',
|
|
203
205
|
],
|
|
204
|
-
examples: ['frontera app deploy', 'frontera app deploy --build', 'frontera app deploy --label 1.4.0', 'frontera app deploy --prod', 'frontera app deploy --preview', 'frontera app deploy --prod --no-promote'],
|
|
206
|
+
examples: ['frontera app deploy', 'frontera app deploy --build', 'frontera app deploy --label 1.4.0', 'frontera app deploy --prod', 'frontera app deploy --preview', 'frontera app deploy --prod --no-promote', 'frontera app deploy --app acme-shipments'],
|
|
205
207
|
needsProject: true,
|
|
206
208
|
},
|
|
207
209
|
|
|
@@ -284,7 +286,7 @@ export const appDeploy: Command = {
|
|
|
284
286
|
}
|
|
285
287
|
|
|
286
288
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
287
|
-
const app = await
|
|
289
|
+
const app = await ensureProjectApp(ctx, client, project, flagString(ctx, 'app'))
|
|
288
290
|
|
|
289
291
|
ctx.output.note(
|
|
290
292
|
` bundle ${distFiles.length} files, ${formatBytes(distFiles.reduce((n, f) => n + f.bytes.byteLength, 0))}`,
|
|
@@ -342,10 +344,7 @@ export const appDeploy: Command = {
|
|
|
342
344
|
const deployment = res.deployment ?? res.version
|
|
343
345
|
|
|
344
346
|
// On the runtime again: whatever a database restore had taken away is back.
|
|
345
|
-
writeState(project.root, {
|
|
346
|
-
// Local environment binding: source remains portable and package.json
|
|
347
|
-
// remains ordinary package metadata.
|
|
348
|
-
writeAppId(project.root, app.id)
|
|
347
|
+
writeState(project.root, { parentVersion: deployment, restoredAway: undefined })
|
|
349
348
|
|
|
350
349
|
// Where a person opens what was just made: its own link, and the moving
|
|
351
350
|
// links of its branch and session. A preview was once described as one
|
|
@@ -360,6 +359,7 @@ export const appDeploy: Command = {
|
|
|
360
359
|
const data = {
|
|
361
360
|
app: app.slug,
|
|
362
361
|
appId: app.id,
|
|
362
|
+
...(app.created ? { created: app.created } : {}),
|
|
363
363
|
deployment,
|
|
364
364
|
// The same id, under its earlier name, for scripts that read `version`.
|
|
365
365
|
version: deployment,
|
|
@@ -72,7 +72,7 @@ export const appDeployments: Command = {
|
|
|
72
72
|
async run(ctx) {
|
|
73
73
|
const project = requireProjectFrom(ctx)
|
|
74
74
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
75
|
-
const app = await projectApp(client, project)
|
|
75
|
+
const app = await projectApp(ctx, client, project)
|
|
76
76
|
const ledger = await client.deployments(app.id)
|
|
77
77
|
// The checks of the newest deployments that still have files; an older or
|
|
78
78
|
// failed read falls back to the render evidence the ledger already carries.
|
package/src/commands/app/dev.ts
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { AppsApi } from '../../api/apps-api'
|
|
5
5
|
import { PlatformApi } from '../../api/platform-api'
|
|
6
6
|
import { createDevSessionBroker, freeLoopbackPort, isLoopbackPortFree } from '../../dev-broker'
|
|
7
7
|
import { CliError, UsageError } from '../../errors'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DEV_OWNER_ENV,
|
|
10
|
+
devServerHome,
|
|
11
|
+
devServerLine,
|
|
12
|
+
enclosingSolution,
|
|
13
|
+
ensureDevServer,
|
|
14
|
+
forgetDevServer,
|
|
15
|
+
readDevServer,
|
|
16
|
+
registerDevServer,
|
|
17
|
+
stopDevServerAndSay,
|
|
18
|
+
watchOwner,
|
|
19
|
+
} from '../../mcp/mcp-apps'
|
|
9
20
|
import { ensureAppPackages } from '../../project-bootstrap'
|
|
10
21
|
import { detectAppFramework } from '../../provenance'
|
|
11
|
-
import {
|
|
22
|
+
import type { AppProject } from '../../context'
|
|
23
|
+
import { bindApp } from '../../project'
|
|
12
24
|
import { mintDevSession, writeDevSessionState } from '../../local/local-app-dev-session'
|
|
13
25
|
import { keepDevWrittenFiles, leftLine, putBackLine } from '../../local/local-app-dev-files'
|
|
14
26
|
import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
|
|
15
|
-
import { requireProjectFrom } from './shared'
|
|
16
|
-
import { flagBool, flagString, type Command } from '../types'
|
|
27
|
+
import { ensureProjectApp, requireProjectFrom } from './shared'
|
|
28
|
+
import { flagBool, flagString, type Command, type CommandContext, type CommandResult } from '../types'
|
|
17
29
|
|
|
18
30
|
const DEFAULT_PORT = 3000
|
|
19
31
|
const READY_TIMEOUT_MS = 30_000
|
|
@@ -74,19 +86,69 @@ function withoutOwner(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
|
74
86
|
return rest as Record<string, string>
|
|
75
87
|
}
|
|
76
88
|
|
|
89
|
+
/**
|
|
90
|
+
* `--detach`: the same server, started by `ensureDevServer` in its own session with its output in a log, so it
|
|
91
|
+
* outlives the shell that started it (an agent's background command is killed at its time limit, with its whole
|
|
92
|
+
* process group). Recorded where `--stop`, `local stop`, MCP `app_dev` and Forge find it; a live one is reused.
|
|
93
|
+
*/
|
|
94
|
+
async function detach(ctx: CommandContext, project: AppProject): Promise<CommandResult> {
|
|
95
|
+
const raw = flagString(ctx, 'port')
|
|
96
|
+
const port = raw === undefined ? 0 : appPort(raw)
|
|
97
|
+
// Here, not in the server's log: a creation is said where the caller reads it, and a bad credential fails now.
|
|
98
|
+
const app = await ensureProjectApp(ctx, new AppsApi(ctx.apiUrl, ctx.token), project)
|
|
99
|
+
const home = devServerHome(project.root)
|
|
100
|
+
const ref = { slug: project.slug, name: project.displayName, root: project.root, path: relative(home.root, project.root) || '.', framework: detectAppFramework(project.root) }
|
|
101
|
+
let started: Awaited<ReturnType<typeof ensureDevServer>>
|
|
102
|
+
try {
|
|
103
|
+
started = await ensureDevServer(home, ref, { env: { FRONTERA_API_URL: ctx.apiUrl }, token: ctx.token, target: app.target }, { owner: 'detached', port })
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new CliError((error as Error).message, { code: 'FAILURE', cause: error, hint: '`frontera app dev` without --detach runs the server in the foreground, where its output shows as it starts' })
|
|
106
|
+
}
|
|
107
|
+
const { server, reused, installed, trackedFiles } = started
|
|
108
|
+
if (installed) ctx.output.note(`[dev] ${installed}`)
|
|
109
|
+
for (const line of trackedFiles ?? []) ctx.output.note(`[dev] ${line}`)
|
|
110
|
+
const logFile = join(home.root, server.log)
|
|
111
|
+
const log = existsSync(logFile) ? relative(ctx.cwd, logFile) : server.log
|
|
112
|
+
return {
|
|
113
|
+
data: { appId: app.id, origin: server.url, port: server.port, pid: server.pid, log, reused },
|
|
114
|
+
text: [
|
|
115
|
+
reused ? `Reused the dev server already running: ${devServerLine(server)}.` : `${app.slug} runs at ${server.url} in its own session: it keeps running after this shell ends.`,
|
|
116
|
+
` log: ${log}`,
|
|
117
|
+
' stop it: `frontera app dev --stop` in this folder',
|
|
118
|
+
].join('\n'),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `--stop`: the App's recorded dev server, however it was started, and what it put back as it stopped. */
|
|
123
|
+
async function stopRecorded(ctx: CommandContext, project: AppProject): Promise<CommandResult> {
|
|
124
|
+
const home = devServerHome(project.root)
|
|
125
|
+
const server = readDevServer(home, project.slug)
|
|
126
|
+
const { stopped, trackedFiles } = await stopDevServerAndSay(home, project.slug)
|
|
127
|
+
for (const line of trackedFiles) ctx.output.note(`[dev] ${line}`)
|
|
128
|
+
if (!stopped || !server) return { data: { stopped: false }, text: `${project.slug} had no dev server running.` }
|
|
129
|
+
return { data: { stopped: true, port: server.port, pid: server.pid }, text: `Stopped ${project.slug}'s dev server (port ${server.port}).` }
|
|
130
|
+
}
|
|
131
|
+
|
|
77
132
|
export const appDev: Command = {
|
|
78
133
|
meta: {
|
|
79
134
|
noun: 'app',
|
|
80
135
|
verb: 'dev',
|
|
81
136
|
args: [],
|
|
82
|
-
flags: { port: 'string', 'no-open': 'boolean' },
|
|
137
|
+
flags: { port: 'string', 'no-open': 'boolean', detach: 'boolean', stop: 'boolean' },
|
|
138
|
+
flagHelp: { stop: 'stop this App\'s dev server, however it was started, and exit' },
|
|
83
139
|
summary: 'Run an App locally with short-lived authenticated Blueprint access',
|
|
84
|
-
|
|
140
|
+
details: [
|
|
141
|
+
'Without --detach the server runs in the foreground until you stop it (Ctrl+C). With --detach it starts in its own session, returns once the App answers, and prints its URL, its log and how to stop it: use it from a coding agent, whose background commands end at a time limit. Run it again to get the same server back.',
|
|
142
|
+
],
|
|
143
|
+
examples: ['frontera app dev', 'frontera app dev --detach', 'frontera app dev --stop', 'frontera app dev --port 3210 --no-open', 'frontera app dev --port 0'],
|
|
85
144
|
needsProject: true,
|
|
86
145
|
},
|
|
87
146
|
|
|
88
147
|
async run(ctx) {
|
|
89
148
|
const project = requireProjectFrom(ctx)
|
|
149
|
+
if (flagBool(ctx, 'detach') && flagBool(ctx, 'stop')) throw new UsageError('--detach and --stop do opposite things', 'start it with `frontera app dev --detach`, stop it with `frontera app dev --stop`')
|
|
150
|
+
if (flagBool(ctx, 'stop')) return stopRecorded(ctx, project)
|
|
151
|
+
if (flagBool(ctx, 'detach')) return detach(ctx, project)
|
|
90
152
|
const asked = appPort(flagString(ctx, 'port'))
|
|
91
153
|
// `--port 0`: one free on every loopback, said with the URL below.
|
|
92
154
|
const port = asked === 0 ? await freeLoopbackPort() : asked
|
|
@@ -109,10 +171,10 @@ export const appDev: Command = {
|
|
|
109
171
|
}
|
|
110
172
|
if (installed) ctx.output.note(`[dev] ${installed.summary}`)
|
|
111
173
|
const apps = new AppsApi(ctx.apiUrl, ctx.token)
|
|
112
|
-
const app = await
|
|
113
|
-
writeAppId(project.root, app.id)
|
|
174
|
+
const app = await ensureProjectApp(ctx, apps, project)
|
|
114
175
|
// The solution this App is in, if any: where status reads how this server's sessions fare.
|
|
115
176
|
const solution = enclosingSolution(project.root)
|
|
177
|
+
const home = devServerHome(project.root)
|
|
116
178
|
// The id sessions are minted for: a restore to a baseline from before the App existed takes it away.
|
|
117
179
|
let appId = app.id
|
|
118
180
|
|
|
@@ -132,12 +194,12 @@ export const appDev: Command = {
|
|
|
132
194
|
appId,
|
|
133
195
|
mint: (id) => apps.appToken(id),
|
|
134
196
|
// Gone from the runtime (`local snapshot restore`): registered again by its slug, as `app dev` started.
|
|
135
|
-
reRegister: async () => (await apps.ensureApp(
|
|
197
|
+
reRegister: async () => (await apps.ensureApp(app.slug, project.displayName, null, { icon: project.icon })).id,
|
|
136
198
|
onReRegistered: (id) => {
|
|
137
199
|
if (id === appId) return
|
|
138
200
|
ctx.output.note(`[dev] the runtime no longer had ${app.slug} (a restore of its database?): registered it again as ${id}; \`frontera app deploy\` puts its code back`)
|
|
139
201
|
appId = id
|
|
140
|
-
|
|
202
|
+
bindApp(project.root, app.target, id)
|
|
141
203
|
},
|
|
142
204
|
record: solution ? (state) => writeDevSessionState(solution.stateDir, project.slug, state) : undefined,
|
|
143
205
|
})
|
|
@@ -235,8 +297,11 @@ export const appDev: Command = {
|
|
|
235
297
|
const back = putBackLine(kept.afterReady(), 'started')
|
|
236
298
|
if (back) ctx.output.note(`[dev] ${back}`)
|
|
237
299
|
ctx.output.note(`[dev] ready: ${targetUrl}`)
|
|
238
|
-
//
|
|
239
|
-
|
|
300
|
+
// Said where agents, Forge and `--detach` look, so they use this server instead of starting another. One
|
|
301
|
+
// `--detach` started is already recorded, with its log; a live one another process recorded stays.
|
|
302
|
+
if (!owner && !readDevServer(home, project.slug)) {
|
|
303
|
+
registerDevServer(home, { slug: project.slug, pid: process.pid, port, url: targetUrl, startedAt: new Date().toISOString(), log: 'the terminal running frontera app dev', ...(app.target ? { target: app.target } : {}) })
|
|
304
|
+
}
|
|
240
305
|
if (!flagBool(ctx, 'no-open')) openBrowser(targetUrl)
|
|
241
306
|
const exitCode = await child.exited
|
|
242
307
|
if (!stopped && exitCode !== 0) {
|
|
@@ -253,7 +318,7 @@ export const appDev: Command = {
|
|
|
253
318
|
unwatch?.()
|
|
254
319
|
process.off('SIGINT', onSignal)
|
|
255
320
|
process.off('SIGTERM', onSignal)
|
|
256
|
-
|
|
321
|
+
forgetDevServer(home, project.slug, process.pid)
|
|
257
322
|
stop()
|
|
258
323
|
// Once the framework's server has exited (or after a while, whatever it does), what it wrote goes back.
|
|
259
324
|
await Promise.race([child.exited, Bun.sleep(STOP_WAIT_MS)])
|
|
@@ -35,7 +35,7 @@ export const appPromote: Command = {
|
|
|
35
35
|
if (force && !reason?.trim()) throw new UsageError('--force needs --reason', 'frontera app promote <deployment> --force --reason "why the checks can wait"')
|
|
36
36
|
const project = requireProjectFrom(ctx)
|
|
37
37
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
38
|
-
const app = await projectApp(client, project)
|
|
38
|
+
const app = await projectApp(ctx, client, project)
|
|
39
39
|
try {
|
|
40
40
|
const res = await client.promote(app.id, { deployment, ...(force ? { force: true, reason } : {}) })
|
|
41
41
|
const name = res.label ?? res.promoted
|
package/src/commands/app/pull.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { AppsApi } from '../../api/apps-api'
|
|
4
4
|
import { CliError, UsageError } from '../../errors'
|
|
5
5
|
import { healProject } from '../../heal'
|
|
6
|
-
import { dirtyFiles, writeState } from '../../project'
|
|
6
|
+
import { bindApp, dirtyFiles, writeState } from '../../project'
|
|
7
7
|
import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
|
|
8
8
|
import { flagBool, type Command } from '../types'
|
|
9
|
+
import { credentialTarget } from './shared'
|
|
9
10
|
import { unpackTo } from '../../untar'
|
|
10
11
|
|
|
11
12
|
export const appPull: Command = {
|
|
@@ -96,7 +97,8 @@ export const appPull: Command = {
|
|
|
96
97
|
const count = unpackTo(dir, bytes)
|
|
97
98
|
// What was pulled, apart from a deployment from here: the folder is not read as deployed until `app deploy`,
|
|
98
99
|
// whose parent is this version (`readProject`). A deployment recorded before this pull no longer describes the tree.
|
|
99
|
-
writeState(dir, {
|
|
100
|
+
writeState(dir, { parentVersion: null, pulledFrom: app.id, pulledVersion: parentVersion })
|
|
101
|
+
bindApp(dir, await credentialTarget(ctx), app.id)
|
|
100
102
|
|
|
101
103
|
// Pull's contract is "give me a working copy", and a copy that cannot
|
|
102
104
|
// `bun install` is not one. Apps stored by an older toolchain carry
|
|
@@ -25,7 +25,7 @@ export const appRollback: Command = {
|
|
|
25
25
|
if (!undo && !reason?.trim()) throw new UsageError('a rollback needs --reason', 'frontera app rollback --reason "what went wrong"')
|
|
26
26
|
const project = requireProjectFrom(ctx)
|
|
27
27
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
28
|
-
const app = await projectApp(client, project)
|
|
28
|
+
const app = await projectApp(ctx, client, project)
|
|
29
29
|
if (undo) {
|
|
30
30
|
const res = await client.undoRollback(app.id)
|
|
31
31
|
return {
|
package/src/commands/app/save.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { AppsApi } from '../../api/apps-api'
|
|
2
2
|
import { packDirectory } from '../../pack'
|
|
3
|
-
import {
|
|
4
|
-
import { requireProjectFrom } from './shared'
|
|
3
|
+
import { ensureProjectApp, requireProjectFrom } from './shared'
|
|
5
4
|
import type { Command } from '../types'
|
|
6
5
|
|
|
7
6
|
export const appSave: Command = {
|
|
@@ -21,15 +20,12 @@ export const appSave: Command = {
|
|
|
21
20
|
|
|
22
21
|
// save and deploy are the only verbs that may bring an app into
|
|
23
22
|
// existence; every read verb resolves instead.
|
|
24
|
-
const app = await
|
|
23
|
+
const app = await ensureProjectApp(ctx, client, project)
|
|
25
24
|
const { tgz } = packDirectory(project.root, ctx.output, 'packaged')
|
|
26
25
|
const res = await client.saveDraft(app.id, tgz, project.parentVersion)
|
|
27
26
|
|
|
28
|
-
writeState(project.root, { appId: app.id })
|
|
29
|
-
writeAppId(project.root, app.id)
|
|
30
|
-
|
|
31
27
|
return {
|
|
32
|
-
data: { app: app.slug, appId: app.id, fileCount: res.fileCount },
|
|
28
|
+
data: { app: app.slug, appId: app.id, ...(app.created ? { created: app.created } : {}), fileCount: res.fileCount },
|
|
33
29
|
text: `Saved draft for ${app.slug} (${res.fileCount} files)`,
|
|
34
30
|
}
|
|
35
31
|
},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AppsApi } from '../../api/apps-api'
|
|
2
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
2
3
|
import { UsageError } from '../../errors'
|
|
4
|
+
import { appBinding, appTarget, bindApp } from '../../project'
|
|
3
5
|
import type { AppProject } from '../../context'
|
|
4
6
|
import type { CommandContext } from '../types'
|
|
5
7
|
|
|
@@ -13,6 +15,16 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
|
|
|
13
15
|
return ctx.project
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The target this credential reaches (`appTarget`): its workspace as `whoami`
|
|
20
|
+
* answers, asked the way `AppsApi` asks, so it is the workspace the App calls
|
|
21
|
+
* land in. Null when it cannot say; the folder's bindings are then left alone.
|
|
22
|
+
*/
|
|
23
|
+
export async function credentialTarget(ctx: CommandContext): Promise<string | null> {
|
|
24
|
+
const me = await new PlatformApi(ctx.apiUrl, ctx.token).whoami().catch(() => null)
|
|
25
|
+
return me?.workspaceId ? appTarget(ctx.apiUrl, me.workspaceId) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* The app this project belongs to, without creating one.
|
|
18
30
|
*
|
|
@@ -20,6 +32,51 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
|
|
|
20
32
|
* that ensured instead would turn a typo, or a slug the platform had since
|
|
21
33
|
* renamed, into a brand new empty app.
|
|
22
34
|
*/
|
|
23
|
-
export function projectApp(client: AppsApi, project: AppProject) {
|
|
24
|
-
|
|
35
|
+
export async function projectApp(ctx: CommandContext, client: AppsApi, project: AppProject) {
|
|
36
|
+
const bound = appBinding(project.root, await credentialTarget(ctx)).id
|
|
37
|
+
return client.resolveApp(bound ?? project.fallbackAppId ?? project.slug)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The folder's App where the credential acts, created when it has none there
|
|
42
|
+
* (`save`, `deploy`, `app dev`), and bound to the folder for that target. A
|
|
43
|
+
* creation is said: a person who renamed the folder or moved it to another
|
|
44
|
+
* workspace must not learn of a second App from the gallery. `app` names an
|
|
45
|
+
* existing App to deploy to instead, which never creates one.
|
|
46
|
+
*/
|
|
47
|
+
export async function ensureProjectApp(
|
|
48
|
+
ctx: CommandContext,
|
|
49
|
+
client: AppsApi,
|
|
50
|
+
project: AppProject,
|
|
51
|
+
app?: string,
|
|
52
|
+
): Promise<{ id: string; slug: string; created: string | null; target: string | null }> {
|
|
53
|
+
const target = await credentialTarget(ctx)
|
|
54
|
+
const known = appBinding(project.root, target)
|
|
55
|
+
const named = app ? await client.resolveApp(app) : null
|
|
56
|
+
// Refused before the folder is bound to it: every later deploy would send its id, and fail the same way.
|
|
57
|
+
if (named?.kind && named.kind !== 'code') {
|
|
58
|
+
throw new UsageError(`${named.slug} is a Chat App, and a folder deploys to a Custom App`, '`frontera app list` names the Apps; pass a Custom App\'s slug to `--app`, or leave `--app` out to deploy to the folder\'s own')
|
|
59
|
+
}
|
|
60
|
+
const ensured = named
|
|
61
|
+
? { ...named, created: false }
|
|
62
|
+
: await client.ensureApp(project.slug, project.displayName, known.id ?? project.fallbackAppId, { icon: project.icon })
|
|
63
|
+
bindApp(project.root, target, ensured.id)
|
|
64
|
+
const created = ensured.created ? createdApp(ensured.slug, known.id ? 'gone' : known.elsewhere || project.fallbackAppId ? 'elsewhere' : 'first') : null
|
|
65
|
+
if (created) ctx.output.note(` ${created}`)
|
|
66
|
+
// The platform owns the name once the App exists (App settings renames it); the folder's name only creates one.
|
|
67
|
+
else if (ensured.slug !== project.slug) ctx.output.note(` bound to the App ${ensured.slug} here: this folder's own name, ${project.slug}, names an App only on its first deploy to a workspace and never renames one (rename it in Apps, App settings)`)
|
|
68
|
+
return { id: ensured.id, slug: ensured.slug, created, target }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a creation says: plain for a folder's first deploy anywhere. A folder
|
|
73
|
+
* that has an App elsewhere, or had one here, gets the way back too, since
|
|
74
|
+
* this workspace may hold its App under another name.
|
|
75
|
+
*/
|
|
76
|
+
function createdApp(slug: string, before: 'first' | 'gone' | 'elsewhere'): string {
|
|
77
|
+
if (before === 'first') return `created the App ${slug} in this workspace`
|
|
78
|
+
const why = before === 'gone' ? ': the App this folder deployed to here is gone' : ', this folder\'s first deploy here (it has deployed to other workspaces)'
|
|
79
|
+
return `created a new App ${slug} in this workspace${why}. `
|
|
80
|
+
+ 'If this workspace already had the App under another name, deploy to that one instead with `frontera app deploy --app <slug>` (`frontera app list` names them) '
|
|
81
|
+
+ `and archive ${slug} in Apps. A deploy never renames an App: rename one in Apps, App settings.`
|
|
25
82
|
}
|
|
@@ -20,7 +20,7 @@ export const appUnpin: Command = {
|
|
|
20
20
|
if (!reason?.trim()) throw new UsageError('unpinning needs --reason', 'frontera app unpin --reason "why production may move again"')
|
|
21
21
|
const project = requireProjectFrom(ctx)
|
|
22
22
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
23
|
-
const app = await projectApp(client, project)
|
|
23
|
+
const app = await projectApp(ctx, client, project)
|
|
24
24
|
const res = await client.unpin(app.id, reason)
|
|
25
25
|
return { data: { app: app.slug, appId: app.id, ...res }, text: `Unpinned ${app.slug}: production keeps serving ${res.productionLabel ?? res.production ?? 'what it serves'}, and promotion works again` }
|
|
26
26
|
},
|
|
@@ -17,7 +17,7 @@ export const appVersions: Command = {
|
|
|
17
17
|
async run(ctx) {
|
|
18
18
|
const project = requireProjectFrom(ctx)
|
|
19
19
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
20
|
-
const app = await projectApp(client, project)
|
|
20
|
+
const app = await projectApp(ctx, client, project)
|
|
21
21
|
const versions = await client.listVersions(app.id)
|
|
22
22
|
|
|
23
23
|
return {
|
|
@@ -26,6 +26,19 @@ interface KnowledgeRow {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* These commands manage the knowledge bases Agents attach. The Knowledge app's
|
|
31
|
+
* bases (organization and workspace) are another thing with no command or
|
|
32
|
+
* project file, and no local runtime runs the app. Said only where the
|
|
33
|
+
* platform runs the app: elsewhere there is no such base to mistake for one.
|
|
34
|
+
*/
|
|
35
|
+
async function knowledgeAppNote(api: PlatformApi): Promise<string> {
|
|
36
|
+
return (await api.knowledgeAppRuns())
|
|
37
|
+
? ' A knowledge base from the Knowledge app is not one of these: it runs on the platform only, not on a local runtime. '
|
|
38
|
+
+ 'Choose it for an Agent on the platform, under the Agent\'s Knowledge page, and test that Agent there.'
|
|
39
|
+
: ''
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/** Accept a base by name or id — `knowledge list` shows both. */
|
|
30
43
|
async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
|
|
31
44
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
@@ -38,7 +51,7 @@ async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<st
|
|
|
38
51
|
|
|
39
52
|
throw new CliError(`no knowledge base named "${ref}"`, {
|
|
40
53
|
code: 'NOT_FOUND',
|
|
41
|
-
hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}`,
|
|
54
|
+
hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}.${await knowledgeAppNote(client)}`,
|
|
42
55
|
})
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -61,7 +74,7 @@ const list: Command = {
|
|
|
61
74
|
data: rows,
|
|
62
75
|
text:
|
|
63
76
|
rows.length === 0
|
|
64
|
-
?
|
|
77
|
+
? `No knowledge bases in this workspace.${await knowledgeAppNote(api)}`
|
|
65
78
|
: // NAME first: it is the value `knowledge sources` takes, not the id.
|
|
66
79
|
// No `displayName` on these rows, so a column for it was always
|
|
67
80
|
// blank and shifted the description out of alignment.
|
|
@@ -12,7 +12,7 @@ import { appPulledAsIs, keepBuildWrittenFiles } from '../../local/local-app-pull
|
|
|
12
12
|
import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
|
|
13
13
|
import { repullRefFor } from '../../local/local-app-code'
|
|
14
14
|
import { customAppFolders } from '../../local/local-apps'
|
|
15
|
-
import { readBaseline, restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
|
|
15
|
+
import { endCheckBaseline, readBaseline, restoreBaseline, restoreCutOffCheck, saveBaseline } from '../../local/local-db-snapshot'
|
|
16
16
|
import { localIngestApi, settleKnowledge, type KnowledgeSettle } from '../../local/local-knowledge-ingestion'
|
|
17
17
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
18
18
|
import { projectSecretLines } from '../../local/local-project-secrets'
|
|
@@ -53,12 +53,12 @@ import { flagBool, flagString, type Command, type CommandContext } from '../type
|
|
|
53
53
|
*
|
|
54
54
|
* The tests write for real (a workflow test always does, as does an Agent
|
|
55
55
|
* case with `realActions` or a `decision`), so after the apply the database
|
|
56
|
-
* is kept as a baseline (`local-db-snapshot.ts`,
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* runs, evaluations) is kept across it
|
|
60
|
-
* check's results can be compared with the
|
|
61
|
-
* tests wrote, to look at it.
|
|
56
|
+
* is kept as a baseline (`local-db-snapshot.ts`, a new copy every check), and
|
|
57
|
+
* after the tests it is restored: the tests' writes are rolled back, and what
|
|
58
|
+
* was written before the check (a person's sign-up, an App's records) stays.
|
|
59
|
+
* The run history (conversations, runs, evaluations) is kept across it
|
|
60
|
+
* (`local-history-carry.ts`), so one check's results can be compared with the
|
|
61
|
+
* last. `--keep-state` keeps what the tests wrote, to look at it.
|
|
62
62
|
*
|
|
63
63
|
* Knowledge documents are read by a background job whose work lands in the
|
|
64
64
|
* database: before the baseline and the tests, the check waits (bounded)
|
|
@@ -104,10 +104,8 @@ export function blockedBy(step: CheckStepName, failed: ReadonlySet<CheckStepName
|
|
|
104
104
|
/** What `--skip` may leave out: the runtime and the apply are the gate itself. */
|
|
105
105
|
const SKIPPABLE: readonly CheckStepName[] = ['typecheck', 'unit', 'apps', 'agents', 'workflows']
|
|
106
106
|
|
|
107
|
-
/** What became of the tests' writes: rolled back to the
|
|
107
|
+
/** What became of the tests' writes: rolled back to the baseline taken before them, kept, or not rolled back and why. */
|
|
108
108
|
export interface CheckTestData {
|
|
109
|
-
/** taken: a new baseline after this apply; reused: the stored one is of this build. */
|
|
110
|
-
baseline?: 'taken' | 'reused'
|
|
111
109
|
rolledBack: boolean
|
|
112
110
|
/** --keep-state */
|
|
113
111
|
kept?: boolean
|
|
@@ -406,6 +404,13 @@ async function check(ctx: CommandContext) {
|
|
|
406
404
|
continue
|
|
407
405
|
}
|
|
408
406
|
if (step === 'apply') {
|
|
407
|
+
// A check cut off before its rollback left its tests' writes: its baseline goes back before this apply (within the hour).
|
|
408
|
+
const before = readState(project)
|
|
409
|
+
if (before?.credentials && running(project)) {
|
|
410
|
+
const cut = await restoreCutOffCheck(project, before.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${before.ports.service}`) })
|
|
411
|
+
if (cut?.left) ctx.output.note(`── test data: ${cut.note}`)
|
|
412
|
+
else if (cut) ctx.output.note(`── test data: the last \`frontera local check\` stopped before it rolled back its tests’ writes, ${cut.ok ? 'so that is done first' : 'and they couldn’t be rolled back now'}: ${cut.note}`)
|
|
413
|
+
}
|
|
409
414
|
say(step, 'frontera local dev --once')
|
|
410
415
|
// Which Agents serve customers: an operating.json leaves an Agent out of the built-in Chat (X4).
|
|
411
416
|
if (customerFacing.length) ctx.output.note(` note: ${customerFacingWords(customerFacing)}`)
|
|
@@ -448,9 +453,9 @@ async function check(ctx: CommandContext) {
|
|
|
448
453
|
if (state?.credentials && running(project) && testsWillRun()) {
|
|
449
454
|
if (keepState) testData = { rolledBack: false, kept: true, note: 'kept what the tests write (--keep-state)' }
|
|
450
455
|
else {
|
|
451
|
-
const saved = await saveBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
|
|
456
|
+
const saved = await saveBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, 'check')
|
|
452
457
|
baselineReady = saved.ok
|
|
453
|
-
if (saved.ok) testData = {
|
|
458
|
+
if (saved.ok) testData = { rolledBack: false, note: saved.note }
|
|
454
459
|
else testData = { rolledBack: false, note: `not rolled back: ${saved.note}` }
|
|
455
460
|
ctx.output.note(` test data: ${saved.ok ? saved.note : `couldn’t keep a baseline, so what the tests write stays: ${saved.note}`}`)
|
|
456
461
|
}
|
|
@@ -666,7 +671,10 @@ async function check(ctx: CommandContext) {
|
|
|
666
671
|
secretNotes.push(...lost)
|
|
667
672
|
}
|
|
668
673
|
}
|
|
669
|
-
} else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); the next `frontera local check` rolls
|
|
674
|
+
} else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); they stay: the next `frontera local check` rolls back only its own tests’ writes')
|
|
675
|
+
// A baseline taken for tests that then did not run holds nothing of theirs to put back.
|
|
676
|
+
const ended = readState(project)
|
|
677
|
+
if (baselineReady && !testsRan && ended?.credentials) endCheckBaseline(project, ended.credentials.organizationId)
|
|
670
678
|
const testDataField = { ...(testData && testsRan ? { testData } : {}), ...(customerFacing.length ? { customerFacing } : {}) }
|
|
671
679
|
|
|
672
680
|
if (flagBool(ctx, 'stop')) {
|
|
@@ -114,7 +114,7 @@ async function loadCommand(ctx: Parameters<Command['run']>[0]) {
|
|
|
114
114
|
const organizationId = state.credentials.organizationId
|
|
115
115
|
const snapshot = !flagBool(ctx, 'no-snapshot')
|
|
116
116
|
if (snapshot) {
|
|
117
|
-
const saved = await ctx.output.task('Saving the records first', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) },
|
|
117
|
+
const saved = await ctx.output.task('Saving the records first', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) }, 'data'))
|
|
118
118
|
if (!saved.ok) throw new CliError(`the records could not be saved first, so nothing was loaded: ${saved.note}`, { code: 'LOCAL_SNAPSHOT_FAILED', hint: '`--no-snapshot` loads without saving them' })
|
|
119
119
|
}
|
|
120
120
|
const persona = await actAsLocalPersona({ meta: { noun: 'action', verb: 'submit' }, cwd: ctx.cwd, apiUrl: serviceUrl, wanted: 'submitter' })
|
|
@@ -489,7 +489,7 @@ async function status(ctx: CommandContext) {
|
|
|
489
489
|
docs: docsStatus(project, { mode: state.mode, platformVersion: state.platformVersion, platformSource: readConfig(project)?.platformSource }),
|
|
490
490
|
notices: projectNotices(project.root),
|
|
491
491
|
// The App dev servers running against this runtime, with their ports: `frontera local stop` stops them.
|
|
492
|
-
appDevServers: devServers.map((s) => ({ app: s.slug, port: s.port, url: s.url, pid: s.pid, startedAt: s.startedAt, startedBy: s.owner?.kind ?? 'hand', ...(s.owner ? { ownerPid: s.owner.pid } : {}) })),
|
|
492
|
+
appDevServers: devServers.map((s) => ({ app: s.slug, port: s.port, url: s.url, pid: s.pid, startedAt: s.startedAt, startedBy: s.owner?.kind ?? 'hand', ...(s.owner && 'pid' in s.owner ? { ownerPid: s.owner.pid } : {}) })),
|
|
493
493
|
// Knowledge documents pending or processing with no ingestion behind them, each with its fix.
|
|
494
494
|
...(stuck.length ? { knowledgeStuck: stuck } : {}),
|
|
495
495
|
// On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).
|