@frontera-sdk/cli 1.53.21 → 1.53.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/commands/app/dev.ts +10 -1
- package/src/commands/local/dev.ts +36 -11
- package/src/commands/registry.ts +3 -0
- package/src/commands/telemetry/status.ts +39 -0
- package/src/local/local-supervisor.ts +9 -4
- package/src/main.ts +78 -0
- package/src/mcp/mcp-apps.ts +3 -1
- package/src/mcp/mcp-protocol.ts +33 -2
- package/src/telemetry/agent-events.ts +79 -0
- package/src/telemetry/apply-events.ts +142 -0
- package/src/telemetry/cli-telemetry.ts +102 -0
- package/src/telemetry/device.ts +64 -0
- package/src/telemetry/enabled.ts +49 -0
- package/src/telemetry/events.ts +294 -0
- package/src/telemetry/fail-open.ts +44 -0
- package/src/telemetry/otlp.ts +112 -0
- package/src/telemetry/outbox.ts +175 -0
- package/src/telemetry/recorder.ts +114 -0
- package/src/telemetry/redact.ts +108 -0
- package/src/telemetry/send.ts +121 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.53.
|
|
3
|
+
"version": "1.53.22",
|
|
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.22",
|
|
44
|
+
"@frontera-sdk/functions": "1.53.22",
|
|
45
45
|
"ai": "^6.0.319",
|
|
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.22",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
package/src/commands/app/dev.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { keepDevWrittenFiles, leftLine, putBackLine } from '../../local/local-ap
|
|
|
26
26
|
import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
|
|
27
27
|
import { ensureProjectApp, requireProjectFrom } from './shared'
|
|
28
28
|
import { flagBool, flagString, type Command, type CommandContext, type CommandResult } from '../types'
|
|
29
|
+
import { recordExit } from '../../telemetry/cli-telemetry'
|
|
29
30
|
|
|
30
31
|
const DEFAULT_PORT = 3000
|
|
31
32
|
const READY_TIMEOUT_MS = 30_000
|
|
@@ -264,14 +265,20 @@ export const appDev: Command = {
|
|
|
264
265
|
throw error
|
|
265
266
|
}
|
|
266
267
|
|
|
268
|
+
const childStarted = Date.now()
|
|
267
269
|
let stopped = false
|
|
270
|
+
// The signal that stopped this command, for `frontera.cli.process_exit`: an agent's time limit ends it with SIGTERM.
|
|
271
|
+
let received: string | null = null
|
|
268
272
|
const stop = () => {
|
|
269
273
|
if (stopped) return
|
|
270
274
|
stopped = true
|
|
271
275
|
child.kill('SIGTERM')
|
|
272
276
|
broker?.server.stop(true)
|
|
273
277
|
}
|
|
274
|
-
const onSignal = () =>
|
|
278
|
+
const onSignal = (signal: NodeJS.Signals) => {
|
|
279
|
+
received ??= signal
|
|
280
|
+
stop()
|
|
281
|
+
}
|
|
275
282
|
process.on('SIGINT', onSignal)
|
|
276
283
|
process.on('SIGTERM', onSignal)
|
|
277
284
|
// Started by an MCP session (`app_dev`, `app_render`) or Forge (its Start): it stops when that process ends,
|
|
@@ -316,6 +323,7 @@ export const appDev: Command = {
|
|
|
316
323
|
text: `Stopped ${app.slug} local development server.`,
|
|
317
324
|
}
|
|
318
325
|
} finally {
|
|
326
|
+
const asked = stopped
|
|
319
327
|
unwatch?.()
|
|
320
328
|
process.off('SIGINT', onSignal)
|
|
321
329
|
process.off('SIGTERM', onSignal)
|
|
@@ -324,6 +332,7 @@ export const appDev: Command = {
|
|
|
324
332
|
// Once the framework's server has exited (or after a while, whatever it does), what it wrote goes back.
|
|
325
333
|
// Unref'd: a pending wait would hold this process open for the whole STOP_WAIT_MS after the server exited.
|
|
326
334
|
await Promise.race([child.exited, new Promise((resolve) => setTimeout(resolve, STOP_WAIT_MS).unref())])
|
|
335
|
+
recordExit('app_dev', { startedAt: childStarted, expected: asked, exitCode: child.exitCode, signal: received ?? child.signalCode })
|
|
327
336
|
const { putBack, left } = kept.atStop()
|
|
328
337
|
for (const line of [putBackLine(putBack, 'stopped'), leftLine(left)]) if (line) ctx.output.note(`[dev] ${line}`)
|
|
329
338
|
if (aside) ctx.output.note(`[dev] ${aside.restore()}`)
|
|
@@ -68,6 +68,9 @@ import { failedForRuntime, RUNTIME_DOWN_ERROR, RUNTIME_STOPPED_LINE, runtimeAnsw
|
|
|
68
68
|
import { noticeLines, projectNotices } from '../../local/local-project-notices'
|
|
69
69
|
import { refreshVendoredSdk, vendoredSdkState } from '../../local/local-vendored-sdk'
|
|
70
70
|
import { flagBool, type Command, type CommandContext } from '../types'
|
|
71
|
+
import { applyEvents } from '../../telemetry/apply-events'
|
|
72
|
+
import { recordExit } from '../../telemetry/cli-telemetry'
|
|
73
|
+
import type { ApplyTrigger } from '../../telemetry/events'
|
|
71
74
|
import { isSharedFunctionFile } from '../../local/local-function-shared'
|
|
72
75
|
import { applyApp } from '../../local/local-apps'
|
|
73
76
|
import { applyBrand, brandUpload } from '../../local/local-brand'
|
|
@@ -86,6 +89,15 @@ import { actionFailureLines, dedupeLines, hasIndexedPaths, nameIndexedPaths, ref
|
|
|
86
89
|
|
|
87
90
|
const DEBOUNCE_MS = 300
|
|
88
91
|
|
|
92
|
+
/** How Activity words what started a round; a round started by saved files names them (`filesChangedTrigger`). */
|
|
93
|
+
const TRIGGER_WORDS: Record<Exclude<ApplyTrigger, 'files_changed'>, string> = {
|
|
94
|
+
once: 'frontera local dev --once',
|
|
95
|
+
runtime_started: 'runtime started',
|
|
96
|
+
dev_started: 'frontera local dev started',
|
|
97
|
+
runtime_answering: 'the runtime is answering again',
|
|
98
|
+
restore: 'the database was restored to its baseline',
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
/** How many Blueprint artifacts one apply may leave off for the runtime's refusals before it says the Blueprint failed. */
|
|
90
102
|
const MAX_REFUSED = 12
|
|
91
103
|
|
|
@@ -858,13 +870,14 @@ async function dev(ctx: CommandContext) {
|
|
|
858
870
|
let runtimeDown = false
|
|
859
871
|
// What starts the next round of applies, for Activity: the files queued since the last one, or a reason of its own.
|
|
860
872
|
const changedFiles = new Set<string>()
|
|
861
|
-
let nextTrigger:
|
|
873
|
+
let nextTrigger: ApplyTrigger | null = null
|
|
874
|
+
const events = applyEvents(project)
|
|
862
875
|
/**
|
|
863
876
|
* The round under way, once it applies something: a round that finds every
|
|
864
877
|
* queued item unchanged applies nothing, so it never says "Applying" and is
|
|
865
878
|
* never recorded as an apply.
|
|
866
879
|
*/
|
|
867
|
-
let round: { since: string | null; trigger: string; applied: number; failed: number } = { since: null, trigger: '', applied: 0, failed: 0 }
|
|
880
|
+
let round: { since: string | null; cause: ApplyTrigger; trigger: string; queued: number; startedAt: number; applied: number; failed: number } = { since: null, cause: 'files_changed', trigger: '', queued: 0, startedAt: 0, applied: 0, failed: 0 }
|
|
868
881
|
const markApplying = () => {
|
|
869
882
|
if (round.since) return
|
|
870
883
|
round.since = new Date().toISOString()
|
|
@@ -936,6 +949,7 @@ async function dev(ctx: CommandContext) {
|
|
|
936
949
|
ctx.output.note(` ✕ ${line}`)
|
|
937
950
|
recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'blocked', needs: [], error: line, hash, at: new Date().toISOString() })
|
|
938
951
|
failing.set(target.key, { item: describeTarget(target), error: line })
|
|
952
|
+
events.item(target, 'stopped', round.cause, 0)
|
|
939
953
|
}
|
|
940
954
|
return
|
|
941
955
|
}
|
|
@@ -955,6 +969,7 @@ async function dev(ctx: CommandContext) {
|
|
|
955
969
|
failing.set(target.key, { item: describeTarget(target), error: RUNTIME_DOWN_ERROR })
|
|
956
970
|
recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'failed', error: RUNTIME_DOWN_ERROR, hash, at: new Date().toISOString() })
|
|
957
971
|
ctx.output.note(` ✕ ${describeTarget(target)}: ${RUNTIME_STOPPED_LINE}`)
|
|
972
|
+
events.item(target, 'runtime_down', round.cause, Date.now() - started)
|
|
958
973
|
return
|
|
959
974
|
}
|
|
960
975
|
if (!result.ok && target.kind === 'blueprint') result = { ...result, output: await blueprintFailureOutput(project, result.output, serviceUrl) }
|
|
@@ -968,6 +983,7 @@ async function dev(ctx: CommandContext) {
|
|
|
968
983
|
saveHashes(project, organizationId, applied)
|
|
969
984
|
failed.delete(target.key)
|
|
970
985
|
failing.delete(target.key)
|
|
986
|
+
events.item(target, 'applied', round.cause, ms)
|
|
971
987
|
ctx.output.note(` ✓ ${describeTarget(target)} applied (${ms} ms)`)
|
|
972
988
|
if (target.kind === 'blueprint') personasDue = true
|
|
973
989
|
if (target.kind === 'workflow' && promoteAfter.delete(target.key)) {
|
|
@@ -1035,6 +1051,7 @@ async function dev(ctx: CommandContext) {
|
|
|
1035
1051
|
// "unchanged" afterwards, even when it is restored to what last applied.
|
|
1036
1052
|
if (applied.delete(target.key)) saveHashes(project, organizationId, applied)
|
|
1037
1053
|
failed.set(target.key, target)
|
|
1054
|
+
events.item(target, 'failed', round.cause, ms, { output: result.output })
|
|
1038
1055
|
// A retry that fails the same way is not said again in the same round of changes.
|
|
1039
1056
|
// A governed Action's checks are each a line to fix: shown whole, not cut to the first seven lines.
|
|
1040
1057
|
const text = result.failureLines ? result.failureLines.map((l, i) => (i ? ` ${l}` : l)).join('\n') : applyErrorText(result.output)
|
|
@@ -1098,7 +1115,8 @@ async function dev(ctx: CommandContext) {
|
|
|
1098
1115
|
if (busy) return
|
|
1099
1116
|
busy = true
|
|
1100
1117
|
// Why this round runs; "Applying" is said only once an item actually applies (`markApplying`).
|
|
1101
|
-
|
|
1118
|
+
const cause = nextTrigger ?? 'files_changed'
|
|
1119
|
+
round = { since: null, cause, trigger: cause === 'files_changed' ? filesChangedTrigger(changedFiles) : TRIGGER_WORDS[cause], queued: pending.size, startedAt: Date.now(), applied: 0, failed: 0 }
|
|
1102
1120
|
nextTrigger = null
|
|
1103
1121
|
changedFiles.clear()
|
|
1104
1122
|
said.clear()
|
|
@@ -1119,7 +1137,11 @@ async function dev(ctx: CommandContext) {
|
|
|
1119
1137
|
waiting.set(next.key, next)
|
|
1120
1138
|
failing.set(next.key, { item: describeTarget(next), error: RUNTIME_DOWN_ERROR })
|
|
1121
1139
|
ctx.output.note(` ✕ ${describeTarget(next)}: ${RUNTIME_STOPPED_LINE}`)
|
|
1122
|
-
|
|
1140
|
+
events.item(next, 'runtime_down', round.cause, 0)
|
|
1141
|
+
} else {
|
|
1142
|
+
ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
|
|
1143
|
+
events.item(next, 'failed', round.cause, 0, { error: err })
|
|
1144
|
+
}
|
|
1123
1145
|
}
|
|
1124
1146
|
}
|
|
1125
1147
|
if (runtimeDown) {
|
|
@@ -1166,6 +1188,7 @@ async function dev(ctx: CommandContext) {
|
|
|
1166
1188
|
if (round.since) {
|
|
1167
1189
|
recordApply(project, { since: round.since, finished: new Date().toISOString(), trigger: round.trigger, applied: round.applied, failed: round.failed })
|
|
1168
1190
|
recordApplying(project, null)
|
|
1191
|
+
events.round({ trigger: round.cause, queued: round.queued, applied: round.applied, failed: round.failed, ms: Date.now() - round.startedAt })
|
|
1169
1192
|
}
|
|
1170
1193
|
// A file saved after the queue emptied, before this round ended: its own round.
|
|
1171
1194
|
if (pending.size && !stopping && !runtimeDown) schedule()
|
|
@@ -1181,7 +1204,7 @@ async function dev(ctx: CommandContext) {
|
|
|
1181
1204
|
const resumeWaiting = () => {
|
|
1182
1205
|
if (!waiting.size) return
|
|
1183
1206
|
ctx.output.note(` the local runtime is answering again: applying the ${waiting.size === 1 ? 'item' : `${waiting.size} items`} it missed`)
|
|
1184
|
-
nextTrigger ??= '
|
|
1207
|
+
nextTrigger ??= 'runtime_answering'
|
|
1185
1208
|
for (const [key, target] of waiting) {
|
|
1186
1209
|
pending.set(key, target)
|
|
1187
1210
|
failing.delete(key)
|
|
@@ -1216,7 +1239,7 @@ async function dev(ctx: CommandContext) {
|
|
|
1216
1239
|
if (!changed.size) {
|
|
1217
1240
|
if (appsAwayByRestore(project.root).length) {
|
|
1218
1241
|
secretsDue = true
|
|
1219
|
-
nextTrigger = '
|
|
1242
|
+
nextTrigger = 'restore'
|
|
1220
1243
|
if (!busy) schedule()
|
|
1221
1244
|
return
|
|
1222
1245
|
}
|
|
@@ -1225,7 +1248,7 @@ async function dev(ctx: CommandContext) {
|
|
|
1225
1248
|
return
|
|
1226
1249
|
}
|
|
1227
1250
|
secretsDue = true
|
|
1228
|
-
nextTrigger = '
|
|
1251
|
+
nextTrigger = 'restore'
|
|
1229
1252
|
for (const t of allTargets(project.root)) if (changed.has(t.key)) pending.set(t.key, t)
|
|
1230
1253
|
ctx.output.note(` the database was restored to its baseline: applying ${changed.size === 1 ? 'one item' : `${changed.size} items`} applied since then again`)
|
|
1231
1254
|
if (!busy) schedule()
|
|
@@ -1375,6 +1398,7 @@ async function dev(ctx: CommandContext) {
|
|
|
1375
1398
|
const targets = allTargets(project.root)
|
|
1376
1399
|
// Outcomes of files that are gone are forgotten, so the home lists what the project holds.
|
|
1377
1400
|
pruneBuild(project, organizationId, new Set(targets.map((t) => t.key)))
|
|
1401
|
+
events.keep(new Set(targets.map((t) => t.key)))
|
|
1378
1402
|
// An Agent, workflow or Function whose file was deleted is taken out of service here, as the files now say.
|
|
1379
1403
|
await sweepOrphans()
|
|
1380
1404
|
await clearDeletedBehavior()
|
|
@@ -1399,7 +1423,7 @@ async function dev(ctx: CommandContext) {
|
|
|
1399
1423
|
for (const target of targets) pending.set(target.key, target)
|
|
1400
1424
|
// The first round: the runtime just started (Forge starts this watcher with it), or `local dev` was run by hand.
|
|
1401
1425
|
const startedAt = Date.parse(state.startedAt ?? '')
|
|
1402
|
-
nextTrigger = once ? '
|
|
1426
|
+
nextTrigger = once ? 'once' : Number.isFinite(startedAt) && Date.now() - startedAt < 5 * 60_000 ? 'runtime_started' : 'dev_started'
|
|
1403
1427
|
await drain()
|
|
1404
1428
|
if (unchanged) ctx.output.note(` ${unchanged} unchanged since the last apply (--reapply applies them again)`)
|
|
1405
1429
|
await sayProjectSecrets()
|
|
@@ -1453,17 +1477,18 @@ async function dev(ctx: CommandContext) {
|
|
|
1453
1477
|
}, 5_000)
|
|
1454
1478
|
|
|
1455
1479
|
await new Promise<void>((resolve) => {
|
|
1456
|
-
const stop = () => {
|
|
1480
|
+
const stop = (signal: 'SIGINT' | 'SIGTERM') => {
|
|
1457
1481
|
stopping = true
|
|
1458
1482
|
rmSync(pidFile, { force: true })
|
|
1459
1483
|
if (timer) clearTimeout(timer)
|
|
1460
1484
|
clearInterval(poller)
|
|
1461
1485
|
watcher?.close()
|
|
1462
1486
|
stopChildren()
|
|
1487
|
+
recordExit('local_dev', { startedAt: performance.timeOrigin, expected: true, signal })
|
|
1463
1488
|
resolve()
|
|
1464
1489
|
}
|
|
1465
|
-
process.once('SIGINT', stop)
|
|
1466
|
-
process.once('SIGTERM', stop)
|
|
1490
|
+
process.once('SIGINT', () => stop('SIGINT'))
|
|
1491
|
+
process.once('SIGTERM', () => stop('SIGTERM'))
|
|
1467
1492
|
})
|
|
1468
1493
|
return { data: { applied: applied.size }, text: 'Stopped watching' }
|
|
1469
1494
|
}
|
package/src/commands/registry.ts
CHANGED
|
@@ -60,6 +60,7 @@ import { localCommands } from './local/index-commands'
|
|
|
60
60
|
import { localSnapshot } from './local/snapshot'
|
|
61
61
|
import { localSteps } from './local/steps'
|
|
62
62
|
import { mcpServe } from './mcp/serve'
|
|
63
|
+
import { telemetryStatus } from './telemetry/status'
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
66
|
* Flags every command accepts. Declared once so a caller never has to wonder
|
|
@@ -108,6 +109,7 @@ export const COMMANDS: readonly Command[] = [
|
|
|
108
109
|
localSnapshot,
|
|
109
110
|
localSteps,
|
|
110
111
|
mcpServe,
|
|
112
|
+
telemetryStatus,
|
|
111
113
|
|
|
112
114
|
appInit,
|
|
113
115
|
appList,
|
|
@@ -228,6 +230,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
|
|
|
228
230
|
forge: 'Forge — the local daemon the desktop app talks to',
|
|
229
231
|
mcp: 'Frontera for coding agents — runtime, docs, Apps, browser',
|
|
230
232
|
local: 'A runtime on this machine — apply, test and check the project',
|
|
233
|
+
telemetry: 'Usage and error data sent from work run inside Forge',
|
|
231
234
|
}
|
|
232
235
|
|
|
233
236
|
export function nounSummary(noun: string): string {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { listOutbox } from '../../telemetry/outbox'
|
|
4
|
+
import { REASON_TEXT, telemetryMode } from '../../telemetry/enabled'
|
|
5
|
+
import type { Command } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What this process would do with telemetry, and why. Reads the environment
|
|
9
|
+
* and the outbox folder; writes nothing and opens no connection.
|
|
10
|
+
*/
|
|
11
|
+
export const telemetryStatus: Command = {
|
|
12
|
+
meta: {
|
|
13
|
+
noun: 'telemetry',
|
|
14
|
+
verb: 'status',
|
|
15
|
+
args: [],
|
|
16
|
+
flags: {},
|
|
17
|
+
summary: 'Say whether this process would send usage and error data, print it, or record nothing, and why',
|
|
18
|
+
details: [
|
|
19
|
+
'Only a process Forge starts records anything: Forge names its outbox folder in FORGE_TELEMETRY_OUTBOX, and its own server sends what is there to the Frontera team. Anywhere else the CLI records nothing. FRONTERA_TELEMETRY=0 or DO_NOT_TRACK=1 switches it off; FRONTERA_TELEMETRY=log prints each event to stderr and sends nothing.',
|
|
20
|
+
],
|
|
21
|
+
examples: ['frontera telemetry status', 'FRONTERA_TELEMETRY=log frontera telemetry status --json'],
|
|
22
|
+
offline: true,
|
|
23
|
+
internal: true,
|
|
24
|
+
},
|
|
25
|
+
async run() {
|
|
26
|
+
const mode = telemetryMode(process.env)
|
|
27
|
+
const outbox = process.env.FORGE_TELEMETRY_OUTBOX?.trim() || null
|
|
28
|
+
const files = outbox ? listOutbox(outbox) : []
|
|
29
|
+
const waiting = { open: 0, closed: 0, sending: 0 }
|
|
30
|
+
for (const f of files) waiting[f.state] += 1
|
|
31
|
+
// Forge removes the folder when its switch goes off; a process started before keeps the variable but writes nothing.
|
|
32
|
+
const now = mode.mode === 'send' && !existsSync(mode.outbox) ? { mode: 'off', reason: 'outbox_gone', why: 'started by Forge, which has switched telemetry off since (the outbox folder is gone): nothing is recorded' } : { mode: mode.mode, reason: mode.reason, why: REASON_TEXT[mode.reason] }
|
|
33
|
+
const where = outbox ? `outbox: ${outbox} (${waiting.closed} waiting to be sent, ${waiting.sending} being sent, ${waiting.open} being written)` : 'outbox: none (FORGE_TELEMETRY_OUTBOX is not set)'
|
|
34
|
+
return {
|
|
35
|
+
data: { ...now, outbox, files: waiting },
|
|
36
|
+
text: [`Telemetry: ${now.mode}`, ` ${now.why}`, ` ${where}`].join('\n'),
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
}
|
|
@@ -46,6 +46,7 @@ import type { LocalProject } from './local-project'
|
|
|
46
46
|
import { startLocalEmbeddings } from './local-embeddings'
|
|
47
47
|
import { LOCAL_TRACE_KEYS, startLocalTraces } from './local-traces'
|
|
48
48
|
import { recordRuntime } from './local-runtime-history'
|
|
49
|
+
import { recordExit } from '../telemetry/cli-telemetry'
|
|
49
50
|
import { buildDocs, checkoutDocsStale, copyDocs, customerDocsBundle, IMAGE_DOCS_PATH, writeDocsBuildMarker } from './local-docs'
|
|
50
51
|
import { keepSchemas, type SchemaSource } from './local-schemas'
|
|
51
52
|
import { applyModelKeys, ensureBlueprint, ensureOrgKey, ensureProviderModels, retireLocalModel, seedLocalRuntime, signIn } from './local-seed'
|
|
@@ -139,7 +140,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
139
140
|
const running: Running = { children: new Map(), containers: new Set() }
|
|
140
141
|
let stopping = false
|
|
141
142
|
|
|
142
|
-
const shutdown = async (code: number) => {
|
|
143
|
+
const shutdown = async (code: number, signal?: NodeJS.Signals) => {
|
|
143
144
|
if (stopping) return
|
|
144
145
|
stopping = true
|
|
145
146
|
state = { ...state, phase: 'stopping' }
|
|
@@ -157,10 +158,11 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
157
158
|
if (code === 0) recordRuntime(project, { event: 'stopped', at: new Date().toISOString() })
|
|
158
159
|
state = { ...state, phase: 'stopped', supervisorPid: undefined }
|
|
159
160
|
save()
|
|
161
|
+
recordExit('runtime', { startedAt: performance.timeOrigin, expected: code === 0, exitCode: code, signal })
|
|
160
162
|
process.exit(code)
|
|
161
163
|
}
|
|
162
|
-
process.on('SIGTERM', () => void shutdown(0))
|
|
163
|
-
process.on('SIGINT', () => void shutdown(0))
|
|
164
|
+
process.on('SIGTERM', () => void shutdown(0, 'SIGTERM'))
|
|
165
|
+
process.on('SIGINT', () => void shutdown(0, 'SIGINT'))
|
|
164
166
|
|
|
165
167
|
const fail = async (name: ComponentName, detail: string) => {
|
|
166
168
|
set(name, { status: 'failed', detail })
|
|
@@ -299,9 +301,11 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
299
301
|
|
|
300
302
|
// Watch native children: a crash is reported, not hidden.
|
|
301
303
|
for (const [name, child] of running.children) {
|
|
302
|
-
|
|
304
|
+
const startedAt = Date.parse(state.components.find((c) => c.name === name)?.startedAt ?? '') || Date.now()
|
|
305
|
+
child.on('exit', (code, signal) => {
|
|
303
306
|
if (stopping) return
|
|
304
307
|
set(name, { status: 'failed', pid: undefined, detail: `exited with code ${code ?? 'signal'}; see \`frontera local logs ${name}\`` })
|
|
308
|
+
recordExit(name, { startedAt, expected: false, exitCode: code, signal })
|
|
305
309
|
})
|
|
306
310
|
}
|
|
307
311
|
|
|
@@ -311,6 +315,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
|
|
|
311
315
|
probe: () => portAnswers(ports.jobs, 3_000),
|
|
312
316
|
restart: async () => {
|
|
313
317
|
if (stopping) return
|
|
318
|
+
recordExit('jobs', { startedAt: Date.parse(state.components.find((c) => c.name === 'jobs')?.startedAt ?? '') || performance.timeOrigin, expected: false })
|
|
314
319
|
const restarted = await restartJobs(project, state, 'it stopped answering, so the supervisor restarted it')
|
|
315
320
|
set('jobs', { status: 'running', startedAt: new Date().toISOString(), detail: undefined })
|
|
316
321
|
appendFileSync(logPath(project, 'studio'), `[supervisor] jobs engine restarted in ${restarted.ms} ms${restarted.lost ? `; ${restarted.lost} lost runs marked failed` : ''}${restarted.note ? `; ${restarted.note}` : ''}\n`)
|
package/src/main.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { remoteFromLocalProject, remoteWords } from './remote-guard'
|
|
|
16
16
|
import { createOutput, type OutputMode } from './output'
|
|
17
17
|
import { readProject } from './project'
|
|
18
18
|
import { commandScope, unaddressedWorkspaceRefusal } from './scopes'
|
|
19
|
+
import { cliTelemetry, INTERRUPTED, invocationVia, projectHashAt, severityOfExit, TERMINATED } from './telemetry/cli-telemetry'
|
|
20
|
+
import { isBackground } from './telemetry/enabled'
|
|
21
|
+
import { errorCode, errorFields } from './telemetry/redact'
|
|
19
22
|
import {
|
|
20
23
|
aliasesFor,
|
|
21
24
|
describeCommand,
|
|
@@ -75,7 +78,70 @@ async function resolveVersion(): Promise<string> {
|
|
|
75
78
|
return import.meta.dir.includes('/node_modules/') ? version : `${version}-dev`
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
/**
|
|
82
|
+
* What `frontera.cli.command` says of this process, filled in as dispatch
|
|
83
|
+
* learns it: registered names only, flag names without their values.
|
|
84
|
+
*/
|
|
85
|
+
const invocation: { noun: string; verb: string; flags: string[]; names: string[]; cwd: string; failure?: unknown } = { noun: 'unknown', verb: '', flags: [], names: [], cwd: process.cwd() }
|
|
86
|
+
|
|
87
|
+
let recorded = false
|
|
88
|
+
|
|
89
|
+
/** Once per process, when it ends or is stopped with Ctrl+C; never fails or slows the command. */
|
|
90
|
+
function recordCommand(exitCode: number): void {
|
|
91
|
+
if (recorded) return
|
|
92
|
+
recorded = true
|
|
93
|
+
try {
|
|
94
|
+
// Forge's own poll that succeeded says nothing anyone did.
|
|
95
|
+
if (exitCode === 0 && isBackground(process.env)) return
|
|
96
|
+
const telemetry = cliTelemetry()
|
|
97
|
+
if (telemetry.mode.mode === 'off') return
|
|
98
|
+
const { failure } = invocation
|
|
99
|
+
const error = failure === undefined ? undefined : errorFields(errorCode((failure as { code?: unknown })?.code), failure instanceof Error ? failure.message : String(failure), invocation.names)
|
|
100
|
+
const project = projectHashAt(invocation.cwd)
|
|
101
|
+
telemetry.record({
|
|
102
|
+
name: 'frontera.cli.command',
|
|
103
|
+
severity: severityOfExit(exitCode),
|
|
104
|
+
attributes: {
|
|
105
|
+
'frontera.noun': invocation.noun,
|
|
106
|
+
'frontera.verb': invocation.verb,
|
|
107
|
+
'frontera.flags': [...new Set(invocation.flags)].sort().join(','),
|
|
108
|
+
'frontera.exit_code': exitCode,
|
|
109
|
+
'frontera.via': invocationVia(process.env),
|
|
110
|
+
duration_ms: Math.round(performance.now()),
|
|
111
|
+
...(project ? { 'frontera.project.hash': project } : {}),
|
|
112
|
+
},
|
|
113
|
+
...(error ? { error } : {}),
|
|
114
|
+
})
|
|
115
|
+
telemetry.roll()
|
|
116
|
+
} catch {
|
|
117
|
+
// Telemetry never fails the command.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ctrl+C or SIGTERM (Forge's time limit, an agent's shell timeout): recorded
|
|
123
|
+
* as stopped (exit 130 or 143, WARN), with how long it ran, before anything
|
|
124
|
+
* ends. A command with its own handler for the signal (`local dev`, `app dev`,
|
|
125
|
+
* the runtime supervisor, `forge serve`) still stops its own way. For it,
|
|
126
|
+
* SIGTERM is how its owner stops it normally, so only Ctrl+C is recorded as
|
|
127
|
+
* stopped. With no handler, the signal is raised again with none, so the
|
|
128
|
+
* process ends exactly as the default would (the shell says 130 or 143).
|
|
129
|
+
*/
|
|
130
|
+
const STOPPED = { SIGINT: INTERRUPTED, SIGTERM: TERMINATED } as const
|
|
131
|
+
|
|
132
|
+
function onStop(signal: keyof typeof STOPPED): () => void {
|
|
133
|
+
const handler = () => {
|
|
134
|
+
const handled = process.listenerCount(signal) > 1
|
|
135
|
+
if (signal === 'SIGINT' || !handled) recordCommand(STOPPED[signal])
|
|
136
|
+
if (handled) return
|
|
137
|
+
process.off(signal, handler)
|
|
138
|
+
process.kill(process.pid, signal)
|
|
139
|
+
}
|
|
140
|
+
return handler
|
|
141
|
+
}
|
|
142
|
+
|
|
78
143
|
async function main(): Promise<number> {
|
|
144
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, onStop(signal))
|
|
79
145
|
const argv = process.argv.slice(2)
|
|
80
146
|
|
|
81
147
|
/**
|
|
@@ -112,6 +178,8 @@ async function main(): Promise<number> {
|
|
|
112
178
|
}
|
|
113
179
|
|
|
114
180
|
const remaining = argv.slice(cursor)
|
|
181
|
+
// Until a command is found, every word typed is a name to keep out of an error template.
|
|
182
|
+
invocation.names = remaining.filter((a) => !a.startsWith('-'))
|
|
115
183
|
|
|
116
184
|
// Flags but no command. Showing help beats "unknown command: --api-url",
|
|
117
185
|
// which reads as a typo in the flag rather than as a missing command.
|
|
@@ -126,9 +194,11 @@ async function main(): Promise<number> {
|
|
|
126
194
|
// Bare `frontera`, `help`, `--help`, `--version` before anything that could
|
|
127
195
|
// fail — help has to work with no credential and no project.
|
|
128
196
|
if (!noun || noun === 'help' || noun === '--help' || noun === '-h') {
|
|
197
|
+
invocation.noun = 'help'
|
|
129
198
|
return emit(describeCommands(), renderHelp)
|
|
130
199
|
}
|
|
131
200
|
if (noun === '--version' || noun === 'version') {
|
|
201
|
+
invocation.noun = 'version'
|
|
132
202
|
const version = await resolveVersion()
|
|
133
203
|
return emit({ version }, () => version)
|
|
134
204
|
}
|
|
@@ -136,6 +206,8 @@ async function main(): Promise<number> {
|
|
|
136
206
|
// Nouns with no verb (`frontera init`) take their arguments directly.
|
|
137
207
|
const direct = findCommand(noun, '')
|
|
138
208
|
const command = direct ?? findCommand(noun, maybeVerb)
|
|
209
|
+
if (nouns(true).includes(noun)) invocation.noun = noun
|
|
210
|
+
if (command) invocation.verb = command.meta.verb
|
|
139
211
|
|
|
140
212
|
// `frontera agent --help`, or a bare `frontera agent`: list the noun's
|
|
141
213
|
// commands. Without this the CLI's own hints dead-ended, since a reserved
|
|
@@ -158,6 +230,8 @@ async function main(): Promise<number> {
|
|
|
158
230
|
const args = direct ? [maybeVerb, ...rest].filter((a): a is string => a !== undefined) : rest
|
|
159
231
|
const spec = flagsFor(command.meta)
|
|
160
232
|
const { positional, flags, repeated } = parseArgs(args, spec, aliasesFor(command.meta))
|
|
233
|
+
invocation.flags = Object.keys(flags)
|
|
234
|
+
invocation.names = [...positional, ...Object.values(flags).filter((v): v is string => typeof v === 'string')]
|
|
161
235
|
|
|
162
236
|
if (flags.help === true) {
|
|
163
237
|
return emit(describeCommand(command.meta), () => renderCommandHelp(command.meta))
|
|
@@ -185,6 +259,7 @@ async function main(): Promise<number> {
|
|
|
185
259
|
|
|
186
260
|
const dirFlag = typeof flags.dir === 'string' ? flags.dir : undefined
|
|
187
261
|
const cwd = dirFlag ?? process.cwd()
|
|
262
|
+
invocation.cwd = cwd
|
|
188
263
|
|
|
189
264
|
/**
|
|
190
265
|
* A missing argument outranks a missing project directory.
|
|
@@ -407,6 +482,7 @@ async function main(): Promise<number> {
|
|
|
407
482
|
// rather than each API client rewording its own 401/403.
|
|
408
483
|
const laneFailure = sessionLaneFailure(command.meta, token, err)
|
|
409
484
|
const failure = (laneFailure && (await onLocalRuntime(laneFailure, command.meta, typeof flags.dir === 'string' ? flags.dir : process.cwd(), { apiUrl: apiUrlUsed, token }).catch(() => null))) ?? laneFailure ?? err
|
|
485
|
+
invocation.failure = failure
|
|
410
486
|
return output.fail(personaToken ? redact(failure, personaToken) : failure)
|
|
411
487
|
}
|
|
412
488
|
}
|
|
@@ -423,6 +499,7 @@ main()
|
|
|
423
499
|
process.exitCode = code
|
|
424
500
|
})
|
|
425
501
|
.catch((err) => {
|
|
502
|
+
invocation.failure = err
|
|
426
503
|
// Only reachable for a throw outside the try — an unknown command, argument
|
|
427
504
|
// parsing, or help rendering. Asked for JSON on the command line, it is JSON
|
|
428
505
|
// (X10: `trace get --json` was no command, and said so only in prose).
|
|
@@ -438,3 +515,4 @@ main()
|
|
|
438
515
|
if (err instanceof CliError && err.hint) process.stderr.write(` ${err.hint}\n`)
|
|
439
516
|
process.exitCode = code === 'USAGE' ? EXIT.USAGE : EXIT.FAILURE
|
|
440
517
|
})
|
|
518
|
+
.finally(() => recordCommand(Number(process.exitCode ?? 0)))
|
package/src/mcp/mcp-apps.ts
CHANGED
|
@@ -138,6 +138,8 @@ export interface EnsureDevServerOptions {
|
|
|
138
138
|
owner?: 'mcp' | 'forge' | 'detached'
|
|
139
139
|
/** The port to listen on. Default: a free one from 3100. */
|
|
140
140
|
port?: number
|
|
141
|
+
/** Variables for the server's process on top of this one's (Forge's telemetry outbox). */
|
|
142
|
+
env?: Record<string, string>
|
|
141
143
|
/** Called once when the server this call started ends, however it ends. */
|
|
142
144
|
onExit?: (exit: DevServerExit) => void
|
|
143
145
|
}
|
|
@@ -291,7 +293,7 @@ export async function ensureDevServer(project: LocalProject, app: AppProjectRef,
|
|
|
291
293
|
const from = statSync(log).size
|
|
292
294
|
const [exe, ...pre] = options.command ?? selfCommand()
|
|
293
295
|
const owned = options.owner !== 'detached'
|
|
294
|
-
const env: NodeJS.ProcessEnv = { ...process.env, ...credential.env, NO_COLOR: '1', ...(credential.token ? { [TOKEN_ON_STDIN_ENV]: '1' } : {}) }
|
|
296
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ...options.env, ...credential.env, NO_COLOR: '1', ...(credential.token ? { [TOKEN_ON_STDIN_ENV]: '1' } : {}) }
|
|
295
297
|
delete env[DEV_OWNER_ENV]
|
|
296
298
|
const child = spawn(exe!, [...pre, 'app', 'dev', '--no-open', '--port', String(port)], {
|
|
297
299
|
cwd: app.root,
|
package/src/mcp/mcp-protocol.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline'
|
|
2
2
|
|
|
3
|
+
import { cliTelemetry, projectHashAt } from '../telemetry/cli-telemetry'
|
|
4
|
+
import { errorCode, errorFields, outputErrorCode } from '../telemetry/redact'
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* The part of the Model Context Protocol `frontera mcp` speaks: JSON-RPC 2.0,
|
|
5
8
|
* one message per line on stdio, with tools only. Calls run concurrently and
|
|
@@ -65,17 +68,45 @@ export async function answerMcpMessage(message: McpMessage, info: McpServerInfo,
|
|
|
65
68
|
const input = (message.params?.arguments ?? {}) as Record<string, unknown>
|
|
66
69
|
const tool = tools.find((t) => t.name === name)
|
|
67
70
|
if (!tool) return reply(errorResult(`unknown tool "${name}"`))
|
|
71
|
+
const started = performance.now()
|
|
72
|
+
let result: ToolResult
|
|
73
|
+
let thrown: unknown
|
|
68
74
|
try {
|
|
69
|
-
|
|
75
|
+
result = await tool.run(input)
|
|
70
76
|
} catch (err) {
|
|
71
|
-
|
|
77
|
+
thrown = err
|
|
78
|
+
result = errorResult((err as Error).message)
|
|
72
79
|
}
|
|
80
|
+
const ms = performance.now() - started
|
|
81
|
+
// After the reply is written: telemetry's file work never adds to the tool's latency.
|
|
82
|
+
setImmediate(() => recordToolCall(tool.name, input, result, thrown, ms))
|
|
83
|
+
return reply(result)
|
|
73
84
|
}
|
|
74
85
|
default:
|
|
75
86
|
return { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not found: ${String(message.method)}` } }
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
/** `frontera.cli.mcp_tool`: the tool, whether it worked, how long it took, and a failure's code and template; never its input or output. */
|
|
91
|
+
function recordToolCall(tool: string, input: Record<string, unknown>, result: ToolResult, thrown: unknown, ms: number): void {
|
|
92
|
+
try {
|
|
93
|
+
const telemetry = cliTelemetry()
|
|
94
|
+
if (telemetry.mode.mode === 'off') return
|
|
95
|
+
const text = result.content.find((c) => c.type === 'text')?.text ?? ''
|
|
96
|
+
const names = Object.values(input).filter((v): v is string => typeof v === 'string')
|
|
97
|
+
const code = thrown === undefined ? outputErrorCode(text) : errorCode((thrown as { code?: unknown })?.code)
|
|
98
|
+
const project = projectHashAt(process.cwd())
|
|
99
|
+
telemetry.record({
|
|
100
|
+
name: 'frontera.cli.mcp_tool',
|
|
101
|
+
severity: result.isError ? 'ERROR' : 'INFO',
|
|
102
|
+
attributes: { 'frontera.tool': tool, 'frontera.ok': !result.isError, duration_ms: Math.round(ms), ...(project ? { 'frontera.project.hash': project } : {}) },
|
|
103
|
+
...(result.isError ? { error: errorFields(code, text, names) } : {}),
|
|
104
|
+
})
|
|
105
|
+
} catch {
|
|
106
|
+
// Telemetry never fails a tool call.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
/** Serve over stdio until stdin closes, then wait for the calls still running. */
|
|
80
111
|
export async function serveMcp(args: {
|
|
81
112
|
info: McpServerInfo
|