Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions server/agent-api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ type CodexPromptBlocker = {

type FreshAgentRuntimeManagerLike = {
create: (input: any) => Promise<{ sessionId: string; sessionType: FreshAgentSessionType; runtimeProvider: FreshAgentRuntimeProvider; sessionRef?: { provider: string; sessionId: string } }>
send: (locator: FreshAgentSessionLocator, input: { text: string; settings?: any }) => Promise<{ sessionId?: string; sessionRef?: { provider: string; sessionId: string } } | void>
send: (locator: FreshAgentSessionLocator, input: { text: string; settings?: any }) => Promise<{ sessionId?: string; submittedTurnId?: string; sessionRef?: { provider: string; sessionId: string } } | void>
attach: (locator: FreshAgentSessionLocator) => Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }>
getSnapshot: (input: FreshAgentThreadLocator) => Promise<any>
}
Expand Down Expand Up @@ -1705,7 +1705,13 @@ export function createAgentApiRouter({
const deadline = Date.now() + (Number.isFinite(timeoutSec) ? timeoutSec * 1000 : FRESH_AGENT_SEND_IDLE_TIMEOUT_MS)
const idle = await waitForFreshAgentIdle(freshAgentRuntimeManager, snapshotLocator, deadline)
if (idle.deadlineMissed) {
return res.json(approx({ paneId, sessionId: result?.sessionId ?? locator.sessionId, sessionRef: result?.sessionRef, status: idle.status }, 'prompt sent; turn did not complete within deadline'))
return res.json(approx({
paneId,
sessionId: result?.sessionId ?? locator.sessionId,
submittedTurnId: result?.submittedTurnId,
sessionRef: result?.sessionRef,
status: idle.status,
}, 'prompt sent; turn did not complete within deadline'))
}

const finalSessionId = result?.sessionId ?? locator.sessionId
Expand All @@ -1728,7 +1734,13 @@ export function createAgentApiRouter({
})
}

return res.json(ok({ paneId, sessionId: finalSessionId, sessionRef: finalSessionRef, status: idle.status }, 'prompt sent'))
return res.json(ok({
paneId,
sessionId: finalSessionId,
submittedTurnId: result?.submittedTurnId,
sessionRef: finalSessionRef,
status: idle.status,
}, 'prompt sent'))
} catch (err: any) {
return res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'fresh-agent send failed'))
}
Expand Down
36 changes: 36 additions & 0 deletions server/config-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fsp from 'fs/promises'
import path from 'path'
import { randomBytes } from 'node:crypto'
import { logger } from './logger.js'
import { getFreshellConfigDir } from './freshell-home.js'
import {
Expand Down Expand Up @@ -57,6 +58,9 @@ export type UserConfig = {
version: 1
settings: AppSettings
legacyLocalSettingsSeed?: LocalSettingsPatch
serverSecrets?: {
codexDisplayIdSecret?: string
}
sessionOverrides: Record<string, SessionOverride>
terminalOverrides: Record<string, TerminalOverride>
projectColors: Record<string, string>
Expand Down Expand Up @@ -266,6 +270,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}

function generateServerLocalSecret(): string {
return randomBytes(32).toString('base64url')
}

function migrateLegacyFreshClaudeSettings(rawSettings: Record<string, unknown>): Record<string, unknown> {
if (!isRecord(rawSettings.freshclaude)) {
return rawSettings
Expand Down Expand Up @@ -336,6 +344,14 @@ export class ConfigStore {
...existing,
settings,
legacyLocalSettingsSeed,
serverSecrets: isRecord(existing.serverSecrets)
? {
...(typeof existing.serverSecrets.codexDisplayIdSecret === 'string'
&& existing.serverSecrets.codexDisplayIdSecret.trim().length > 0
? { codexDisplayIdSecret: existing.serverSecrets.codexDisplayIdSecret }
: {}),
}
: undefined,
sessionOverrides: existing.sessionOverrides || {},
terminalOverrides: existing.terminalOverrides || {},
projectColors: existing.projectColors || {},
Expand Down Expand Up @@ -371,6 +387,7 @@ export class ConfigStore {
version: 1,
settings: defaultSettings,
legacyLocalSettingsSeed: undefined,
serverSecrets: undefined,
sessionOverrides: {},
terminalOverrides: {},
projectColors: {},
Expand Down Expand Up @@ -419,6 +436,25 @@ export class ConfigStore {
return cfg.settings
}

async getCodexDisplayIdSecret(): Promise<string> {
return this.writeMutex.acquire(async () => {
const cfg = await this.loadForWrite()
const existing = cfg.serverSecrets?.codexDisplayIdSecret
if (typeof existing === 'string' && existing.trim().length > 0) {
return existing
}
const secret = generateServerLocalSecret()
await this.saveInternal({
...cfg,
serverSecrets: {
...(cfg.serverSecrets ?? {}),
codexDisplayIdSecret: secret,
},
})
return secret
})
}

async getLegacyLocalSettingsSeed(): Promise<LocalSettingsPatch | undefined> {
const cfg = await this.load()
return cfg.legacyLocalSettingsSeed
Expand Down
120 changes: 110 additions & 10 deletions server/fresh-agent-extras-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as fsp from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { FreshAgentRuntimeProviderSchema, FreshAgentSessionTypeSchema } from '../shared/fresh-agent-contract.js'
import type { FreshAgentCreateRequest, FreshAgentSessionLocator } from './fresh-agent/runtime-adapter.js'
import type { FreshAgentCreateRequest, FreshAgentSendResult, FreshAgentSessionLocator } from './fresh-agent/runtime-adapter.js'

const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
const EXEC_TIMEOUT_MS = 30_000
Expand Down Expand Up @@ -104,15 +104,83 @@ async function ensureCheckpointRepo(cwd: string): Promise<string> {
return gitDir
}

export type CheckpointEntry = { id: string; ts: number; label: string }
export type CheckpointEntry = { id: string; ts: number; label: string; requestId?: string; turnId?: string }
type CheckpointMetadata = Record<string, { requestId?: string; turnId?: string }>

async function createCheckpoint(cwd: string, label: string): Promise<CheckpointEntry> {
function checkpointMetadataPath(gitDir: string): string {
return path.join(gitDir, 'freshell-checkpoint-metadata.json')
}

function isValidCheckpointId(id: string): boolean {
return /^[0-9a-f]{7,40}$/i.test(id)
}

async function readCheckpointMetadata(gitDir: string): Promise<CheckpointMetadata> {
try {
const raw = await fsp.readFile(checkpointMetadataPath(gitDir), 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
const metadata: CheckpointMetadata = {}
for (const [id, value] of Object.entries(parsed)) {
if (!isValidCheckpointId(id) || !value || typeof value !== 'object' || Array.isArray(value)) continue
const entry = value as Record<string, unknown>
metadata[id] = {
...(typeof entry.requestId === 'string' && entry.requestId ? { requestId: entry.requestId } : {}),
...(typeof entry.turnId === 'string' && entry.turnId ? { turnId: entry.turnId } : {}),
}
}
return metadata
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
throw error
}
}

async function writeCheckpointMetadata(gitDir: string, metadata: CheckpointMetadata): Promise<void> {
const filePath = checkpointMetadataPath(gitDir)
const tempPath = `${filePath}.tmp-${randomUUID()}`
await fsp.writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
try {
await fsp.rename(tempPath, filePath)
} catch (error) {
await fsp.unlink(tempPath).catch(() => undefined)
throw error
}
}

async function updateCheckpointMetadata(
cwd: string,
id: string,
patch: { requestId?: string; turnId?: string },
): Promise<CheckpointEntry> {
if (!isValidCheckpointId(id)) {
throw new Error('invalid checkpoint id')
}
const gitDir = await ensureCheckpointRepo(cwd)
const resolvedId = (await runGit([`--git-dir=${gitDir}`, 'rev-parse', '--verify', `${id}^{commit}`])).trim()
const metadata = await readCheckpointMetadata(gitDir)
metadata[resolvedId] = {
...(metadata[resolvedId] ?? {}),
...(patch.requestId ? { requestId: patch.requestId } : {}),
...(patch.turnId ? { turnId: patch.turnId } : {}),
}
await writeCheckpointMetadata(gitDir, metadata)
const entries = await listCheckpoints(cwd)
const entry = entries.find((candidate) => candidate.id === resolvedId)
if (!entry) throw new Error('invalid checkpoint id')
return entry
}

async function createCheckpoint(cwd: string, label: string, metadata: { requestId?: string } = {}): Promise<CheckpointEntry> {
const gitDir = await ensureCheckpointRepo(cwd)
const base = [`--git-dir=${gitDir}`, `--work-tree=${cwd}`]
await runGit([...base, 'add', '-A'], { cwd })
await runGit([...base, ...CHECKPOINT_IDENTITY, 'commit', '--allow-empty', '-q', '-m', label], { cwd })
const sha = (await runGit([`--git-dir=${gitDir}`, 'rev-parse', 'HEAD'])).trim()
return { id: sha, ts: Math.floor(Date.now() / 1000), label }
if (metadata.requestId) {
await updateCheckpointMetadata(cwd, sha, { requestId: metadata.requestId })
}
return { id: sha, ts: Math.floor(Date.now() / 1000), label, ...metadata }
}

async function listCheckpoints(cwd: string): Promise<CheckpointEntry[]> {
Expand All @@ -134,17 +202,18 @@ async function listCheckpoints(cwd: string): Promise<CheckpointEntry[]> {
// Empty repo (no commits yet).
return []
}
const metadata = await readCheckpointMetadata(gitDir)
return raw
.split('\n')
.filter(Boolean)
.map((line) => {
const [id, ts, ...rest] = line.split('\t')
return { id, ts: Number(ts), label: rest.join('\t') }
return { id, ts: Number(ts), label: rest.join('\t'), ...(metadata[id] ?? {}) }
})
}

async function restoreCheckpoint(cwd: string, id: string): Promise<void> {
if (!/^[0-9a-f]{7,40}$/i.test(id)) {
if (!isValidCheckpointId(id)) {
throw new Error('invalid checkpoint id')
}
const gitDir = await ensureCheckpointRepo(cwd)
Expand All @@ -171,7 +240,7 @@ async function restoreCheckpoint(cwd: string, id: string): Promise<void> {
/** Structural slice of FreshAgentRuntimeManager — keeps this router
* standalone-testable with a fake and avoids a hard import cycle. */
export type FreshAgentSendCapable = {
send?: (locator: FreshAgentSessionLocator, payload: { text: string; settings?: FreshAgentCreateRequest }) => Promise<unknown>
send?: (locator: FreshAgentSessionLocator, payload: { text: string; settings?: FreshAgentCreateRequest }) => Promise<FreshAgentSendResult>
}

function parseSendLocator(body: unknown): FreshAgentSessionLocator | null {
Expand Down Expand Up @@ -267,8 +336,8 @@ export function createFreshAgentExtrasRouter(
? req.body.settings as FreshAgentCreateRequest
: undefined
try {
await manager.send(locator, { text, ...(settings ? { settings } : {}) })
res.json({ sent: true })
const result = await manager.send(locator, { text, ...(settings ? { settings } : {}) })
res.json({ sent: true, submittedTurnId: result?.submittedTurnId })
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : 'send failed' })
}
Expand All @@ -279,6 +348,9 @@ export function createFreshAgentExtrasRouter(
const label = typeof req.body?.label === 'string' && req.body.label.trim()
? req.body.label.trim().slice(0, 120)
: 'checkpoint'
const requestId = typeof req.body?.requestId === 'string' && req.body.requestId.trim()
? req.body.requestId.trim()
: undefined
if (!cwd) {
return res.status(400).json({ error: 'cwd is required' })
}
Expand All @@ -288,7 +360,7 @@ export function createFreshAgentExtrasRouter(
return res.status(400).json({ error: `cwd does not exist: ${cwd}` })
}
try {
const entry = await createCheckpoint(cwd, label)
const entry = await createCheckpoint(cwd, label, { ...(requestId ? { requestId } : {}) })
res.json(entry)
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : 'checkpoint failed' })
Expand All @@ -307,6 +379,34 @@ export function createFreshAgentExtrasRouter(
}
})

router.post('/checkpoints/metadata', async (req, res) => {
const cwd = typeof req.body?.cwd === 'string' ? req.body.cwd : ''
const id = typeof req.body?.id === 'string' ? req.body.id : ''
const requestId = typeof req.body?.requestId === 'string' && req.body.requestId.trim()
? req.body.requestId.trim()
: undefined
const turnId = typeof req.body?.turnId === 'string' && req.body.turnId.trim()
? req.body.turnId.trim()
: undefined
if (!cwd || !id) {
return res.status(400).json({ error: 'cwd and id are required' })
}
if (!requestId && !turnId) {
return res.status(400).json({ error: 'requestId or turnId is required' })
}
try {
await fsp.access(cwd)
} catch {
return res.status(400).json({ error: `cwd does not exist: ${cwd}` })
}
try {
res.json(await updateCheckpointMetadata(cwd, id, { requestId, turnId }))
} catch (error) {
const message = error instanceof Error ? error.message : 'metadata update failed'
res.status(message.includes('invalid checkpoint id') ? 400 : 500).json({ error: message })
}
})

router.post('/checkpoints/restore', async (req, res) => {
const cwd = typeof req.body?.cwd === 'string' ? req.body.cwd : ''
const id = typeof req.body?.id === 'string' ? req.body.id : ''
Expand Down
Loading
Loading