@frontera-sdk/cli 1.46.6 → 1.46.7
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 +6 -3
- package/src/commands/forge/index-commands.ts +4 -0
- package/src/commands/forge/serve.ts +144 -0
- package/src/commands/registry.ts +3 -0
- package/src/flag-help.ts +7 -1
- package/src/forge/adapter.ts +108 -0
- package/src/forge/claude-adapter.ts +205 -0
- package/src/forge/claude-commands.ts +136 -0
- package/src/forge/claude-skills.ts +189 -0
- package/src/forge/claude-translator.ts +361 -0
- package/src/forge/codex-adapter.ts +28 -0
- package/src/forge/env.ts +42 -0
- package/src/forge/hosts.ts +323 -0
- package/src/forge/http.ts +74 -0
- package/src/forge/log.ts +53 -0
- package/src/forge/projects.ts +349 -0
- package/src/forge/protocol.ts +38 -0
- package/src/forge/runs.ts +250 -0
- package/src/forge/server.ts +655 -0
- package/src/forge/sessions.ts +49 -0
- package/src/forge/store.ts +427 -0
- package/src/forge/terminals.ts +220 -0
- package/src/forge/turn-text.ts +35 -0
- package/src/kit.ts +25 -7
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { ForgeChangeStatus, ForgeChangedFile, ForgeChanges, ForgeFileEntry } from '@frontera-sdk/forge-contracts'
|
|
7
|
+
|
|
8
|
+
import { findProjectRoot } from '../context'
|
|
9
|
+
import { readProject } from '../project'
|
|
10
|
+
import { applyClaudeImport, applyManagedBlock, type BlockMarkers } from '../kit'
|
|
11
|
+
import { ensureGitignored, findContext, writeContext } from '../project-context'
|
|
12
|
+
import { listProfiles, normalizeOrigin } from '../profiles'
|
|
13
|
+
import { badRequest } from './http'
|
|
14
|
+
import type { NewProject } from './store'
|
|
15
|
+
|
|
16
|
+
/** Directories nobody browses by hand, and that would swamp a listing. */
|
|
17
|
+
const SKIPPED_DIRS = new Set(['node_modules', '.git'])
|
|
18
|
+
|
|
19
|
+
/** Untracked files above this are counted as "large", not read line by line. */
|
|
20
|
+
const MAX_COUNTED_BYTES = 1024 * 1024
|
|
21
|
+
|
|
22
|
+
function git(root: string, args: string[]): { ok: boolean; out: string } {
|
|
23
|
+
const res = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
24
|
+
if (res.error || res.status !== 0) return { ok: false, out: '' }
|
|
25
|
+
return { ok: true, out: res.stdout ?? '' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isGitWorkTree(root: string): boolean {
|
|
29
|
+
const res = git(root, ['rev-parse', '--is-inside-work-tree'])
|
|
30
|
+
return res.ok && res.out.trim() === 'true'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a root is, detected fresh: a git tree, a Frontera App, a bound profile. */
|
|
34
|
+
export function inspectRoot(rawRoot: string): NewProject {
|
|
35
|
+
if (typeof rawRoot !== 'string' || rawRoot.trim() === '') throw badRequest('root is required')
|
|
36
|
+
const root = resolve(rawRoot)
|
|
37
|
+
let stat
|
|
38
|
+
try {
|
|
39
|
+
stat = statSync(root)
|
|
40
|
+
} catch {
|
|
41
|
+
throw badRequest(`${root} does not exist`)
|
|
42
|
+
}
|
|
43
|
+
if (!stat.isDirectory()) throw badRequest(`${root} is not a directory`)
|
|
44
|
+
|
|
45
|
+
let appSlug: string | null = null
|
|
46
|
+
if (findProjectRoot(root) === root) {
|
|
47
|
+
try {
|
|
48
|
+
appSlug = readProject(root).slug
|
|
49
|
+
} catch {
|
|
50
|
+
appSlug = null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let profile: string | null = null
|
|
55
|
+
try {
|
|
56
|
+
profile = findContext(root)?.profile ?? null
|
|
57
|
+
} catch {
|
|
58
|
+
// A corrupt context file is the CLI's problem to report, not a reason to
|
|
59
|
+
// refuse opening the folder.
|
|
60
|
+
profile = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { root, name: basename(root) || root, git: isGitWorkTree(root), appSlug, profile }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What is already true in this folder, written where both hosts read it.
|
|
68
|
+
*
|
|
69
|
+
* Codex reads `AGENTS.md` and Claude Code reads `CLAUDE.md` (which imports it)
|
|
70
|
+
* before the first turn, with no prompt injection and no per-turn cost — so
|
|
71
|
+
* this is the harness-native place to hand over the situation. It survives the
|
|
72
|
+
* developer running `claude` here from their own terminal, which a per-turn
|
|
73
|
+
* brief would not.
|
|
74
|
+
*
|
|
75
|
+
* FACTS, not instructions. The skills say what to do; a brief that argues with
|
|
76
|
+
* them produces a model that argues with both. Everything here is something
|
|
77
|
+
* Forge arranged and the agent would otherwise have to derive: which
|
|
78
|
+
* deployment, which workspace, which credential, what is already connected.
|
|
79
|
+
*
|
|
80
|
+
* Written as a managed block, so anything the developer adds to the same file
|
|
81
|
+
* stays where they put it.
|
|
82
|
+
*/
|
|
83
|
+
const FORGE_MARKERS: BlockMarkers = {
|
|
84
|
+
begin: '<!-- frontera:forge:begin — managed by Frontera Forge; edits here are replaced -->',
|
|
85
|
+
end: '<!-- frontera:forge:end -->',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function situationBlock(input: {
|
|
89
|
+
workspaceName: string
|
|
90
|
+
workspaceId: string
|
|
91
|
+
apiUrl: string | null
|
|
92
|
+
profile: string | null
|
|
93
|
+
root: string
|
|
94
|
+
}): string {
|
|
95
|
+
const lines = [
|
|
96
|
+
'## Frontera Forge',
|
|
97
|
+
'',
|
|
98
|
+
`This folder is managed by Frontera Forge for the **${input.workspaceName}** workspace`
|
|
99
|
+
+ `${input.apiUrl ? ` on ${input.apiUrl}` : ''}. It is not a checkout of a`
|
|
100
|
+
+ " product repository — it is where this workspace's local work lives.",
|
|
101
|
+
'',
|
|
102
|
+
'- The `frontera` CLI is on PATH.'
|
|
103
|
+
+ (input.profile
|
|
104
|
+
? ` This directory is bound to the \`${input.profile}\` profile (\`.frontera/context.json\`), so commands here need no \`--profile\` or \`--api-url\`.`
|
|
105
|
+
: ' No profile is bound to this directory yet, so commands need `--profile` or `--api-url`.'),
|
|
106
|
+
'- The platform\'s tools are connected as the `frontera` MCP server: Apps,'
|
|
107
|
+
+ ' Agents, Blueprint, Skills, Knowledge, Packs and navigation are available'
|
|
108
|
+
+ ' as `mcp__frontera__*` tools. Prefer them over guessing at platform state,'
|
|
109
|
+
+ ' and over shelling out for something a tool already answers.',
|
|
110
|
+
'- The Frontera plugin is installed in this host, so the `frontera:*` skills'
|
|
111
|
+
+ ' (what the artifacts are, how to author and publish them) load on demand.',
|
|
112
|
+
'- App source belongs at `./<slug>`, pulled with'
|
|
113
|
+
+ ' `frontera app pull <slug> --dir ./<slug>`.',
|
|
114
|
+
'',
|
|
115
|
+
`Workspace id \`${input.workspaceId}\`.`,
|
|
116
|
+
]
|
|
117
|
+
return lines.join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Write (or refresh) the situation into `AGENTS.md` + `CLAUDE.md`.
|
|
122
|
+
*
|
|
123
|
+
* Idempotent and cheap: both hosts re-read the files each session, so keeping
|
|
124
|
+
* them current on every folder resolve is how a workspace rename or a
|
|
125
|
+
* re-binding reaches the agent without anyone remembering to do it.
|
|
126
|
+
*/
|
|
127
|
+
function writeSituationFiles(root: string, body: string): void {
|
|
128
|
+
try {
|
|
129
|
+
const agentsPath = join(root, 'AGENTS.md')
|
|
130
|
+
const existing = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : null
|
|
131
|
+
const next = applyManagedBlock(existing, body, FORGE_MARKERS)
|
|
132
|
+
if (next !== existing) writeFileSync(agentsPath, next)
|
|
133
|
+
|
|
134
|
+
const claudePath = join(root, 'CLAUDE.md')
|
|
135
|
+
const claudeExisting = existsSync(claudePath) ? readFileSync(claudePath, 'utf8') : null
|
|
136
|
+
const { content } = applyClaudeImport(claudeExisting, '@AGENTS.md')
|
|
137
|
+
if (content !== claudeExisting) writeFileSync(claudePath, content)
|
|
138
|
+
} catch {
|
|
139
|
+
// A read-only folder is the developer's business; the session still works.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Where Forge keeps a workspace's folder. Visible on purpose: developers open it in editors. */
|
|
144
|
+
export function workspaceRoot(workspaceSlug: string): string {
|
|
145
|
+
const safe = workspaceSlug.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'workspace'
|
|
146
|
+
return join(homedir(), 'Frontera', safe)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The profile bound to this deployment's API origin, if the CLI has one. */
|
|
150
|
+
function profileForOrigin(apiUrl: string | null): string | null {
|
|
151
|
+
if (!apiUrl) return null
|
|
152
|
+
let origin: string
|
|
153
|
+
try {
|
|
154
|
+
origin = normalizeOrigin(apiUrl)
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const match = listProfiles().find((p) => {
|
|
160
|
+
try {
|
|
161
|
+
return normalizeOrigin(p.profile.apiUrl) === origin
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
return match?.name ?? null
|
|
167
|
+
} catch {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create-or-adopt the managed folder for a workspace: mkdir, `git init` so
|
|
174
|
+
* Changes has a baseline, and bind the deployment's profile so `frontera`
|
|
175
|
+
* and the host's Frontera MCP resolve there without anyone choosing.
|
|
176
|
+
* Idempotent; a folder the developer already turned into a repo or bound
|
|
177
|
+
* to a profile is left as it is.
|
|
178
|
+
*/
|
|
179
|
+
export function ensureWorkspaceProject(
|
|
180
|
+
input: { workspaceId: string; workspaceSlug: string; workspaceName?: string },
|
|
181
|
+
apiUrl: string | null,
|
|
182
|
+
warn: (message: string) => void = () => {},
|
|
183
|
+
): NewProject {
|
|
184
|
+
const root = workspaceRoot(input.workspaceSlug)
|
|
185
|
+
mkdirSync(root, { recursive: true })
|
|
186
|
+
if (!isGitWorkTree(root)) {
|
|
187
|
+
spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' })
|
|
188
|
+
}
|
|
189
|
+
let bound: string | null = null
|
|
190
|
+
try {
|
|
191
|
+
bound = findContext(root)?.profile ?? null
|
|
192
|
+
} catch {
|
|
193
|
+
bound = null
|
|
194
|
+
}
|
|
195
|
+
if (!bound) {
|
|
196
|
+
const profile = profileForOrigin(apiUrl)
|
|
197
|
+
if (!profile) {
|
|
198
|
+
warn(`no CLI profile for ${apiUrl ?? '(no api url)'}; ${root} left unbound`)
|
|
199
|
+
} else {
|
|
200
|
+
try {
|
|
201
|
+
writeContext(root, profile)
|
|
202
|
+
if (existsSync(join(root, '.git'))) ensureGitignored(root)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Binding is a convenience; the folder still works without it.
|
|
205
|
+
warn(`could not bind ${root} to profile ${profile}: ${error instanceof Error ? error.message : String(error)}`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const detected = inspectRoot(root)
|
|
210
|
+
const name = input.workspaceName?.trim() || detected.name
|
|
211
|
+
writeSituationFiles(
|
|
212
|
+
root,
|
|
213
|
+
situationBlock({
|
|
214
|
+
workspaceName: name,
|
|
215
|
+
workspaceId: input.workspaceId,
|
|
216
|
+
apiUrl,
|
|
217
|
+
profile: detected.profile,
|
|
218
|
+
root,
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
return { ...detected, name, workspaceId: input.workspaceId }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** One level of a project tree, directories first. Never escapes the root. */
|
|
225
|
+
export function listFiles(root: string, rel: string): ForgeFileEntry[] {
|
|
226
|
+
const target = resolve(root, rel || '.')
|
|
227
|
+
const inside = target === root || target.startsWith(root + sep)
|
|
228
|
+
if (!inside) throw badRequest('path escapes the project root')
|
|
229
|
+
let entries
|
|
230
|
+
try {
|
|
231
|
+
entries = readdirSync(target, { withFileTypes: true })
|
|
232
|
+
} catch {
|
|
233
|
+
throw badRequest(`${relative(root, target) || '.'} is not a readable directory`)
|
|
234
|
+
}
|
|
235
|
+
const out: ForgeFileEntry[] = []
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
if (SKIPPED_DIRS.has(entry.name)) continue
|
|
238
|
+
const abs = join(target, entry.name)
|
|
239
|
+
const path = relative(root, abs).split(sep).join('/')
|
|
240
|
+
if (entry.isDirectory()) {
|
|
241
|
+
out.push({ name: entry.name, path, kind: 'dir' })
|
|
242
|
+
} else {
|
|
243
|
+
let size: number | undefined
|
|
244
|
+
try {
|
|
245
|
+
size = statSync(abs).size
|
|
246
|
+
} catch {
|
|
247
|
+
size = undefined
|
|
248
|
+
}
|
|
249
|
+
out.push({ name: entry.name, path, kind: 'file', ...(size !== undefined ? { size } : {}) })
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function statusFor(xy: string): ForgeChangeStatus | null {
|
|
256
|
+
if (xy === '??') return 'untracked'
|
|
257
|
+
const x = xy[0] ?? ' '
|
|
258
|
+
const y = xy[1] ?? ' '
|
|
259
|
+
const code = x !== ' ' ? x : y
|
|
260
|
+
switch (code) {
|
|
261
|
+
case 'A':
|
|
262
|
+
return 'added'
|
|
263
|
+
case 'D':
|
|
264
|
+
return 'deleted'
|
|
265
|
+
case 'R':
|
|
266
|
+
case 'C':
|
|
267
|
+
return 'renamed'
|
|
268
|
+
case 'M':
|
|
269
|
+
case 'T':
|
|
270
|
+
case 'U':
|
|
271
|
+
return 'modified'
|
|
272
|
+
default:
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function countLines(abs: string): number {
|
|
278
|
+
try {
|
|
279
|
+
const stat = statSync(abs)
|
|
280
|
+
if (!stat.isFile() || stat.size > MAX_COUNTED_BYTES) return 0
|
|
281
|
+
const buf = readFileSync(abs)
|
|
282
|
+
// A NUL in the first block is the same heuristic git uses for "binary".
|
|
283
|
+
if (buf.subarray(0, 8000).includes(0)) return 0
|
|
284
|
+
let n = 0
|
|
285
|
+
for (const byte of buf) if (byte === 10) n += 1
|
|
286
|
+
if (buf.length > 0 && buf[buf.length - 1] !== 10) n += 1
|
|
287
|
+
return n
|
|
288
|
+
} catch {
|
|
289
|
+
return 0
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parse `git diff --numstat`: `added\tdeleted\tpath`, with `-` for binary and
|
|
295
|
+
* `old => new` (or `{a => b}/rest`) paths for renames. The rename form is
|
|
296
|
+
* folded to the new path so it matches the status listing.
|
|
297
|
+
*/
|
|
298
|
+
function parseNumstat(out: string): Map<string, { additions: number; deletions: number }> {
|
|
299
|
+
const map = new Map<string, { additions: number; deletions: number }>()
|
|
300
|
+
for (const line of out.split('\n')) {
|
|
301
|
+
if (!line) continue
|
|
302
|
+
const [a, d, ...rest] = line.split('\t')
|
|
303
|
+
const rawPath = rest.join('\t')
|
|
304
|
+
if (!rawPath) continue
|
|
305
|
+
const path = rawPath.includes(' => ')
|
|
306
|
+
? rawPath.replace(/\{([^{}]*) => ([^{}]*)\}/g, '$2').replace(/^.* => /, '')
|
|
307
|
+
: rawPath
|
|
308
|
+
map.set(path, { additions: a === '-' ? 0 : Number(a) || 0, deletions: d === '-' ? 0 : Number(d) || 0 })
|
|
309
|
+
}
|
|
310
|
+
return map
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Working tree vs HEAD. Not a git tree, or no commits yet: as much as exists. */
|
|
314
|
+
export function readChanges(root: string): ForgeChanges {
|
|
315
|
+
if (!isGitWorkTree(root)) return { files: [], patch: '', head: null, branch: null }
|
|
316
|
+
|
|
317
|
+
const headRes = git(root, ['rev-parse', 'HEAD'])
|
|
318
|
+
const head = headRes.ok ? headRes.out.trim() : null
|
|
319
|
+
const branchRes = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
320
|
+
const branch = branchRes.ok ? branchRes.out.trim() : null
|
|
321
|
+
|
|
322
|
+
// Before the first commit there is no HEAD to diff against; the index is
|
|
323
|
+
// the closest thing to a baseline.
|
|
324
|
+
const base = head ? ['HEAD'] : []
|
|
325
|
+
const numstat = parseNumstat(git(root, ['diff', ...base, '--numstat']).out)
|
|
326
|
+
const patch = git(root, ['diff', ...base]).out
|
|
327
|
+
|
|
328
|
+
const files: ForgeChangedFile[] = []
|
|
329
|
+
const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
|
|
330
|
+
const entries = status.out.split('\0')
|
|
331
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
332
|
+
const entry = entries[i]
|
|
333
|
+
if (!entry || entry.length < 4) continue
|
|
334
|
+
const xy = entry.slice(0, 2)
|
|
335
|
+
const path = entry.slice(3)
|
|
336
|
+
const kind = statusFor(xy)
|
|
337
|
+
// A rename carries its origin as the next NUL-separated field.
|
|
338
|
+
if (xy[0] === 'R' || xy[0] === 'C') i += 1
|
|
339
|
+
if (!kind) continue
|
|
340
|
+
if (kind === 'untracked') {
|
|
341
|
+
files.push({ path, status: kind, additions: countLines(join(root, path)), deletions: 0 })
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
const counts = numstat.get(path) ?? { additions: 0, deletions: 0 }
|
|
345
|
+
files.push({ path, status: kind, ...counts })
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { files, patch, head, branch }
|
|
349
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handful of wire constants the daemon needs at RUNTIME.
|
|
3
|
+
*
|
|
4
|
+
* They are duplicated from `@frontera-sdk/forge-contracts` on purpose. That
|
|
5
|
+
* package is private and internal — it is the shared type surface for the
|
|
6
|
+
* desktop shell and the web surface — while this CLI is published to npm. A
|
|
7
|
+
* published package that depends on an unpublished one resolves nowhere:
|
|
8
|
+
* `bunx @frontera-sdk/cli` would fail for every consumer, which is the exact
|
|
9
|
+
* breakage the release workflow's `workspace:` gate exists to catch.
|
|
10
|
+
*
|
|
11
|
+
* So the contracts package stays a devDependency here, imported for types
|
|
12
|
+
* only (erased at build), and the four values live here. `protocol.test.ts`
|
|
13
|
+
* asserts the two agree, so the duplication cannot drift silently.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Response headers the platform's chat client reads. */
|
|
17
|
+
export const FORGE_HEADERS = {
|
|
18
|
+
conversationId: 'X-Conversation-Id',
|
|
19
|
+
runId: 'x-workflow-run-id',
|
|
20
|
+
agentRuntime: 'x-agent-runtime',
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
/** Unauthenticated readiness probe the desktop shell polls. */
|
|
24
|
+
export const FORGE_WELL_KNOWN_PATH = '/.well-known/frontera/forge'
|
|
25
|
+
|
|
26
|
+
/** Run ids carry this so the web treats them as non-steerable runs. */
|
|
27
|
+
export const FORGE_RUN_ID_PREFIX = 'forge_'
|
|
28
|
+
|
|
29
|
+
/** Platform chat affordances that have no meaning against a local harness. */
|
|
30
|
+
export const FORGE_DISABLED_CAPABILITIES = [
|
|
31
|
+
'fork',
|
|
32
|
+
'share',
|
|
33
|
+
'export',
|
|
34
|
+
'schedule',
|
|
35
|
+
'labels',
|
|
36
|
+
'canvas',
|
|
37
|
+
'overview',
|
|
38
|
+
] as const
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { ForgeProject } from '@frontera-sdk/forge-contracts'
|
|
2
|
+
|
|
3
|
+
import { FORGE_RUN_ID_PREFIX } from './protocol'
|
|
4
|
+
import { readUIMessageStream, type UIMessage, type UIMessageChunk } from 'ai'
|
|
5
|
+
|
|
6
|
+
import type { AdapterFactory, ApprovalDecision, HostAdapter } from './adapter'
|
|
7
|
+
import type { Logger } from './log'
|
|
8
|
+
import type { ForgeStore, StoredThread } from './store'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Live turns. Each run keeps an append-only chunk journal in memory and fans
|
|
12
|
+
* every chunk out to whoever is streaming it — the response to `POST /v1/chat`
|
|
13
|
+
* and any `GET /v1/chat/:runId/stream` that reconnected after a reload. When
|
|
14
|
+
* the host finishes, the journal is folded into one assistant `UIMessage`
|
|
15
|
+
* and persisted; the journal itself is dropped.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
interface Subscriber {
|
|
19
|
+
push(chunk: UIMessageChunk): void
|
|
20
|
+
close(): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LiveRun {
|
|
24
|
+
id: string
|
|
25
|
+
threadId: string
|
|
26
|
+
messageId: string
|
|
27
|
+
journal: UIMessageChunk[]
|
|
28
|
+
subscribers: Set<Subscriber>
|
|
29
|
+
finished: boolean
|
|
30
|
+
adapter: HostAdapter
|
|
31
|
+
abort: AbortController
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Thread titles are the first line of the first message, cut short. */
|
|
35
|
+
const TITLE_LENGTH = 60
|
|
36
|
+
|
|
37
|
+
export function titleFrom(text: string): string {
|
|
38
|
+
const line = text.split('\n').find((l) => l.trim() !== '') ?? text
|
|
39
|
+
const trimmed = line.trim()
|
|
40
|
+
return trimmed.length > TITLE_LENGTH ? `${trimmed.slice(0, TITLE_LENGTH - 1)}…` : trimmed
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fold a chunk journal into the message the AI SDK would have built on the
|
|
45
|
+
* client. The reducer throws on a malformed sequence (a result for a call it
|
|
46
|
+
* never saw); the fallback keeps the text so a turn is never lost entirely.
|
|
47
|
+
*/
|
|
48
|
+
export async function assembleMessage(messageId: string, journal: UIMessageChunk[]): Promise<UIMessage> {
|
|
49
|
+
const stream = new ReadableStream<UIMessageChunk>({
|
|
50
|
+
start(controller) {
|
|
51
|
+
for (const chunk of journal) controller.enqueue(chunk)
|
|
52
|
+
controller.close()
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
let last: UIMessage = { id: messageId, role: 'assistant', parts: [] }
|
|
56
|
+
try {
|
|
57
|
+
for await (const message of readUIMessageStream({ message: last, stream, onError: () => {}, terminateOnError: false })) {
|
|
58
|
+
last = message
|
|
59
|
+
}
|
|
60
|
+
return { ...last, id: messageId, role: 'assistant' }
|
|
61
|
+
} catch {
|
|
62
|
+
return fallbackMessage(messageId, journal)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fallbackMessage(messageId: string, journal: UIMessageChunk[]): UIMessage {
|
|
67
|
+
const text = journal
|
|
68
|
+
.map((chunk) => (chunk.type === 'text-delta' ? chunk.delta : ''))
|
|
69
|
+
.join('')
|
|
70
|
+
const error = journal.find((chunk) => chunk.type === 'error')
|
|
71
|
+
const parts: UIMessage['parts'] = []
|
|
72
|
+
if (text) parts.push({ type: 'text', text, state: 'done' })
|
|
73
|
+
if (error && error.type === 'error') parts.push({ type: 'text', text: `Error: ${error.errorText}`, state: 'done' })
|
|
74
|
+
return { id: messageId, role: 'assistant', parts }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class RunManager {
|
|
78
|
+
private readonly live = new Map<string, LiveRun>()
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
private readonly store: ForgeStore,
|
|
82
|
+
private readonly adapters: AdapterFactory,
|
|
83
|
+
private readonly log: Logger,
|
|
84
|
+
private readonly apiUrl: string,
|
|
85
|
+
) {}
|
|
86
|
+
|
|
87
|
+
activeRunFor(threadId: string): string | null {
|
|
88
|
+
for (const run of this.live.values()) if (run.threadId === threadId && !run.finished) return run.id
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Persist the user's message, open a run, start the host. Returns the live stream. */
|
|
93
|
+
start(thread: StoredThread, project: ForgeProject, text: string): { runId: string; stream: ReadableStream<UIMessageChunk> } {
|
|
94
|
+
const runId = `${FORGE_RUN_ID_PREFIX}${crypto.randomUUID()}`
|
|
95
|
+
const messageId = crypto.randomUUID()
|
|
96
|
+
|
|
97
|
+
this.store.appendMessage(thread.id, {
|
|
98
|
+
id: crypto.randomUUID(),
|
|
99
|
+
role: 'user',
|
|
100
|
+
parts: [{ type: 'text', text }],
|
|
101
|
+
})
|
|
102
|
+
this.store.insertRun(runId, thread.id)
|
|
103
|
+
const running = this.store.updateThread(thread.id, {
|
|
104
|
+
status: 'running',
|
|
105
|
+
activeRunId: runId,
|
|
106
|
+
...(thread.title === null ? { title: titleFrom(text) } : {}),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const run: LiveRun = {
|
|
110
|
+
id: runId,
|
|
111
|
+
threadId: thread.id,
|
|
112
|
+
messageId,
|
|
113
|
+
journal: [],
|
|
114
|
+
subscribers: new Set(),
|
|
115
|
+
finished: false,
|
|
116
|
+
adapter: this.adapters(thread.host),
|
|
117
|
+
abort: new AbortController(),
|
|
118
|
+
}
|
|
119
|
+
this.live.set(runId, run)
|
|
120
|
+
|
|
121
|
+
void this.execute(run, running, project, text)
|
|
122
|
+
return { runId, stream: this.streamFrom(run, 0) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Replay from `startIndex`, then live until the run finishes. Null when the run is not live. */
|
|
126
|
+
subscribe(runId: string, startIndex: number): ReadableStream<UIMessageChunk> | null {
|
|
127
|
+
const run = this.live.get(runId)
|
|
128
|
+
if (!run) return null
|
|
129
|
+
return this.streamFrom(run, Math.max(0, startIndex))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async cancel(runId: string): Promise<boolean> {
|
|
133
|
+
const run = this.live.get(runId)
|
|
134
|
+
if (!run || run.finished) return false
|
|
135
|
+
run.abort.abort()
|
|
136
|
+
await run.adapter.interrupt()
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
respondApproval(threadId: string, approvalId: string, decision: ApprovalDecision): boolean {
|
|
141
|
+
for (const run of this.live.values()) {
|
|
142
|
+
if (run.threadId !== threadId || run.finished) continue
|
|
143
|
+
if (run.adapter.respondApproval(approvalId, decision)) return true
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Interrupt every live run — daemon shutdown. */
|
|
149
|
+
async stopAll(): Promise<void> {
|
|
150
|
+
await Promise.all([...this.live.keys()].map((id) => this.cancel(id)))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private streamFrom(run: LiveRun, startIndex: number): ReadableStream<UIMessageChunk> {
|
|
154
|
+
let subscriber: Subscriber | null = null
|
|
155
|
+
return new ReadableStream<UIMessageChunk>({
|
|
156
|
+
start(controller) {
|
|
157
|
+
// Replay and subscribe in one synchronous step, so no chunk can land
|
|
158
|
+
// between the slice and the subscription.
|
|
159
|
+
for (const chunk of run.journal.slice(startIndex)) controller.enqueue(chunk)
|
|
160
|
+
if (run.finished) {
|
|
161
|
+
controller.close()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
subscriber = {
|
|
165
|
+
push: (chunk) => {
|
|
166
|
+
try {
|
|
167
|
+
controller.enqueue(chunk)
|
|
168
|
+
} catch {
|
|
169
|
+
// Consumer went away; the close below removes us.
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
close: () => {
|
|
173
|
+
try {
|
|
174
|
+
controller.close()
|
|
175
|
+
} catch {
|
|
176
|
+
// Already closed.
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
run.subscribers.add(subscriber)
|
|
181
|
+
},
|
|
182
|
+
cancel() {
|
|
183
|
+
if (subscriber) run.subscribers.delete(subscriber)
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async execute(run: LiveRun, thread: StoredThread, project: ForgeProject, text: string): Promise<void> {
|
|
189
|
+
const emit = (chunk: UIMessageChunk) => {
|
|
190
|
+
if (run.finished) return
|
|
191
|
+
run.journal.push(chunk)
|
|
192
|
+
for (const sub of run.subscribers) sub.push(chunk)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let outcome: { error: string | null }
|
|
196
|
+
try {
|
|
197
|
+
outcome = await run.adapter.startTurn(
|
|
198
|
+
{
|
|
199
|
+
runId: run.id,
|
|
200
|
+
threadId: thread.id,
|
|
201
|
+
messageId: run.messageId,
|
|
202
|
+
text,
|
|
203
|
+
project,
|
|
204
|
+
thread,
|
|
205
|
+
apiUrl: this.apiUrl,
|
|
206
|
+
signal: run.abort.signal,
|
|
207
|
+
setHostSessionId: (id) => {
|
|
208
|
+
this.store.updateThread(thread.id, { hostSessionId: id })
|
|
209
|
+
},
|
|
210
|
+
setThreadStatus: (status) => {
|
|
211
|
+
if (this.store.getThread(thread.id)) this.store.updateThread(thread.id, { status })
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
emit,
|
|
215
|
+
)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
218
|
+
this.log.error(`run ${run.id} crashed: ${message}`)
|
|
219
|
+
outcome = { error: message }
|
|
220
|
+
if (!run.journal.some((c) => c.type === 'start')) emit({ type: 'start', messageId: run.messageId } as UIMessageChunk)
|
|
221
|
+
emit({ type: 'error', errorText: message } as UIMessageChunk)
|
|
222
|
+
emit({ type: 'finish' } as UIMessageChunk)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!run.journal.some((c) => c.type === 'finish')) emit({ type: 'finish' } as UIMessageChunk)
|
|
226
|
+
run.finished = true
|
|
227
|
+
for (const sub of run.subscribers) sub.close()
|
|
228
|
+
run.subscribers.clear()
|
|
229
|
+
|
|
230
|
+
const cancelled = run.abort.signal.aborted
|
|
231
|
+
try {
|
|
232
|
+
const message = await assembleMessage(run.messageId, run.journal)
|
|
233
|
+
// A thread deleted mid-run has nowhere to put the message.
|
|
234
|
+
if (this.store.getThread(thread.id)) {
|
|
235
|
+
this.store.appendMessage(thread.id, message)
|
|
236
|
+
this.store.updateThread(thread.id, {
|
|
237
|
+
status: outcome.error && !cancelled ? 'error' : 'idle',
|
|
238
|
+
activeRunId: null,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
this.store.finishRun(run.id, cancelled ? 'cancelled' : outcome.error ? 'error' : 'completed', outcome.error)
|
|
242
|
+
} catch (err) {
|
|
243
|
+
this.log.error(`run ${run.id} could not be persisted: ${err instanceof Error ? err.message : String(err)}`)
|
|
244
|
+
} finally {
|
|
245
|
+
// Keep the journal briefly so a client that reconnects right at the end
|
|
246
|
+
// can still replay the tail, then let it go.
|
|
247
|
+
setTimeout(() => this.live.delete(run.id), 30_000).unref()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|