Skip to content

Commit 015f783

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@dcf33c591ff81fa6a2f5d59ee5813c3dc55285cb
1 parent 2c93077 commit 015f783

12 files changed

Lines changed: 279 additions & 49 deletions

File tree

‎bun.lock‎

Lines changed: 43 additions & 47 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎common/src/desktop-cloud/agents.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export const CLOUD_CODEX_MODELS: readonly CloudCliModel[] = [
4747
label: 'GPT-6-Sol',
4848
tagline: 'Reliable agentic workhorse for everyday tasks',
4949
},
50+
{
51+
id: 'gpt-6.1-sol',
52+
label: 'GPT-6.1-Sol',
53+
tagline: 'Latest agentic workhorse for everyday tasks',
54+
},
5055
{
5156
id: 'gpt-6-astra',
5257
label: 'GPT-6-Astra',

‎common/src/desktop-cloud/archive.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ export const CLOUD_ARCHIVE_BYTES = 4 * 1024 ** 3
55
export const CLOUD_EXPANDED_BYTES = 16 * 1024 ** 3
66
export const CLOUD_SOURCE_FILES = 200_000
77
export const CLOUD_UPLOAD_PART_BYTES = 8 * 1024 ** 2
8+
// `GET projects/:id/git-bases?archive=…`: the client packs a Git upload's base bundle before its
9+
// files. Desktop 0.0.174 packed it after them and hung on any real project, so the API serves
10+
// the bases (which turn the bundle on) only to clients that send this.
11+
export const CLOUD_GIT_BASES_ARCHIVE = '2'
12+
// `?archive=` of a client that uploads Git objects instead of history bundles: a packfile of only
13+
// what Cloud lacks, registered as a project pack (POST projects/:id/packs), and a zstd overlay of
14+
// the working tree's differences. It is also told the bases those packs made shallow, which a
15+
// bundle client must never build on.
16+
export const CLOUD_GIT_BASES_PACKED = '3'
17+
/**
18+
* What a direct (streamed) source upload holds, which fixes its object key's suffix: a zstd tar
19+
* of files laid over a chat's base, or a Git packfile. Proxied uploads are always `.tar.gz`.
20+
*/
21+
export const cloudSourceKindSchema = z.enum(['overlay', 'pack'])
22+
export type CloudSourceKind = z.infer<typeof cloudSourceKindSchema>
23+
export const CLOUD_SOURCE_SUFFIX: Record<CloudSourceKind, string> = {
24+
overlay: '.tar.zst',
25+
pack: '.pack',
26+
}
27+
/** A project pack's commits, HEAD included; more unpushed commits than this go up as HEAD alone. */
28+
export const CLOUD_PACK_MAX_COMMITS = 500
829

930
/**
1031
* What a size refusal tells the user to DO.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/** File changes returned by the Cloud worktree and local Desktop Git readers. */
2+
export type ChangesScope = 'all' | 'uncommitted'
3+
export interface ChangedFile {
4+
path: string
5+
status: 'added' | 'modified' | 'deleted'
6+
modifiedAt: number | null
7+
adds: number | null
8+
dels: number | null
9+
binary: boolean
10+
untracked: boolean
11+
}
12+
export interface FileDiffResult {
13+
patch?: string
14+
binary?: boolean
15+
tooLarge?: boolean
16+
}
17+
export interface CloudChanges {
18+
scope: ChangesScope
19+
branch: string | null
20+
files: ChangedFile[]
21+
totals: { files: number; adds: number; dels: number }
22+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect, test } from 'bun:test'
2+
import { FILE_DOWNLOAD_CHUNK_BYTES as CHUNK, fileDownloadResponse } from './file-download'
3+
4+
test('download response preserves binary bytes, filename and size beyond preview limits', async () => {
5+
const data = new Uint8Array(CHUNK * 5 + 7).map((_, i) => i % 256)
6+
const offsets: number[] = []
7+
let closed = 0
8+
const response = await fileDownloadResponse('dir/résumé "x".bin', async offset => {
9+
offsets.push(offset)
10+
return { data: data.slice(offset, offset + CHUNK), size: data.length, revision: 'stable' }
11+
}, { close: async () => { closed++ } })
12+
expect(response.headers.get('content-type')).toBe('application/octet-stream')
13+
expect(response.headers.get('content-disposition')).toContain('r%C3%A9sum%C3%A9%20%22x%22.bin')
14+
expect(new Uint8Array(await response.arrayBuffer())).toEqual(data)
15+
expect(offsets).toEqual([0, CHUNK, CHUNK * 2, CHUNK * 3, CHUNK * 4, CHUNK * 5])
16+
expect(closed).toBe(1)
17+
})
18+
test('file replacement and truncated chunks abort the response instead of completing a partial download', async () => {
19+
for (const changed of ['revision', 'size', 'short']) {
20+
let closed = 0
21+
const response = await fileDownloadResponse('file', async offset => ({
22+
data: new Uint8Array(offset && changed === 'short' ? 1 : CHUNK),
23+
size: offset && changed === 'size' ? CHUNK * 3 : CHUNK * 2,
24+
revision: offset && changed === 'revision' ? 'changed' : 'first',
25+
}), { close: async () => { closed++ } })
26+
await expect(response.arrayBuffer()).rejects.toThrow('changed')
27+
expect(closed).toBe(1)
28+
}
29+
})
30+
test('cancellation closes idle reads and initial failures close before returning headers', async () => {
31+
let closed = 0
32+
const abort = new AbortController()
33+
const response = await fileDownloadResponse('file', async () => ({ data: new Uint8Array(CHUNK), size: CHUNK * 2, revision: 'a' }),
34+
{ signal: abort.signal, close: async () => { closed++ } })
35+
abort.abort(new Error('cancelled'))
36+
// Bun 1.3 throws synchronously for an already-errored Response body.
37+
await expect((async () => response.arrayBuffer())()).rejects.toThrow('cancelled')
38+
expect(closed).toBe(1)
39+
await expect(fileDownloadResponse('file', async () => { throw new Error('missing') }, { close: async () => { closed++ } })).rejects.toThrow('missing')
40+
expect(closed).toBe(2)
41+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/** Bounded binary reads shared by Desktop and Cloud; previews have separate limits. */
2+
export const FILE_DOWNLOAD_CHUNK_BYTES = 256 * 1024
3+
export interface FileDownloadChunk {
4+
data: Uint8Array
5+
size: number
6+
revision: string
7+
}
8+
9+
/** Fetch the first chunk before sending headers so missing files remain HTTP errors.
10+
* Later failures abort the body: a partial download must never look successful. */
11+
export async function fileDownloadResponse(
12+
path: string,
13+
read: (offset: number) => Promise<FileDownloadChunk>,
14+
options: { signal?: AbortSignal; close?: () => Promise<void> } = {},
15+
): Promise<Response> {
16+
let closed = false
17+
let onAbort: (() => void) | undefined
18+
const close = async () => {
19+
if (closed) return
20+
closed = true
21+
if (onAbort) options.signal?.removeEventListener('abort', onAbort)
22+
await options.close?.()
23+
}
24+
let first: FileDownloadChunk
25+
try {
26+
options.signal?.throwIfAborted()
27+
first = await read(0)
28+
options.signal?.throwIfAborted()
29+
if (!Number.isSafeInteger(first.size) || first.size < 0) throw new Error('Invalid download size')
30+
} catch (error) {
31+
await close()
32+
throw error
33+
}
34+
let offset = 0
35+
let initial: FileDownloadChunk | undefined = first
36+
const body = new ReadableStream<Uint8Array>({
37+
start(controller) {
38+
onAbort = () => {
39+
controller.error(options.signal?.reason ?? new Error('Download cancelled'))
40+
void close().catch(() => {})
41+
}
42+
options.signal?.addEventListener('abort', onAbort, { once: true })
43+
},
44+
async pull(controller) {
45+
try {
46+
options.signal?.throwIfAborted()
47+
const chunk = initial ?? await read(offset)
48+
initial = undefined
49+
if (closed) return
50+
options.signal?.throwIfAborted()
51+
if (chunk.size !== first.size || chunk.revision !== first.revision ||
52+
chunk.data.length !== Math.min(FILE_DOWNLOAD_CHUNK_BYTES, first.size - offset))
53+
throw new Error('The file changed during download. Please try again.')
54+
offset += chunk.data.length
55+
if (chunk.data.length) controller.enqueue(chunk.data)
56+
if (offset === first.size) {
57+
await close()
58+
controller.close()
59+
}
60+
} catch (error) {
61+
await close()
62+
controller.error(error)
63+
}
64+
},
65+
cancel: close,
66+
})
67+
const filename = path.split(/[\\/]/).pop() || 'download'
68+
return new Response(body, { headers: {
69+
'Content-Type': 'application/octet-stream',
70+
'Content-Length': String(first.size),
71+
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename).replace(/['()*]/g, char => '%' + char.charCodeAt(0).toString(16))}`,
72+
'Cache-Control': 'private, no-store, no-transform',
73+
'X-Content-Type-Options': 'nosniff',
74+
} })
75+
}

‎common/src/desktop-cloud/handback.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ export const handbackRequestSchema = z
3636
.strict()
3737
export type HandbackRequest = z.infer<typeof handbackRequestSchema>
3838

39+
/** Sent only after Desktop has committed the local landing. The timestamp identifies this move,
40+
* so a delayed acknowledgement cannot reclaim a later Cloud workspace. */
41+
export const handbackCompleteSchema = handbackRequestSchema.extend({
42+
handedBackAt: z.iso.datetime({ offset: true }),
43+
})
44+
export type HandbackCompleteRequest = z.infer<typeof handbackCompleteSchema>
45+
3946
export interface HandbackResponse {
4047
workspaceId: string
4148
/** ISO time the chat left Cloud. Repeating the same handback returns the original time. */
@@ -71,7 +78,7 @@ export const CLOUD_EXPORT_BUNDLE_REF = 'HEAD'
7178

7279
const commitSchema = z.string().regex(/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/)
7380

74-
export const cloudExportMetaSchema = z
81+
const cloudExportV2Schema = z
7582
.object({
7683
version: z.literal(2),
7784
/** The commit the worktree started from (its marker), or null for a chat initialized before it was recorded. */
@@ -86,4 +93,22 @@ export const cloudExportMetaSchema = z
8693
deleted: z.array(z.string().max(4096)).max(200_000),
8794
})
8895
.strict()
96+
/** V3 sends Git objects once plus only working files whose bytes differ from HEAD.
97+
* `have` is a complete commit graph verified by the receiver; unknown bases send a full pack.
98+
* `files` is the complete eligible working-file inventory, for plain-folder reconstruction.
99+
* `shallow` preserves any history boundary already present in Cloud (never invents one).
100+
*/
101+
export const cloudExportV3Schema = cloudExportV2Schema.extend({
102+
version: z.literal(3),
103+
bundle: z.literal(false),
104+
pack: z.boolean(),
105+
have: commitSchema.nullable(),
106+
shallow: z.array(commitSchema).max(200_000),
107+
files: z.array(z.string().max(4096)).max(200_000),
108+
}).strict()
109+
export const cloudExportMetaSchema = z.union([cloudExportV2Schema, cloudExportV3Schema])
89110
export type CloudExportMeta = z.infer<typeof cloudExportMetaSchema>
111+
112+
/** Archived chats resume through Desktop's existing local landing and Cloud upload. */
113+
export const ARCHIVED_CHAT_RESTORE_ERROR =
114+
'This chat’s Cloud workspace was archived. Restore it in Freebuff Desktop to continue.'

‎common/src/desktop-cloud/history.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type CloudHistoryChat = {
3333
archivedAt: string | null
3434
/** Continue locally (COD-878): when the chat moved to a computer, or null. Absent from older servers. */
3535
handedBackAt?: string | null
36+
/** Archived Cloud files must land on this computer before another run. */
37+
localRestoreRequired?: boolean
3638
metadataRevision: number
3739
phase: string
3840
updatedAt: string

‎common/src/desktop-cloud/placement.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const PLACEMENT_GUIDANCE =
99
'Cloud suits long or unattended work (the user wants to step away), several things in parallel, heavy or risky installs, and deploying a site. ' +
1010
"The user's computer suits work that needs it: its hardware, simulators or devices, the user's Docker images, VPN or localhost services, CLI sign-ins and browser or MCP tools only that computer has, files outside the project, and quick edit-and-check loops against something already running there. " +
1111
"The user's explicit wording always wins. If unsure, stay. At most one move per user message, and never back unless the user asks. " +
12-
'After calling it, end your turn with one short line saying where the chat is going and why; the chat continues there on its own.'
12+
'Calling it switches the runtime at this tool boundary. Continue the same response from the tool result in the destination; do not end your turn or ask the user to continue. Do not announce or summarize the runtime change; simply carry on with the task.'
1313

1414
/** The run event a Cloud run writes when its agent asks to continue on the user's computer. */
1515
export const HANDOFF_REQUEST_EVENT = 'handoff_request'
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/** Stable product messages, also used when Desktop imports Cloud history. */
2+
export const CLOUD_RESOURCE_MESSAGE =
3+
'Your cloud environment has reached its resource limit. Open this chat in the Desktop app to continue locally.'
4+
export const CLOUD_RESOURCE_PENDING =
5+
'Your cloud environment has reached its resource limit. Your files are being saved for recovery. Open this chat in the Desktop app to continue locally.'
6+
export const CLOUD_RESOURCE_MOVE_REASON =
7+
'The cloud environment is running low on resources. Continue the same task locally, preserving completed work and checking any interrupted command before retrying it.'
8+
export const CLOUD_RESOURCE_EVENT = 'resource_limit'
9+
export type CloudResourceKind =
10+
| 'disk'
11+
| 'memory'
12+
| 'cpu'
13+
| 'processes'
14+
| 'files'
15+
| 'time'
16+
export const isCloudResourceMessage = (message: string | null | undefined) =>
17+
message === CLOUD_RESOURCE_MESSAGE || message === CLOUD_RESOURCE_PENDING

0 commit comments

Comments
 (0)