@frontera-sdk/cli 1.52.38 → 1.52.40
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 +48 -4
- 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/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 +331 -31
- 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 +875 -88
- 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 +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- 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/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -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 +211 -21
- 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 +180 -7
- 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 +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- 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-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- 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 +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- 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 +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- 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-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- 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/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 +24 -21
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { isLoopbackPortFree } from '../dev-broker'
|
|
6
|
+
import { findSolutionRoot, localProject, type LocalProject } from '../local/local-project'
|
|
7
|
+
import { isAlive } from '../local/local-state'
|
|
8
|
+
import { readProject } from '../project'
|
|
9
|
+
import { ensureAppPackages } from '../project-bootstrap'
|
|
10
|
+
import { detectAppFramework } from '../provenance'
|
|
11
|
+
import { selfCommand } from './mcp-cli'
|
|
12
|
+
import type { RuntimeLink } from './mcp-runtime'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Custom Apps a coding agent works on from a solution: each is its own
|
|
16
|
+
* project (`package.json` with `frontera.config.json`), found at the solution
|
|
17
|
+
* root, in `apps/<slug>/` or one folder down. Their dev servers run against
|
|
18
|
+
* the local runtime and are kept per App in `.frontera/local/app-dev/`, so
|
|
19
|
+
* every MCP call (and Forge) reuses the one already running.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface AppProjectRef {
|
|
23
|
+
slug: string
|
|
24
|
+
name: string
|
|
25
|
+
/** Absolute. */
|
|
26
|
+
root: string
|
|
27
|
+
/** From the solution root, for messages. */
|
|
28
|
+
path: string
|
|
29
|
+
framework: 'next' | 'vite' | 'unknown'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isAppRoot(dir: string): boolean {
|
|
33
|
+
if (!existsSync(join(dir, 'package.json'))) return false
|
|
34
|
+
if (existsSync(join(dir, 'frontera.config.json'))) return true
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { frontera?: unknown }
|
|
37
|
+
return typeof pkg.frontera === 'object' && pkg.frontera !== null
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SKIP = new Set(['node_modules', '.git', '.frontera', 'dist', 'out', '.next', 'build'])
|
|
44
|
+
|
|
45
|
+
function children(dir: string): string[] {
|
|
46
|
+
try {
|
|
47
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
48
|
+
.filter((d) => d.isDirectory() && !SKIP.has(d.name) && !d.name.startsWith('.'))
|
|
49
|
+
.map((d) => join(dir, d.name))
|
|
50
|
+
} catch {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findAppProjects(solutionRoot: string): AppProjectRef[] {
|
|
56
|
+
const candidates = [solutionRoot, ...children(join(solutionRoot, 'apps')), ...children(solutionRoot)]
|
|
57
|
+
const seen = new Set<string>()
|
|
58
|
+
const out: AppProjectRef[] = []
|
|
59
|
+
for (const dir of candidates) {
|
|
60
|
+
if (seen.has(dir) || !isAppRoot(dir)) continue
|
|
61
|
+
seen.add(dir)
|
|
62
|
+
try {
|
|
63
|
+
const project = readProject(dir)
|
|
64
|
+
out.push({ slug: project.slug, name: project.displayName, root: dir, path: relative(solutionRoot, dir) || '.', framework: detectAppFramework(dir) })
|
|
65
|
+
} catch {
|
|
66
|
+
// A broken package.json is not an App the agent can run.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The App a tool means: by slug, or the only one there is. */
|
|
73
|
+
export function resolveAppProject(solutionRoot: string, slug: string | undefined): AppProjectRef {
|
|
74
|
+
const apps = findAppProjects(solutionRoot)
|
|
75
|
+
if (!apps.length) throw new Error('No Custom App in this project. Create one with `frontera app init <name>` in apps/, then try again.')
|
|
76
|
+
if (slug) {
|
|
77
|
+
const app = apps.find((a) => a.slug === slug || basename(a.root) === slug)
|
|
78
|
+
if (!app) throw new Error(`No App "${slug}" here; there is ${apps.map((a) => a.slug).join(', ')}.`)
|
|
79
|
+
return app
|
|
80
|
+
}
|
|
81
|
+
if (apps.length > 1) throw new Error(`This project has ${apps.length} Apps (${apps.map((a) => a.slug).join(', ')}): say which with app.`)
|
|
82
|
+
return apps[0]!
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DevServer {
|
|
86
|
+
slug: string
|
|
87
|
+
pid: number
|
|
88
|
+
port: number
|
|
89
|
+
/** What to open: the App's origin, or the Vite dev host page. */
|
|
90
|
+
url: string
|
|
91
|
+
startedAt: string
|
|
92
|
+
log: string
|
|
93
|
+
/**
|
|
94
|
+
* The process that started it: an MCP session (`app_dev`, `app_render`) or
|
|
95
|
+
* Forge (its Start). The server stops when that process ends (`frontera app
|
|
96
|
+
* dev` watches it), so a server an agent or Forge started never outlives it.
|
|
97
|
+
* Absent: started by hand.
|
|
98
|
+
*/
|
|
99
|
+
owner?: { kind: 'mcp' | 'forge'; pid: number }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** How a dev server this process started ended: its exit code (or signal) and the last lines it wrote. */
|
|
103
|
+
export interface DevServerExit {
|
|
104
|
+
code: number | null
|
|
105
|
+
signal: string | null
|
|
106
|
+
at: string
|
|
107
|
+
/** The last lines of this run's output, from its log. */
|
|
108
|
+
tail: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface EnsureDevServerOptions {
|
|
112
|
+
/**
|
|
113
|
+
* The `frontera` command to run `app dev` with. Default: this same CLI
|
|
114
|
+
* (`selfCommand`), right for `frontera mcp`; a host that only imports this
|
|
115
|
+
* module (Forge) passes its own, or it would start a copy of itself.
|
|
116
|
+
*/
|
|
117
|
+
command?: string[]
|
|
118
|
+
/** Who owns the server's lifetime (this process either way). Default: an MCP session. */
|
|
119
|
+
owner?: 'mcp' | 'forge'
|
|
120
|
+
/** Called once when the server this call started ends, however it ends. */
|
|
121
|
+
onExit?: (exit: DevServerExit) => void
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What tells `frontera app dev` which process started it, so it stops when that one ends. */
|
|
125
|
+
export const DEV_OWNER_ENV = 'FRONTERA_APP_DEV_OWNER_PID'
|
|
126
|
+
|
|
127
|
+
/** The dev servers this process started, by project and App, so they stop with the MCP session or Forge (`stopStartedDevServers`). */
|
|
128
|
+
const startedHere = new Map<string, { project: LocalProject; slug: string; pid: number }>()
|
|
129
|
+
|
|
130
|
+
const devDir = (project: LocalProject) => join(project.stateDir, 'app-dev')
|
|
131
|
+
const devFile = (project: LocalProject, slug: string) => join(devDir(project), `${slug.replace(/[^a-z0-9-]+/gi, '-')}.json`)
|
|
132
|
+
|
|
133
|
+
export function readDevServer(project: LocalProject, slug: string): DevServer | null {
|
|
134
|
+
try {
|
|
135
|
+
const server = JSON.parse(readFileSync(devFile(project, slug), 'utf8')) as DevServer
|
|
136
|
+
return isAlive(server.pid) ? server : null
|
|
137
|
+
} catch {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function listDevServers(project: LocalProject): DevServer[] {
|
|
143
|
+
try {
|
|
144
|
+
return readdirSync(devDir(project))
|
|
145
|
+
.filter((f) => f.endsWith('.json'))
|
|
146
|
+
.map((f) => readDevServer(project, f.replace(/\.json$/, '')))
|
|
147
|
+
.filter((s): s is DevServer => s !== null)
|
|
148
|
+
} catch {
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The solution an App folder sits in, when it sits in one: the nearest folder
|
|
155
|
+
* above it marked as a Frontera project. Null for an App on its own.
|
|
156
|
+
*/
|
|
157
|
+
export function enclosingSolution(appRoot: string): LocalProject | null {
|
|
158
|
+
const root = findSolutionRoot(dirname(appRoot))
|
|
159
|
+
if (root === appRoot || !['frontera.yaml', 'frontera.solution.yaml'].some((f) => existsSync(join(root, f)))) return null
|
|
160
|
+
return localProject(root)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Record a dev server another command started (`frontera app dev` run by hand
|
|
165
|
+
* or by another session), so `app_dev`, `app_render` and Forge reuse it rather
|
|
166
|
+
* than start a second one, which Next.js refuses in the same folder.
|
|
167
|
+
*/
|
|
168
|
+
export function registerDevServer(project: LocalProject, server: DevServer): void {
|
|
169
|
+
mkdirSync(devDir(project), { recursive: true })
|
|
170
|
+
writeFileSync(devFile(project, server.slug), `${JSON.stringify(server, null, 2)}\n`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Forget a dev server this process recorded, unless another has since taken its place. */
|
|
174
|
+
export function forgetDevServer(project: LocalProject, slug: string, pid: number): void {
|
|
175
|
+
try {
|
|
176
|
+
const server = JSON.parse(readFileSync(devFile(project, slug), 'utf8')) as DevServer
|
|
177
|
+
if (server.pid === pid) rmSync(devFile(project, slug), { force: true })
|
|
178
|
+
} catch {
|
|
179
|
+
// Nothing recorded, or already gone.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function answers(url: string): Promise<boolean> {
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) })
|
|
186
|
+
return res.status < 500
|
|
187
|
+
} catch {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function freePort(from = 3100): Promise<number> {
|
|
193
|
+
// Free on both loopbacks: the dev server binds 127.0.0.1, but a browser opening localhost may reach ::1 first.
|
|
194
|
+
for (let port = from; port < from + 100; port++) if (await isLoopbackPortFree(port)) return port
|
|
195
|
+
throw new Error('no free port for the App dev server between 3100 and 3199')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The env that points an App command at the local runtime instead of the person's profile. */
|
|
199
|
+
export function localAppEnv(link: RuntimeLink): Record<string, string> {
|
|
200
|
+
const key = link.creds.workspaceKey
|
|
201
|
+
if (!key) throw new Error('The local runtime has no workspace key yet. Restart it with `frontera local start`.')
|
|
202
|
+
return { FRONTERA_TOKEN: key, FRONTERA_API_URL: link.serviceUrl, FRONTERA_ORG_ID: link.creds.organizationId }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const READY_MS = 60_000
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The App's dev server against the local runtime: the one already running,
|
|
209
|
+
* or a new `frontera app dev` started in the background, its output in
|
|
210
|
+
* `.frontera/local/logs/app-<slug>.log`.
|
|
211
|
+
*/
|
|
212
|
+
export async function ensureDevServer(project: LocalProject, app: AppProjectRef, link: RuntimeLink, options: EnsureDevServerOptions = {}): Promise<{ server: DevServer; reused: boolean; installed?: string; trackedFiles?: string[] }> {
|
|
213
|
+
const running = readDevServer(project, app.slug)
|
|
214
|
+
if (running && (await answers(running.url))) return { server: running, reused: true }
|
|
215
|
+
if (app.framework === 'unknown') throw new Error(`${app.slug} has neither Next.js nor Vite in package.json, so it has no dev server to start.`)
|
|
216
|
+
// A pulled App comes without node_modules: its packages are installed first (bun install), or `next` isn't there to start.
|
|
217
|
+
// From its bun.lock as it is when it has one: a pulled App's lockfile is never rewritten here. One whose
|
|
218
|
+
// package.json names its own SDK copy where bun.lock locked a release installs from the copy, writing neither.
|
|
219
|
+
const install = await ensureAppPackages(app.root)
|
|
220
|
+
if (install?.lockMismatch) throw new Error(`${app.slug} has no node_modules, and they weren't installed: ${install.summary}.`)
|
|
221
|
+
if (install && !install.installed) throw new Error(`${app.slug} has no node_modules, and ${install.fromCopy ? '' : `\`bun install\` in ${relative(project.root, app.root) || '.'} failed: `}${install.summary}. Fix that, then try again.`)
|
|
222
|
+
const frozen = existsSync(join(app.root, 'bun.lock')) || existsSync(join(app.root, 'bun.lockb'))
|
|
223
|
+
// From its own SDK copy (package.json and bun.lock disagree because of it): said with what kept both files as pulled.
|
|
224
|
+
const where = relative(project.root, app.root) || app.slug
|
|
225
|
+
const installed = install ? (install.fromCopy || install.fromLock ? `${where} had no node_modules: ${install.summary}` : `${where} had no node_modules: ran bun install first${frozen ? ' from its bun.lock as it is (--frozen-lockfile; the lockfile is unchanged)' : ''} (${install.summary})`) : undefined
|
|
226
|
+
const port = await freePort()
|
|
227
|
+
mkdirSync(join(project.stateDir, 'logs'), { recursive: true })
|
|
228
|
+
mkdirSync(devDir(project), { recursive: true })
|
|
229
|
+
const log = join(project.stateDir, 'logs', `app-${app.slug}.log`)
|
|
230
|
+
const fd = openSync(log, 'a')
|
|
231
|
+
// Where this run's output starts in the log, which keeps earlier runs: its tail is this run's alone.
|
|
232
|
+
const from = statSync(log).size
|
|
233
|
+
const [exe, ...pre] = options.command ?? selfCommand()
|
|
234
|
+
const child = spawn(exe!, [...pre, 'app', 'dev', '--no-open', '--port', String(port)], {
|
|
235
|
+
cwd: app.root,
|
|
236
|
+
detached: true,
|
|
237
|
+
stdio: ['ignore', fd, fd],
|
|
238
|
+
// The server stops when this process ends, however it ends (`frontera app dev` watches it).
|
|
239
|
+
env: { ...process.env, ...localAppEnv(link), NO_COLOR: '1', [DEV_OWNER_ENV]: String(process.pid) },
|
|
240
|
+
})
|
|
241
|
+
closeSync(fd)
|
|
242
|
+
let exited: DevServerExit | null = null
|
|
243
|
+
child.on('exit', (code, signal) => {
|
|
244
|
+
exited = { code, signal, at: new Date().toISOString(), tail: logTail(log, from) }
|
|
245
|
+
options.onExit?.(exited)
|
|
246
|
+
})
|
|
247
|
+
child.on('error', () => {
|
|
248
|
+
// A command that could not start at all exits too ('exit' follows with its code); nothing else to do here.
|
|
249
|
+
})
|
|
250
|
+
child.unref()
|
|
251
|
+
const origin = `http://127.0.0.1:${port}`
|
|
252
|
+
const server: DevServer = { slug: app.slug, pid: child.pid!, port, url: app.framework === 'vite' ? `${origin}/dev-host.html` : origin, startedAt: new Date().toISOString(), log: relative(project.root, log), owner: { kind: options.owner ?? 'mcp', pid: process.pid } }
|
|
253
|
+
startedHere.set(`${project.root}\0${app.slug}`, { project, slug: app.slug, pid: server.pid })
|
|
254
|
+
writeFileSync(devFile(project, app.slug), `${JSON.stringify(server, null, 2)}\n`)
|
|
255
|
+
const deadline = Date.now() + READY_MS
|
|
256
|
+
while (Date.now() < deadline) {
|
|
257
|
+
if (exited || !isAlive(server.pid)) break
|
|
258
|
+
// Ready once it answers and `app dev` said so: by then it has put back the tracked files the server wrote.
|
|
259
|
+
if ((await answers(server.url)) && devSaidReady(log, from)) {
|
|
260
|
+
const tracked = trackedFileLines(log, from)
|
|
261
|
+
return { server, reused: false, ...(installed ? { installed } : {}), ...(tracked.length ? { trackedFiles: tracked } : {}) }
|
|
262
|
+
}
|
|
263
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
264
|
+
}
|
|
265
|
+
rmSync(devFile(project, app.slug), { force: true })
|
|
266
|
+
startedHere.delete(`${project.root}\0${app.slug}`)
|
|
267
|
+
const gone = exited as DevServerExit | null
|
|
268
|
+
// Not ready in time: the server this call started is stopped, never left running unrecorded.
|
|
269
|
+
if (!gone) stopGroup(server.pid)
|
|
270
|
+
const tail = logTail(log, from)
|
|
271
|
+
const notFound = /command not found|not found: (next|vite)\b/i.test(tail) ? ` Its dev command isn't installed: run \`bun install\` in ${relative(project.root, app.root) || '.'}.` : ''
|
|
272
|
+
const why = gone ? `The dev server for ${app.slug} exited ${exitWords(gone)} before it was ready.` : `The dev server for ${app.slug} did not start within ${READY_MS / 1000} seconds, so it was stopped.`
|
|
273
|
+
throw new Error(`${why}${notFound}${tail ? `\n${tail}` : ''}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Whether this run of `frontera app dev` has said it is ready (`[dev] ready: …`), after putting back what the server wrote. */
|
|
277
|
+
function devSaidReady(log: string, from: number): boolean {
|
|
278
|
+
return /^\[dev\] ready\b/m.test(logSince(log, from))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* What this run of `frontera app dev` said of the App's tracked files: the
|
|
283
|
+
* ones the dev server rewrote and it put back (`next-env.d.ts`), the ones it
|
|
284
|
+
* left because they were edited, and an SDK path it set aside. Without the
|
|
285
|
+
* `[dev] ` prefix.
|
|
286
|
+
*/
|
|
287
|
+
export function trackedFileLines(log: string, from: number): string[] {
|
|
288
|
+
return logSince(log, from)
|
|
289
|
+
.split('\n')
|
|
290
|
+
.map((l) => l.trim())
|
|
291
|
+
.filter((l) => /^\[dev\] (put back|left|tsconfig\.json)\b/.test(l))
|
|
292
|
+
.map((l) => l.replace(/^\[dev\] /, ''))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function logSince(log: string, from: number): string {
|
|
296
|
+
try {
|
|
297
|
+
return readFileSync(log).subarray(from).toString('utf8')
|
|
298
|
+
} catch {
|
|
299
|
+
return ''
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** "with code 1", "on SIGTERM": how a dev server ended, for a sentence. */
|
|
304
|
+
export function exitWords(exit: Pick<DevServerExit, 'code' | 'signal'>): string {
|
|
305
|
+
return exit.code !== null ? `with code ${exit.code}` : exit.signal ? `on ${exit.signal}` : 'with no exit code'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const TAIL_LINES = 30
|
|
309
|
+
|
|
310
|
+
/** The last lines written to a log from an offset on: one run's output. */
|
|
311
|
+
function logTail(log: string, from: number): string {
|
|
312
|
+
try {
|
|
313
|
+
// A byte offset: the log holds UTF-8 (Next.js prints ▲ and ✓), so it is cut as bytes, then read.
|
|
314
|
+
const text = readFileSync(log).subarray(from).toString('utf8')
|
|
315
|
+
return text.split('\n').map((l) => l.trimEnd()).filter((l, i, all) => l || i < all.length - 1).slice(-TAIL_LINES).join('\n').trim()
|
|
316
|
+
} catch {
|
|
317
|
+
return ''
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** SIGTERM to a dev server's whole group (`app dev` and the framework's server under it), or to it alone. */
|
|
322
|
+
function stopGroup(pid: number): boolean {
|
|
323
|
+
try {
|
|
324
|
+
process.kill(-pid, 'SIGTERM')
|
|
325
|
+
return true
|
|
326
|
+
} catch {
|
|
327
|
+
try {
|
|
328
|
+
process.kill(pid, 'SIGTERM')
|
|
329
|
+
return true
|
|
330
|
+
} catch {
|
|
331
|
+
return false
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Whether a recorded pid is still an App dev server (`frontera app dev`, or the
|
|
338
|
+
* framework under it), not an unrelated process that took the pid since: only
|
|
339
|
+
* such a process is ever signalled. Where `ps` can't say, the record is trusted.
|
|
340
|
+
*/
|
|
341
|
+
export function isDevServerProcess(pid: number): boolean {
|
|
342
|
+
const res = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' })
|
|
343
|
+
if (res.error) return true
|
|
344
|
+
if (res.status !== 0) return false
|
|
345
|
+
return /\bapp\b.*\bdev\b|\bnext\b.*\bdev\b|\bvite\b/.test(res.stdout)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Stop an App's dev server and wait (a while) for it to end, then say what
|
|
350
|
+
* `frontera app dev` put back as it stopped: the tracked files the server
|
|
351
|
+
* rewrote. `stopped` false when none ran.
|
|
352
|
+
*/
|
|
353
|
+
export async function stopDevServerAndSay(project: LocalProject, slug: string, waitMs = 15_000): Promise<{ stopped: boolean; trackedFiles: string[] }> {
|
|
354
|
+
const server = readDevServer(project, slug)
|
|
355
|
+
const log = server?.log ? join(project.root, server.log) : null
|
|
356
|
+
let from = 0
|
|
357
|
+
try {
|
|
358
|
+
from = log ? statSync(log).size : 0
|
|
359
|
+
} catch {
|
|
360
|
+
// No log (started by hand): nothing to read back.
|
|
361
|
+
}
|
|
362
|
+
if (!stopDevServer(project, slug) || !server) return { stopped: false, trackedFiles: [] }
|
|
363
|
+
const deadline = Date.now() + waitMs
|
|
364
|
+
while (Date.now() < deadline && isAlive(server.pid)) await new Promise((r) => setTimeout(r, 200))
|
|
365
|
+
return { stopped: true, trackedFiles: log && existsSync(log) ? trackedFileLines(log, from) : [] }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Stop an App's dev server, when one runs. Only this project's record is read, so another project's server is never touched. */
|
|
369
|
+
export function stopDevServer(project: LocalProject, slug: string): boolean {
|
|
370
|
+
const server = readDevServer(project, slug)
|
|
371
|
+
rmSync(devFile(project, slug), { force: true })
|
|
372
|
+
if (!server || !isDevServerProcess(server.pid)) return false
|
|
373
|
+
// The whole group: `app dev` and the framework's server under it.
|
|
374
|
+
return stopGroup(server.pid)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Stop every App dev server this project records (`local stop`, `local
|
|
379
|
+
* remove`): the ones an MCP session started and the ones run by hand, which
|
|
380
|
+
* point at the runtime that is going away. Records of servers already gone
|
|
381
|
+
* are cleared. Returns the servers stopped.
|
|
382
|
+
*/
|
|
383
|
+
export function stopDevServers(project: LocalProject): DevServer[] {
|
|
384
|
+
const stopped: DevServer[] = []
|
|
385
|
+
let files: string[] = []
|
|
386
|
+
try {
|
|
387
|
+
files = readdirSync(devDir(project)).filter((f) => f.endsWith('.json'))
|
|
388
|
+
} catch {
|
|
389
|
+
return stopped
|
|
390
|
+
}
|
|
391
|
+
for (const f of files) {
|
|
392
|
+
const slug = f.replace(/\.json$/, '')
|
|
393
|
+
const server = readDevServer(project, slug)
|
|
394
|
+
if (stopDevServer(project, slug) && server) stopped.push(server)
|
|
395
|
+
}
|
|
396
|
+
return stopped
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Stop the dev servers this process started: an MCP session's when its
|
|
401
|
+
* connection closes, Forge's when it quits. One another session or a person
|
|
402
|
+
* has since taken over (a different pid in the record) is left running.
|
|
403
|
+
*/
|
|
404
|
+
export function stopStartedDevServers(): number {
|
|
405
|
+
let n = 0
|
|
406
|
+
for (const { project, slug, pid } of startedHere.values()) {
|
|
407
|
+
if (readDevServer(project, slug)?.pid === pid && stopDevServer(project, slug)) n++
|
|
408
|
+
}
|
|
409
|
+
startedHere.clear()
|
|
410
|
+
return n
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Call `onGone` once, when the process `owner` is no longer there (checked
|
|
415
|
+
* every `everyMs`): how `frontera app dev` started by an MCP session stops
|
|
416
|
+
* with it, however the session ends. Returns the stop for the watch.
|
|
417
|
+
*/
|
|
418
|
+
export function watchOwner(owner: number, onGone: () => void, everyMs = 5_000): () => void {
|
|
419
|
+
const timer = setInterval(() => {
|
|
420
|
+
if (isAlive(owner)) return
|
|
421
|
+
clearInterval(timer)
|
|
422
|
+
onGone()
|
|
423
|
+
}, everyMs)
|
|
424
|
+
return () => clearInterval(timer)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** "cdl-console on port 3101 (http://127.0.0.1:3101), started by an MCP session (pid 4242)" */
|
|
428
|
+
export function devServerLine(s: DevServer): string {
|
|
429
|
+
const by = !s.owner
|
|
430
|
+
? 'started by hand (`frontera app dev`)'
|
|
431
|
+
: s.owner.kind === 'forge'
|
|
432
|
+
? `started from Forge (pid ${s.owner.pid}); it stops when Forge stops it or quits`
|
|
433
|
+
: `started by an MCP session (pid ${s.owner.pid}); it stops when that session ends`
|
|
434
|
+
return `${s.slug} on port ${s.port} (${s.url}), ${by}`
|
|
435
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { parkCall, type ToolCall } from '../local/local-test-runner'
|
|
2
|
+
import type { RuntimeLink } from './mcp-runtime'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One Agent turn as `agent_chat` reads it: the reply streamed from
|
|
6
|
+
* `/v1/chat`, read until the turn finishes or the Agent waits on the person.
|
|
7
|
+
*
|
|
8
|
+
* A turn that waits on the person keeps its stream open: a Function asked
|
|
9
|
+
* the person to confirm (`data-person-confirmation`), or a tool call waits
|
|
10
|
+
* for an approval (`tool-approval-request`). Read to its end, such a turn
|
|
11
|
+
* never ends, which is how a follow-up looked like a timeout. So the reader
|
|
12
|
+
* stops there, says what is waiting, and keeps the stream: the answer
|
|
13
|
+
* (`agent_chat` with `confirm` or `approval`) settles it and the rest of the
|
|
14
|
+
* turn is read from the same stream, or from the run's journal when that
|
|
15
|
+
* connection is gone.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface PendingConfirmation {
|
|
19
|
+
kind: 'confirmation'
|
|
20
|
+
id: string
|
|
21
|
+
summary: string
|
|
22
|
+
details: string | null
|
|
23
|
+
confirmLabel: string
|
|
24
|
+
cancelLabel: string
|
|
25
|
+
expiresAt?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PendingApproval {
|
|
29
|
+
kind: 'approval'
|
|
30
|
+
id: string
|
|
31
|
+
toolCallId: string | null
|
|
32
|
+
tool: string | null
|
|
33
|
+
summary?: string | null
|
|
34
|
+
expiresAt?: string
|
|
35
|
+
/** The card's reason, as the person reads it. */
|
|
36
|
+
reason?: string | null
|
|
37
|
+
/** What asked: the binding's own setting, or the chat's approval mode. */
|
|
38
|
+
askedBecause?: 'binding' | 'approval-mode'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type PendingDecision = PendingConfirmation | PendingApproval
|
|
42
|
+
|
|
43
|
+
export type TurnEnd =
|
|
44
|
+
| { status: 'finished' }
|
|
45
|
+
| { status: 'waiting'; pending: PendingDecision }
|
|
46
|
+
/** Nothing arrived for the idle window: the runtime took the turn and sent nothing back. */
|
|
47
|
+
| { status: 'idle'; silentMs: number }
|
|
48
|
+
| { status: 'timeout'; elapsedMs: number }
|
|
49
|
+
|
|
50
|
+
export interface TurnRead {
|
|
51
|
+
reply: string
|
|
52
|
+
toolCalls: ToolCall[]
|
|
53
|
+
end: TurnEnd
|
|
54
|
+
/** The run behind the turn (`x-workflow-run-id`), to read its journal again. */
|
|
55
|
+
runId: string | null
|
|
56
|
+
/** Stream chunks read so far: where the journal picks up. */
|
|
57
|
+
chunks: number
|
|
58
|
+
/** Events seen, by type, for a turn that ended oddly. */
|
|
59
|
+
events: Record<string, number>
|
|
60
|
+
lastEvent: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A turn's stream, kept between calls while the Agent waits on the person. */
|
|
64
|
+
/** The part of a stream reader a held turn uses. */
|
|
65
|
+
interface ChunkReader {
|
|
66
|
+
read(): Promise<{ done: boolean; value?: Uint8Array }>
|
|
67
|
+
cancel(): Promise<void>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HeldTurn {
|
|
71
|
+
reader: ChunkReader | null
|
|
72
|
+
buffer: string
|
|
73
|
+
runId: string | null
|
|
74
|
+
chunks: number
|
|
75
|
+
reply: string
|
|
76
|
+
calls: Map<string, ToolCall & { id: string }>
|
|
77
|
+
events: Record<string, number>
|
|
78
|
+
lastEvent: string | null
|
|
79
|
+
/** Decisions already reported, so a replay of the journal does not stop on them again. */
|
|
80
|
+
seen: Set<string>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const TURN_IDLE_MS = 90_000
|
|
84
|
+
export const TURN_TOTAL_MS = 240_000
|
|
85
|
+
|
|
86
|
+
function headers(link: RuntimeLink): Record<string, string> {
|
|
87
|
+
return {
|
|
88
|
+
authorization: `Bearer ${link.creds.session}`,
|
|
89
|
+
'x-org-id': link.creds.organizationId,
|
|
90
|
+
'x-workspace-id': link.creds.workspaceId,
|
|
91
|
+
'content-type': 'application/json',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Send the person's message and hold the turn's stream. */
|
|
96
|
+
export async function startTurn(link: RuntimeLink, args: { conversationId: string; agentName: string; message: string }): Promise<HeldTurn> {
|
|
97
|
+
const res = await fetch(new URL('/v1/chat', link.serviceUrl), {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: headers(link),
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
conversationId: args.conversationId,
|
|
102
|
+
agentName: args.agentName,
|
|
103
|
+
messages: [{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: args.message }] }],
|
|
104
|
+
}),
|
|
105
|
+
})
|
|
106
|
+
// A 409 carries what waits on the person (`decision_pending`): kept whole for the caller to read.
|
|
107
|
+
if (!res.ok || !res.body) throw new Error(`chat answered ${res.status}: ${(await res.text()).slice(0, res.status === 409 ? 20_000 : 300)}`)
|
|
108
|
+
return { reader: res.body.getReader() as ChunkReader, buffer: '', runId: res.headers.get('x-workflow-run-id'), chunks: 0, reply: '', calls: new Map(), events: {}, lastEvent: null, seen: new Set() }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Pick the held turn up again from the run's journal, after its connection went away. */
|
|
112
|
+
export async function reattachTurn(link: RuntimeLink, held: HeldTurn): Promise<HeldTurn> {
|
|
113
|
+
if (!held.runId) throw new Error('the turn has no run to reattach to')
|
|
114
|
+
const res = await fetch(new URL(`/v1/chat/${encodeURIComponent(held.runId)}/stream?startIndex=${held.chunks}`, link.serviceUrl), { headers: headers(link) })
|
|
115
|
+
if (res.status === 404) {
|
|
116
|
+
// The run settled while nobody read it: what it said is in the conversation.
|
|
117
|
+
return { ...held, reader: null }
|
|
118
|
+
}
|
|
119
|
+
if (!res.ok || !res.body) throw new Error(`the turn's stream answered ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
|
120
|
+
return { ...held, reader: res.body.getReader() as ChunkReader, buffer: '' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface StreamEvent {
|
|
124
|
+
type?: string
|
|
125
|
+
delta?: string
|
|
126
|
+
errorText?: string
|
|
127
|
+
toolCallId?: string
|
|
128
|
+
toolName?: string
|
|
129
|
+
input?: unknown
|
|
130
|
+
args?: unknown
|
|
131
|
+
approvalId?: string
|
|
132
|
+
data?: Record<string, unknown>
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One event: reply text, a tool call, or a decision the Agent now waits on. */
|
|
136
|
+
function take(held: HeldTurn, event: StreamEvent): PendingDecision | null {
|
|
137
|
+
const type = event.type ?? 'unknown'
|
|
138
|
+
held.events[type] = (held.events[type] ?? 0) + 1
|
|
139
|
+
held.lastEvent = type
|
|
140
|
+
if (type === 'text-delta' && typeof event.delta === 'string') held.reply += event.delta
|
|
141
|
+
if (type === 'tool-input-available' && event.toolCallId && event.toolName) held.calls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, args: event.input, ok: false })
|
|
142
|
+
if (type === 'tool-output-available' && event.toolCallId && held.calls.has(event.toolCallId)) Object.assign(held.calls.get(event.toolCallId)!, { ok: true, parked: false })
|
|
143
|
+
// Denied or failed: it waits no longer, and never ran.
|
|
144
|
+
if ((type === 'tool-output-denied' || type === 'tool-output-error') && event.toolCallId && held.calls.has(event.toolCallId)) held.calls.get(event.toolCallId)!.parked = false
|
|
145
|
+
if (type === 'error') throw new Error(event.errorText ?? 'the turn failed')
|
|
146
|
+
if (type === 'tool-approval-request' && event.approvalId && !held.seen.has(event.approvalId)) {
|
|
147
|
+
held.seen.add(event.approvalId)
|
|
148
|
+
// The call waits on the person: a test saved from this turn counts it as called.
|
|
149
|
+
const parked = parkCall(held.calls, event)
|
|
150
|
+
const call = parked ? held.calls.get(parked.id) : event.toolCallId ? held.calls.get(event.toolCallId) : undefined
|
|
151
|
+
return { kind: 'approval', id: event.approvalId, toolCallId: event.toolCallId ?? null, tool: call?.name ?? null }
|
|
152
|
+
}
|
|
153
|
+
if (type === 'data-person-confirmation' && event.data) {
|
|
154
|
+
const d = event.data as Partial<PendingConfirmation> & { status?: string; id?: string }
|
|
155
|
+
if (d.status === 'pending' && d.id && !held.seen.has(d.id)) {
|
|
156
|
+
held.seen.add(d.id)
|
|
157
|
+
return { kind: 'confirmation', id: d.id, summary: String(d.summary ?? ''), details: (d.details as string | null) ?? null, confirmLabel: String(d.confirmLabel ?? 'Confirm'), cancelLabel: String(d.cancelLabel ?? 'Cancel'), ...(d.expiresAt ? { expiresAt: String(d.expiresAt) } : {}) }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read the held turn until it finishes, waits on the person, goes silent
|
|
165
|
+
* for `idleMs`, or runs past `totalMs`. The reply and tool calls are
|
|
166
|
+
* everything since the person's message, across reads.
|
|
167
|
+
*/
|
|
168
|
+
export async function readTurn(held: HeldTurn, opts: { idleMs?: number; totalMs?: number } = {}): Promise<TurnRead> {
|
|
169
|
+
const idleMs = opts.idleMs ?? TURN_IDLE_MS
|
|
170
|
+
const totalMs = opts.totalMs ?? TURN_TOTAL_MS
|
|
171
|
+
const started = Date.now()
|
|
172
|
+
const decoder = new TextDecoder()
|
|
173
|
+
const result = (end: TurnEnd): TurnRead => ({ reply: held.reply, toolCalls: [...held.calls.values()].map(({ id: _id, ...c }) => c), end, runId: held.runId, chunks: held.chunks, events: { ...held.events }, lastEvent: held.lastEvent })
|
|
174
|
+
if (!held.reader) return result({ status: 'finished' })
|
|
175
|
+
let lastData = Date.now()
|
|
176
|
+
for (;;) {
|
|
177
|
+
// Keepalive comments arrive without data; only data counts against the idle window.
|
|
178
|
+
const wait = Math.max(0, Math.min(idleMs - (Date.now() - lastData), totalMs - (Date.now() - started)))
|
|
179
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
180
|
+
const next = await Promise.race([
|
|
181
|
+
held.reader.read(),
|
|
182
|
+
new Promise<'timer'>((r) => {
|
|
183
|
+
timer = setTimeout(() => r('timer'), wait)
|
|
184
|
+
}),
|
|
185
|
+
]).finally(() => clearTimeout(timer))
|
|
186
|
+
if (next === 'timer') {
|
|
187
|
+
if (Date.now() - started >= totalMs) return result({ status: 'timeout', elapsedMs: Date.now() - started })
|
|
188
|
+
return result({ status: 'idle', silentMs: Date.now() - lastData })
|
|
189
|
+
}
|
|
190
|
+
if (next.done) {
|
|
191
|
+
held.reader = null
|
|
192
|
+
return result({ status: 'finished' })
|
|
193
|
+
}
|
|
194
|
+
held.buffer += decoder.decode(next.value, { stream: true })
|
|
195
|
+
let newline: number
|
|
196
|
+
while ((newline = held.buffer.indexOf('\n')) >= 0) {
|
|
197
|
+
const line = held.buffer.slice(0, newline).trim()
|
|
198
|
+
held.buffer = held.buffer.slice(newline + 1)
|
|
199
|
+
if (!line.startsWith('data:')) continue
|
|
200
|
+
const payload = line.slice(5).trim()
|
|
201
|
+
if (payload === '[DONE]') continue
|
|
202
|
+
let event: StreamEvent
|
|
203
|
+
try {
|
|
204
|
+
event = JSON.parse(payload) as StreamEvent
|
|
205
|
+
} catch {
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
held.chunks++
|
|
209
|
+
lastData = Date.now()
|
|
210
|
+
const pending = take(held, event)
|
|
211
|
+
if (pending) return result({ status: 'waiting', pending })
|
|
212
|
+
if (event.type === 'finish') {
|
|
213
|
+
// The turn is over; nothing after `finish` belongs to the reply.
|
|
214
|
+
void held.reader.cancel().catch(() => {})
|
|
215
|
+
held.reader = null
|
|
216
|
+
return result({ status: 'finished' })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Stop reading a held turn: the conversation moved on or the connection is being dropped. */
|
|
223
|
+
export function dropTurn(held: HeldTurn | undefined): void {
|
|
224
|
+
void held?.reader?.cancel().catch(() => {})
|
|
225
|
+
if (held) held.reader = null
|
|
226
|
+
}
|