diff --git a/.github/scripts/backend-docs.test.mjs b/.github/scripts/backend-docs.test.mjs new file mode 100644 index 000000000..7d6030a87 --- /dev/null +++ b/.github/scripts/backend-docs.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { test } from 'node:test' + +const root = resolve(import.meta.dirname, '../..') +const read = name => readFileSync(resolve(root, name), 'utf8') +const docs = ['README.md', 'README.zh.md', 'docs/backend-operations.md', 'docs/development-service-api.md', 'docs/development-service-guide.md'] +const names = text => [...text.matchAll(/'([^']+)'/g)].map(match => match[1]) + +test('public API tables enumerate the actual complete command/query contracts', () => { + const source = read('core/src/host/project-development-service.js') + const queries = names(source.match(/const queries = new Set\(\[([^\]]+)\]/)[1]) + const operations = [...source.match(/const operations = Object.freeze\(\{([\s\S]*?)\}\)/)[1].matchAll(/'([^']+)':/g)].map(match => match[1]) + const api = read('docs/development-service-api.md') + const rows = part => [...part.matchAll(/^\| ([a-z][a-z.-]+) \|/gm)].map(match => match[1]).sort() + const queryText = api.split('## v1查询')[1].split('## v1命令')[0] + const commandText = api.split('## v1命令')[1].split('## Execution')[0] + assert.deepEqual(rows(queryText), [...queries].sort()) + assert.deepEqual(rows(commandText), operations.filter(name => !queries.includes(name)).sort()) + assert.equal(queries.length, 24); assert.equal(rows(commandText).length, 26) + for (const name of ['README.md', 'README.zh.md']) assert.match(read(name), /24查询\/26命令/) +}) + +test('quickstart JSON, shell syntax and local documentation links stay usable', () => { + for (const name of docs) { + const text = read(name) + for (const match of text.matchAll(/```json\n([\s\S]*?)\n```/g)) assert.doesNotThrow(() => JSON.parse(match[1]), `${name}: invalid JSON`) + for (const match of text.matchAll(/```bash\n([\s\S]*?)\n```/g)) execFileSync('bash', ['-n'], { input: match[1] }) + for (const match of text.matchAll(/\]\(([^)]+)\)/g)) { + const target = match[1].split('#')[0] + if (!target || /^[a-z]+:/i.test(target)) continue + assert.ok(existsSync(resolve(dirname(resolve(root, name)), decodeURIComponent(target))), `${name}: missing ${target}`) + } + } + for (const name of ['README.md', 'README.zh.md']) { + const text = read(name) + assert.doesNotMatch(text, /1811e3b8f|fb_dev\(|fb_api\(|owner@flybot\.example|SuperAgent/) + assert.match(text, /node scripts\/backend\.mjs init/) + assert.match(text, /node scripts\/backend\.mjs start/) + assert.match(text, /README\.native/) + } +}) diff --git a/.github/scripts/backend-runtime.test.mjs b/.github/scripts/backend-runtime.test.mjs new file mode 100644 index 000000000..47b62632a --- /dev/null +++ b/.github/scripts/backend-runtime.test.mjs @@ -0,0 +1,120 @@ +// Exercise the production launcher in an isolated persistent home. Inference is a test double. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { existsSync, readFileSync, writeFileSync, mkdtempSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createServer } from 'node:net' +import { test } from 'node:test' +import { backendConfig, callApi, connection, prepareBackend, readJson, root } from '../../scripts/backend-lib.mjs' +import { mockModel } from '../../dev/demo/mock.mjs' +import { agentReply } from '../../dev/demo/agents.mjs' + +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) +async function freePort() { const server = createServer(); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port } +async function run(args, env) { + const child = spawn(process.execPath, [join(root, 'scripts/backend.mjs'), ...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '', stderr = '' + child.stdout.on('data', bytes => { stdout += bytes }); child.stderr.on('data', bytes => { stderr += bytes }) + const [code] = await once(child, 'exit') + if (code !== 0) throw new Error(`CLI refused: ${stderr}`) + return stdout +} +async function launch(home, env) { + const child = spawn(process.execPath, [join(root, 'scripts/backend.mjs'), 'start', '--home', home], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes }); child.stderr.on('data', bytes => { output += bytes }) + const exited = once(child, 'exit') + const close = async () => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); await exited } + try { + for (let pass = 0; pass < 300; ++pass) { + if (output.includes('Backend ready:')) return { child, close, output: () => output } + if (child.exitCode !== null || child.signalCode !== null) throw new Error(`Production launcher failed: ${output}`) + await pause(100) + } + throw new Error('Production launcher readiness timed out') + } catch (error) { await close(); throw error } +} + +test('production backend retains canonical Tasks/auth across restart, keeps consultation private and serves scoped SSE', { timeout: 90000 }, async t => { + const home = mkdtempSync(join(tmpdir(), 'flybot-backend-runtime-')), port = await freePort() + const model = mockModel([], { reply: agentReply }) + model.listen(0, '127.0.0.1'); await once(model, 'listening') + const env = { ...process.env, DEEPSEEK_API_KEY: 'isolated-test-provider', DEEPSEEK_BASE_URL: `http://127.0.0.1:${model.address().port}/v1` } + const profile = prepareBackend(home, backendConfig({ port })) + // Offline test dependency provision; the separate fresh-home acceptance runs the real init installer. + const modules = join(profile, 'node_modules'), installed = join(root, 'core/dev/harness/node_modules') + mkdirSync(modules) + for (const entry of readdirSync(installed)) if (!entry.startsWith('.') && !['@flybot', 'ds-bot'].includes(entry)) symlinkSync(join(installed, entry), join(modules, entry)) + symlinkSync(join(root, 'core'), join(modules, 'ds-bot')) + mkdirSync(join(modules, '@flybot'), { recursive: true }) + for (const name of ['social', 'tree', 'timeline', 'workflow']) symlinkSync(join(root, 'plugins', name), join(modules, '@flybot', name)) + let host + t.after(async () => { await host?.close(); model.closeAllConnections(); await new Promise(resolve => model.close(resolve)); rmSync(home, { recursive: true, force: true }) }) + host = await launch(home, env) + await assert.rejects(run(['init', '--home', home], env), /already active/) + const login = await run(['login', '--home', home], env) + assert.match(login, /Authenticated/); assert.doesNotMatch(login + host.output(), /\?token=|sessionToken|isolated-test-provider/) + const token = readJson(join(home, 'private/backend-session.json')).sessionToken + const transport = await connection(home), api = (endpoint, body = {}, who = token) => callApi(transport, endpoint, body, who) + const dev = (kind, name, projectId, payload = {}, requestId) => api('project-development', { version: 'project-development/v1', kind, name, projectId, payload, ...(requestId ? { requestId } : {}) }) + const { project } = await api('project-create', { name: 'Persistent service' }) + const proposed = await dev('command', 'task.propose', project.id, { title: 'Project change', criteria: 'Result checked', impact: 'Keep project history' }, 'one-original-intent') + const taskId = proposed.result.id + assert.ok(taskId) + assert.equal((await dev('query', 'task', project.id, { taskId })).result.task.phase, 'awaiting-chief') + const inquiry = await dev('command', 'consultation.ask', project.id, { text: 'Explain the Project status without creating work' }, 'ordinary-question') + assert.equal(inquiry.result.consultation.initiativeTransferred, false) + let answer + for (let pass = 0; pass < 100; ++pass) { + answer = (await dev('query', 'consultation.get', project.id, { consultationId: inquiry.result.consultation.id })).result.consultation + if (answer.state === 'completed' && answer.replyCount > 0) break + await pause(100) + } + assert.equal(answer.state, 'completed') + assert.ok(answer.replyCount > 0) + assert.equal((await dev('query', 'tasks', project.id)).result.tasks.length, 1) + const external = await api('personal-agent-create', { name: 'External protocol caller', kind: 'external', provider: 'http-contract', endpoint: 'https://agent.example.invalid/callback' }) + await api('personal-agent-join', { agentId: external.agent.id, projectId: project.id }) + const credential = await api('personal-agent-token-create', { agentId: external.agent.id, projectIds: [project.id] }) + const privateProposal = await api('personal-agent-propose-task', { projectId: project.id, title: 'Proposed by the independent agent', criteria: 'Owner reviews the proposal', impact: 'Retain explicit Project impact', scope: 'Transport mapping only', projectTaskKind: 'code', requestId: 'external-safe-proposal' }, credential.token) + assert.equal(privateProposal.proposal.fields.impact, 'Retain explicit Project impact') + assert.equal(privateProposal.proposal.fields.scope, 'Transport mapping only') + assert.equal((await dev('query', 'tasks', project.id)).result.tasks.length, 1) + const publication = await dev('command', 'proposal.publish', project.id, { proposalId: privateProposal.proposal.id }, 'human-approved-agent-proposal') + assert.equal(publication.result.task.projectTask.requirements.impact, 'Retain explicit Project impact') + await api('personal-agent-delegation-set', { agentId: external.agent.id, projectId: project.id, mode: 'watch' }) + const watched = await api('project-development', { version: 'project-development/v1', kind: 'command', name: 'task.propose', projectId: project.id, requestId: 'external-watch-intent', payload: { title: 'Delegated Project work', criteria: 'Current authority checked', impact: 'Exercise the delegated publish boundary' } }, credential.token) + assert.ok(watched.result.id) + await api('personal-agent-token-revoke', { credentialId: credential.credential.id }) + await assert.rejects(api('project-read', { id: project.id }, credential.token), /unauthorized|forbidden/) + const invitation = await api('auth-invite'), bob = await api('auth-redeem', { token: invitation.token, displayName: 'Bob' }) + await assert.rejects(api('project-read', { id: project.id }, bob.sessionToken), /forbidden/) + const projectInvitation = await api('project-invite', { id: project.id }) + await api('project-join', { token: projectInvitation.token }, bob.sessionToken) + await assert.rejects(api('project-development', { version: 'project-development/v1', kind: 'query', name: 'consultation.get', projectId: project.id, payload: { consultationId: inquiry.result.consultation.id } }, bob.sessionToken), /unavailable|forbidden|consultation/i) + const stream = await fetch(`${transport.baseUrl}/api/project-events?projectId=${project.id}`, { headers: { 'x-flybot-member-session': bob.sessionToken }, signal: AbortSignal.timeout(10000) }) + assert.equal(stream.status, 200) + const reader = stream.body.getReader() + assert.match(new TextDecoder().decode((await reader.read()).value), /event: snapshot/) + await api('project-member-remove', { id: project.id, memberId: bob.memberId }) + let revoked = '' + while (!revoked.includes('access-revoked')) { const next = await reader.read(); if (next.done) break; revoked += new TextDecoder().decode(next.value) } + assert.match(revoked, /access-revoked/) + await reader.cancel() + await host.close(); host = null + assert.equal(existsSync(join(home, 'bot/state.sqlite')), true) + host = await launch(home, env) + assert.match(await run(['login', '--home', home], env), /existing private local session/) + const nextTransport = await connection(home) + const after = await callApi(nextTransport, 'project-development', { version: 'project-development/v1', kind: 'command', name: 'task.propose', projectId: project.id, requestId: 'one-original-intent', payload: { title: 'Project change', criteria: 'Result checked', impact: 'Keep project history' } }, token) + assert.equal(after.replayed, true) + assert.equal(after.result.id, taskId) + await assert.rejects(callApi(nextTransport, 'project-read', { id: project.id }, bob.sessionToken), /forbidden/) + await host.close(); host = null + const statePath = join(home, 'bot/state.json'), future = JSON.stringify({ version: 99, note: 'Preserve this unsupported guard' }) + writeFileSync(statePath, future) + await assert.rejects(launch(home, env), /read-only/) + assert.equal(readFileSync(statePath, 'utf8'), future) +}) diff --git a/.github/scripts/backend.test.mjs b/.github/scripts/backend.test.mjs new file mode 100644 index 000000000..cbabdb95f --- /dev/null +++ b/.github/scripts/backend.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { backendConfig, callApi, optionsOf, prepareBackend, privateJson, requestBody } from '../../scripts/backend-lib.mjs' +import { createServer } from 'node:http' + +test('backend source profile preserves original plugins and uses real persistent pools rather than fixtures', t => { + const home = mkdtempSync(join(tmpdir(), 'flybot-backend-profile-')) + t.after(() => rmSync(home, { recursive: true, force: true })) + const config = backendConfig(), profile = prepareBackend(home, config) + const path = join(profile, 'package.json'), manifest = JSON.parse(readFileSync(path, 'utf8')) + for (const name of ['ds-bot', '@flybot/tree', '@flybot/workflow', '@flybot/social', '@flybot/timeline']) assert.ok(manifest.dsh.profile.bundles.includes(name)) + assert.equal(manifest.dependencies['@deepseek-ai/dsh-base'], '0.2.1-rc.2') + assert.equal(manifest.dependencies['@deepseek-ai/dsh-web-app'], '0.2.1-rc.2') + assert.equal(config.bot.stateStorage, 'sqlite') + assert.equal(config.bot.projectWorkbench, true) + assert.equal(config.bot.projectDispatch, 'pools') + manifest.dependencies.custom = '1.0.0'; manifest.dsh.profile.bundles.push('custom'); privateJson(path, manifest) + prepareBackend(home, config) + assert.ok(JSON.parse(readFileSync(path, 'utf8')).dsh.profile.bundles.includes('custom')) + assert.equal(statSync(join(home, 'backend.json')).mode & 0o777, 0o600) + const overlay = readFileSync(join(home, 'backend.patch.yml'), 'utf8') + assert.match(overlay, /projectWorkbench/) + assert.doesNotMatch(overlay, /fixture|mock|API_KEY|sessionToken/) +}) + +test('backend rejects ambiguous configuration and malformed CLI command identity', () => { + for (const config of [{ port: 0 }, { port: '3080' }, { unknown: true }, { bot: [] }, { bot: { projectWorkbench: false } }, { bot: { projectDispatch: 'workflow' } }, { bot: { home: '/somewhere' } }]) assert.throws(() => backendConfig(config)) + assert.deepEqual(optionsOf(['query', 'tasks', '--project', 'p', '--home', '/tmp/user-home']), { command: 'query', positional: ['tasks'], options: { project: 'p', home: '/tmp/user-home' } }) + for (const args of [['start', '--home'], ['start', '--password', 'secret'], ['start', '--home', 'a', '--home', 'b']]) assert.throws(() => optionsOf(args)) + assert.throws(() => requestBody('command', 'task.propose', { project: 'p' }), /request-id/) + assert.throws(() => requestBody('query', 'tasks', { project: 'p', json: '[]' }), /object/) + const request = requestBody('command', 'task.propose', { project: 'p', 'request-id': 'one-intent', json: '{"title":"Project change"}' }) + assert.equal(request.endpoint, 'project-development') + assert.equal(request.payload.version, 'project-development/v1') + assert.equal(request.payload.requestId, 'one-intent') + assert.equal(request.payload.payload.title, 'Project change') +}) + +test('CLI HTTP client uses existing authenticated API and reports domain/transport failures', async t => { + let seen + const server = createServer(async (request, response) => { + let text = ''; for await (const bytes of request) text += bytes + seen = { url: request.url, cookie: request.headers.cookie, member: request.headers['x-flybot-member-session'], request: JSON.parse(text) } + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify(seen.request.endpoint === 'deny' ? { ok: false, error: { code: 'bot/forbidden', message: 'Current membership required' } } : { ok: true, value: { projectId: 'p' } })) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + t.after(() => new Promise(resolve => server.close(resolve))) + const transport = { baseUrl: `http://127.0.0.1:${server.address().port}`, cookie: 'transport=private' } + assert.deepEqual(await callApi(transport, 'project-read', { id: 'p' }, 'member-secret'), { projectId: 'p' }) + assert.equal(seen.url, '/api/bot'); assert.equal(seen.member, 'member-secret'); assert.equal(seen.cookie, 'transport=private') + await assert.rejects(callApi(transport, 'deny', {}, 'member-secret'), /bot\/forbidden/) +}) diff --git a/LESSONS.md b/LESSONS.md index 6301e79a4..beebd83b0 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -771,3 +771,7 @@ Seen 1× (actual SQLite API regression). A Task query requested full delivery de ### 2026-11-03 — Absence of a new contract is not legacy admission Seen 1× (group dispatch and Bot tracking reproduced the bypass). Capture existing Project identities once with committed evidence; later untyped rows must not inherit admission. Preserve original rework and explicit Workflow execution through trusted continuation/controller ports. Scope guards must run before mutating leases or claims, and physical storage rollback must retain the code-version fence. + +### 2026-11-03 — Validate the composed external proposal, not just its private receipt + +Seen 1× (production-entry FBA regression). Legacy external proposals preserved title/criteria but dropped canonical impact/scope, leaving the approved action unable to carry its original requirements to Chief. Preserve explicit fields in both the private intent hash and published original Task; test actual authenticated HTTP approval/watch/revoke. Successful startup also requires actual Store writability, not only a Harness login URL. diff --git a/PROJECT.md b/PROJECT.md index 5f086a1cd..0aefd8276 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -288,3 +288,7 @@ Service composition adds scoped native execution/exit and current-vs-pinned role ### 2026-11-03 — Preserve original capabilities (#2186) Keep personal Chief/Bots, groups, private memories and original Task/Workflow IDs. Shared Project legacy execution requires an identity-bound committed cutover; new dispatch/tracking cannot bypass Chief admission. Associated original groups retain their own history ACL plus current Project access. JSON version4 fences new contracts across SQLite rollback; code downgrades must not erase receipts. No deployment or UI replacement. See docs/subsystems/project-compatibility.md. + +### 2026-11-03 — Persistent deployment and final API docs (#2189) + +Use scripts/backend.mjs to initialize/start the original DSH/FlyBot host plus existing restricted HTTP/SSE gateway in a private persistent home. Login and CLI calls reuse native/platform identity; no mock model, sample repository, parallel executor or frontend replacement is packaged. Original guides move to README.native variants. Auth-mode confirms Store writability before readiness. External safe proposal impact/scope must survive publication into canonical requirements (actual production-entry regression). Deployment/restart, deterministic HTTP/DSH tests and real-provider acceptance remain separate evidence. diff --git a/README.md b/README.md index 07addb976..9240363a2 100644 --- a/README.md +++ b/README.md @@ -1,544 +1,86 @@ -# FlyBot 自动开发后端服务 +# FlyBot:AI 协作开发后端 -这是 `ThinkFlowLab/FlyBot` 的 **`delivery/ai-centric-development-service` 交付分支**。 -代码基线为 `1811e3b8f71c527b87320a12575df09e0e823e8e`,已包含 S1–S6、 -已确认退出的工作区恢复、可信集成源准入及 Chief/SDK 生命周期修复; -**直接检出本分支即可,不需要逐个合并阶段 PR**。 +Project 管理项目、成员、角色与仓库;Chief 常驻协调,Dev/Reviewer/Tester +按 Task 使用独立角色 Session,通过持久化 Handoff 自主接手、打回和转交。 +普通提问直接咨询 Chief,不创建 Task。个人 Chief、个人 Bot、群聊与私有记忆继续保留。 -本分支支持不打开网页、直接通过 HTTP 驱动 Project 的自动开发流程: -发布任务 → 启动 → Bot 从任务池领取 → 自主 handoff/打回/转交 → -宿主验证候选与独立 Review → 更新原 PR → Chief 裁定。 -Chief 是常驻 Project 身份,角色执行保留人设、Skills、长期记忆引用与 Task 作用域。 +本交付分支包含后端收敛工作,尚未合入主仓 main 或部署到现有实例。 +它复用 FlyBot/DSH、原账号服务、任务池和执行器;没有另建一套调度系统。 -当前部署形态是 **FlyBot 进程内的自动开发应用服务 + 已认证 HTTP 接口**, -入口为 `POST /api/bot`,协议为 `project-development/v1`;不是另一个调度器或数据库。 -身份认证复用原账号服务,但调用 API 不需要进入 control-plane 管理页面或工作台。 -尚无独立进程发行包、Swagger/OpenAPI 页面。最新前端 Task40/PR48 的后续候选不包含在本分支中。 +## 启动持久服务 -## 使用说明导航 - -| 想做什么 | 从哪里开始 | -| --- | --- | -| 连接服务、登录、执行第一个查询 | 本README第2节 | -| 创建并启动任务、查询进度、追加反馈 | 本README第3–4节 | -| 查每个接口的参数、响应、权限、重试和错误 | [完整API参考:12查询 / 21命令](docs/development-service-api.md) | -| 理解个人代理、安全/值守、Bot交接与Chief裁定 | [操作流程指南](docs/development-service-guide.md) | -| 理解Project/Task状态保存在哪里、如何变化 | [状态承载与TaskView](docs/development-service-guide.md#状态由谁承载和更新) | -| 从源码跑隔离演示、做恢复验证 | 本README第7–8节 | - -当前有工作台网页与直接HTTP接口;命令行的fb_dev输出JSON,没有独立交互式终端看板或Swagger页面。 -个人代理的私聊/问题交接有真实记录,完整值守开发闭环尚未实际验收;不要把存在接口当作所有场景已验证。 -公共Chief与个人项目代理是不同身份。任务发布、运行、候选验证、PR更新、Chief验收、合并/部署也不是同一步。 - -## 1. 获取代码 - -```bash -git clone --branch delivery/ai-centric-development-service --single-branch \ - https://github.com/ThinkFlowLab/FlyBot.git FlyBot-service -cd FlyBot-service -git rev-parse HEAD -``` - -本分支发布到主仓的新分支,来自原交付分支 `zy95-12/FlyBot@db749e4eb`。 -这是已验证代码的交付快照,尚未整合主仓最新 main 的后续变更;不能据此声称已通过主线集成测试。 -创建本分支不合并 main,不改变既有阶段 PR,也不升级正在运行的服务。 - -## 2. 直接使用当前机器上的服务 - -当前已部署服务监听 `http://127.0.0.1:18080`,HTTP API 为: - -```text -POST http://127.0.0.1:18080/api/bot -Content-Type: application/json -X-Flybot-Member-Session: <登录返回的 sessionToken> -``` - -在服务器本机调用无需转发。在自己的电脑调用,先保持以下 SSH 连接: +需要 **Node.js22.22+、npm、Git**,首次安装需要访问 npm。Linux 是当前验证环境。 ```bash -ssh -N -L 18080:127.0.0.1:18080 root@你的服务器SSH地址 +git clone --branch feat/2189-backend-quickstart https://github.com/ThinkFlowLab/FlyBot.git +cd FlyBot +node scripts/backend.mjs init +node scripts/backend.mjs start ``` -然后使用下面的 `http://127.0.0.1:18080`。这是你电脑上的转发地址, -不是服务器公网入口。不要将 `18081` 的原始 Harness 管理端口作为人类 API 使用。 +`init` 安装锁定的依赖及 pnpm,默认数据保存在 `~/.flybot-backend`;不会创建临时项目或删除退出后的数据。 +`start` 输出服务地址,默认 `http://127.0.0.1:3080`。Ctrl-C 正常停止,再次 `start` 恢复同一数据。 +端口占用或需要指定数据目录时,见[配置和运维](docs/backend-operations.md)。 -已有 Owner / Maintainer / Member 使用原来的邮箱与密码;Owner 邮箱为 -`owner@flybot.example.com`。密码由操作者私下提供,不写入仓库;从源码新建演示时账号不同,见第 7 节。 +**执行 AI 工作前**,为启动服务的环境配置真实模型,例如 `DEEPSEEK_API_KEY`, +或沿用 DSH 的模型配置。密钥放在私有环境/凭据存储中,不写入 Git。 +Codex/Claude 执行还需配置角色的 Harness、Resource、独立登录环境及仓库权限, +见[执行配置](docs/backend-operations.md#执行配置)。未配置时任务会等待配置或资源,不会伪装成已执行。 -### 登录,取得调用会话 +## 登录和第一次调用 -以下命令在 **Bash** 中执行,需要 Python 3。密码只在提示时输入。 +另开终端,在同一源码目录执行: ```bash -export FLYBOT_BASE_URL='http://127.0.0.1:18080' -export FLYBOT_EMAIL='owner@flybot.example.com' -read -r -s -p 'FlyBot password: ' FLYBOT_PASSWORD -printf '\n' -export FLYBOT_PASSWORD -FLYBOT_SESSION="$(python3 - <<'PY' -import json, os, urllib.request, urllib.error -url = os.environ['FLYBOT_BASE_URL'].rstrip('/') + '/api/bot' -data = {'endpoint': 'platform-login', 'payload': { - 'email': os.environ['FLYBOT_EMAIL'], 'password': os.environ['FLYBOT_PASSWORD']}} -request = urllib.request.Request(url, data=json.dumps(data).encode(), - headers={'Content-Type': 'application/json'}) -with urllib.request.urlopen(request, timeout=30) as response: - body = json.load(response) -if not body.get('ok'): - raise SystemExit(json.dumps(body.get('error'), ensure_ascii=False)) -print(body['value']['sessionToken']) -PY -)" -unset FLYBOT_PASSWORD -export FLYBOT_SESSION +node scripts/backend.mjs login +node scripts/backend.mjs request projects +node scripts/backend.mjs request project-create --json '{"name":"My project","goal":"维护产品代码与技术决策"}' ``` -登录失败时先解决错误再继续;不要把 `FLYBOT_SESSION` 发到群聊、日志或 Issue。 -工作台浏览器登录和这里的 API 登录可以是不同 Session。服务器会在每次调用时重新检查身份与 Project 权限。 - -### 定义两个调用函数 +未连接账号平台时,首次 `login` 通过原生邀请机制建立本机 Owner 会话,后续复用; +凭据仅保存在数据目录的私有文件中。已配置 control-plane 时使用 +`node scripts/backend.mjs login --email YOUR_EMAIL`,在提示中输入已有密码,账号和权限复用原平台。 +配置账号平台不需要修改它的代码或操作其管理 UI。 -仍在同一个 Bash 终端粘贴以下代码。`fb_api` 用于现有原生接口; -`fb_dev` 封装 v1 请求信封,**不替代后端权限检查**。 +将下列 `PROJECT_ID` 换成创建项目返回的 `project.id`: ```bash -fb_api() { - local fb_endpoint="$1" fb_payload='{}' - if [ "$#" -ge 2 ]; then fb_payload="$2"; fi - python3 - "$fb_endpoint" "$fb_payload" <<'PY' -import json, os, sys, urllib.request, urllib.error -url = os.environ['FLYBOT_BASE_URL'].rstrip('/') + '/api/bot' -token = os.environ.get('FLYBOT_SESSION', '') -if not token: - raise SystemExit('Missing FLYBOT_SESSION; sign in first') -request = urllib.request.Request(url, data=json.dumps({ - 'endpoint': sys.argv[1], 'payload': json.loads(sys.argv[2])}).encode(), - headers={'Content-Type': 'application/json', 'X-Flybot-Member-Session': token}) -try: - with urllib.request.urlopen(request, timeout=60) as response: - body = json.load(response) -except urllib.error.HTTPError as error: - raise SystemExit('HTTP ' + str(error.code) + ': ' + error.read().decode()) -if not body.get('ok'): - raise SystemExit(json.dumps(body.get('error'), ensure_ascii=False)) -print(json.dumps(body['value'], ensure_ascii=False, indent=2)) -PY -} - -fb_dev() { - local fb_envelope - fb_envelope="$(python3 - "$@" <<'PY' -import json, os, sys -if len(sys.argv) < 3: - raise SystemExit('Usage: fb_dev query|command NAME [PAYLOAD_JSON] [REQUEST_ID]') -body = {'version': 'project-development/v1', 'projectId': os.environ['FLYBOT_PROJECT_ID'], - 'kind': sys.argv[1], 'name': sys.argv[2], - 'payload': json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}} -if body['kind'] == 'command': - if len(sys.argv) != 5: - raise SystemExit('A command needs a stable REQUEST_ID as the fourth argument') - body['requestId'] = sys.argv[4] -print(json.dumps(body)) -PY -)" || return - fb_api project-development "$fb_envelope" -} +node scripts/backend.mjs command consultation.ask --project PROJECT_ID --request-id ask-1 --json '{"text":"解释一下项目当前的情况"}' +node scripts/backend.mjs query consultation.list --project PROJECT_ID +node scripts/backend.mjs query tasks --project PROJECT_ID ``` -### 找到 Project,先做只读查询 +咨询异步返回,结果留在当前个人代理的私有咨询记录中,Task 数量不增加。 +创建有项目影响的工作时才发布 Task: ```bash -fb_api projects '{}' - -# 当前部署的演示项目。其他实例请从 projects 响应中选择自己的 project.id。 -export FLYBOT_PROJECT_ID='project-e4acb996-350e-4b2a-b521-29a433445a0d' -fb_dev query tasks '{"limit":20}' -fb_dev query pools '{}' -fb_dev query chief.state '{}' -fb_dev query execution.boundary '{}' +node scripts/backend.mjs command task.propose --project PROJECT_ID --request-id change-1 --json '{"title":"修复明确的代码问题","criteria":"复现用例及必要回归通过","impact":"恢复项目功能"}' +node scripts/backend.mjs query tasks --project PROJECT_ID ``` -`execution.boundary` 返回当前身份、Project 权限、代理模式及资源预检等安全元数据; -它不授予权限。Owner、Maintainer、Member 的可执行操作不同,以响应和后端授权为准。 - -## 3. 发布并启动一个开发任务 - -此节包含真实写操作:可能启动模型执行、修改登记的 Git 工作区并发布原任务的 Draft PR。 -先确认目标 Project 已由操作者配置仓库、资源、Harness 和凭据;不要拿原来的前端任务做试验。 - -发布任务与启动执行是两个动作。普通问题请直接问 Chief,不要创建 Task。 -目前任务创建复用 `task-create`;并不存在 `project-development/v1` 的 `task.create` 命令。 - -1. 查询项目 Bot,记录 `public.botId`: - - ```bash - fb_api project-agents "{\"projectId\":\"$FLYBOT_PROJECT_ID\"}" - ``` - -2. 将返回的公共 Chief Bot ID 填到 `botId`,再提交明确的变更及验收条件: - - ```bash - fb_api task-create '{"projectId":"YOUR_PROJECT_ID","botId":"PUBLIC_CHIEF_BOT_ID","title":"修复目标仓库中的具体问题","criteria":"写清复现步骤、预期行为与必须通过的验证","projectTaskKind":"code"}' - ``` - - 使用同一个 Project,保存返回的任务 `id`。`task-create` 仍是原接口, - 不承诺用 v1 requestId 去重;响应不确定时先查任务列表,避免重复创建。 - -3. 使用真实返回的任务 ID 启动: - - ```bash - fb_dev command task.run '{"taskId":"YOUR_NEW_TASK_ID"}' 'manual-run-001' - ``` - - `requestId` 对同一次启动重试保持不变;新的操作使用新 ID。 - 返回排队或已启动不代表完成。角色从原任务池领取,自行决定下游、打回或请 Chief 裁定;不是这里按固定 DAG 派活。 - -## 4. 查看进度、补充输入和验收 - -将示例 ID 换为上一步或查询响应中真实返回的 ID。 +返回 `result.id` 是 Task ID。发布后先等待 Chief 接纳;配置好仓库、模型、角色与资源后, +Owner/Maintainer 可通过 `chief.configure` 启用 Chief。Chief 接纳会提交启动意图,再由现有任务池推进。 +首个代码任务的[完整配置与推进步骤](docs/development-service-guide.md#首个代码任务)单独列出,避免把“发布成功”当作开发完成。 ```bash -fb_dev query task '{"taskId":"YOUR_TASK_ID"}' -fb_dev query task.detail '{"taskId":"YOUR_TASK_ID","attemptLimit":5}' -fb_dev query inputs '{"taskId":"YOUR_TASK_ID","limit":20}' -fb_dev query effects '{"taskId":"YOUR_TASK_ID"}' -fb_dev query attempt.log '{"attemptId":"YOUR_ATTEMPT_ID"}' -fb_dev query attempt.integration-state '{"attemptId":"YOUR_ATTEMPT_ID"}' +node scripts/backend.mjs query task --project PROJECT_ID --json '{"taskId":"TASK_ID"}' +node scripts/backend.mjs events --project PROJECT_ID ``` -读取 `result.task` 的 `phase`、`waitingFor`、`reason`、证据引用和 `nextActions`。 -例如 `result-ready` / 等待 Chief 或验收不等于 Task 已关闭;PR 存在也不等于交付成功。 -`task.detail` 的 Attempt 摘要与受权限过滤的日志用于排障,完整原始上下文需按 `attempt.context` 分页读取。 - -追加需求,仍投递到同一个 Task: +CLI 输出与 HTTP 是同一套接口;查询无需模型,事件支持游标恢复。 +HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, +成员或个人代理使用 `X-Flybot-Member-Session`;原始 Harness 端口和凭据留在服务内部。 -```bash -fb_dev command input.publish '{"taskId":"YOUR_TASK_ID","text":"新增的明确要求或验证反馈"}' 'feedback-001' -``` +## 进一步使用 -`accepted`、已送达、Bot 声明已读和已处理是不同状态;以 `inputs` 的回执为准。 -Chief 的 30 分钟巡检是原生定时任务配置,不是代码里的固定常量; -配置或手动巡检使用 `chief.configure` / `chief.inspect`,具体字段见[Chief 契约](docs/subsystems/project-chief-control.md)。 - -代码任务的验收必须满足同一候选 SHA 的宿主测试、角色 Review、PR HEAD 及其他既有门禁。 -不要伪造 Bot handoff、worker 事件或 Chief 结果;这些是宿主专用通道,不对人类 HTTP 调用开放。 - -使用结束,只注销本次 API 登录: - -```bash -fb_api auth-revoke '{}' -unset FLYBOT_SESSION -``` - -## 5. 接口索引与错误处理 - -所有 v1 操作都通过同一个 `POST /api/bot`: - -```json -{ - "endpoint": "project-development", - "payload": { - "version": "project-development/v1", - "kind": "query", - "name": "tasks", - "projectId": "YOUR_PROJECT_ID", - "payload": {"limit": 20} - } -} -``` - -响应外层为 `{"ok":true,"value":...}`;v1 的 `value` 中包含 `version`、可选 `requestId` 和 `result`。 -**HTTP 200 不一定表示业务成功**:`ok:false` 时读取 `error.code`、`error.message`;上面的函数会返回非零退出状态。 - -| 类别 | 操作名 | -| --- | --- | -| 查询 `query` | `tasks`、`task`、`task.detail`、`pools`、`chief.state` | -| 查询 `query` | `inputs`、`sources`、`effects`、`execution.boundary` | -| 查询 `query` | `attempt.context`、`attempt.log`、`attempt.integration-state` | -| 命令 `command` | `task.run`、`task.accept`、`task.reject` | -| 命令 `command` | `attempt.stop`、`attempt.cleanup`、`attempt.publication-retry`、`attempt.integration-bind` | -| 命令 `command` | `queue.cancel`、`queue.retry`、`chief.inspect`、`chief.configure`、`schedule.configure` | -| 命令 `command` | `agent.mode`、`proposal.publish`、`execution.input`、`input.publish` | -| 命令 `command` | `source.subscribe`、`source.disable`、`source.poll`、`effect.retry`、`comment.publish` | - -命令需要 1–100 字符的稳定 `requestId`(字母、数字、`_`、`-`)。 -可选 `expectedGeneration` / `expectedCandidateSha` 位于 v1 信封,不是内部 payload, -用于拒绝过期快照;各底层操作仍有自己的幂等与并发规则,不承诺所有命令无条件 exactly-once。 -`execution.input` 的内部 payload.requestId 是执行问题的 ID,不能拿外层请求 ID 代替。 - -| 错误/现象 | 应如何处理 | +| 需要 | 文档 | | --- | --- | -| 未登录、Session 撤销或账号失效 | 重新登录;不要用 DSH owner cookie 替代成员会话 | -| `bot/forbidden`、权限拒绝 | 检查 Project 成员角色及操作权限;body 中声明 Owner 无效 | -| `bot/invalid`、协议/参数错误 | 检查 version、kind、name 和必填字段 | -| `bot/conflict`、generation/候选变化 | 重读 Task/Attempt,确认新状态再决定操作 | -| 等待资源、worker 退出或 Chief 裁定 | 查任务池、边界与日志;不能靠重复启动或删 state 绕过 | -| 断线但远端可能已发布成功 | 查原 PR 与持久化 Effect;使用已支持的恢复接口,不另造成功回执 | - -逐接口的参数、结果、权限、幂等与错误说明见[API参考](docs/development-service-api.md)。 -内部实现约束见[应用服务协议](docs/subsystems/project-development-service.md)、 -[任务输入](docs/subsystems/project-inputs.md)、[订阅](docs/subsystems/project-sources.md)、 -[持久化副作用](docs/subsystems/project-effects.md)、[执行边界](docs/subsystems/project-execution-boundary.md)。 -GitHub 订阅目前是原生定时轮询,不是一个外部 webhook URL。 - -## 6. 当前机器上的运维 - -以下是现有部署的宿主操作,不是 HTTP 开发任务命令: - -```bash -sudo systemctl status flybot-shared-workbench.service -sudo journalctl -u flybot-shared-workbench.service -n 50 --no-pager -``` - -服务已自启动,关闭你的 SSH/API 客户端不会关闭服务。工作区、运行状态和私有凭据由操作者在 -`/var/lib/flybot-shared` 管理,部署源在 `/srv/flybot-shared/release`。 -本分支不包含这些私有文件、账号密码或本机 operator launcher。 -要在此机器升级代码,应先完成验证,并等待活跃 SDK、宿主验证、角色租约和 Chief 工作到安全检查点, -保留状态/源备份后再切换部署;不要直接把 checkout 覆盖到运行目录。 -恢复与回滚步骤见[上线手册](docs/service-recovery-demo.md)。 - -## 7. 从源码启动一个隔离演示(另一台机器) - -要求 Node.js 22.19+(CI 使用 Node 24)、Git、Python 3/venv、Bash 和 npm。 -账号服务复用不修改的 control-plane 代码,仅用于演示注册/登录;无需操作它的管理 UI。 -以下启动的是**临时账号、临时仓库和确定性模型**,不是复制现有机器的 Codex 凭据或真实前端任务。 - -在 `FlyBot-service` 的父目录获取账号服务源码并固定版本。下面的账号服务仓库沿用原部署的fork地址; -若其为私有,需要相应读取权限,本分支不会自动授予依赖仓库的访问权: - -```bash -git clone https://github.com/zy95-12/flybot-control-plane.git -cd flybot-control-plane -git checkout --detach 0369a2e0007cd4ca75474c049e7fe57da1f313a6 -cd ../FlyBot-service -npm ci --prefix core --no-fund --no-audit -npm run harness --prefix core -python3 -m venv ../.venv-flybot-service-demo -../.venv-flybot-service-demo/bin/python -m pip install \ - -r ../flybot-control-plane/control_plane/requirements.txt \ - -r dev/demo/requirements.txt -../.venv-flybot-service-demo/bin/python dev/demo/control_plane.py \ - --control-plane ../flybot-control-plane --serve-workbench --port 0 -``` - -启动时分别输入 Alice/Bob 的演示密码(12 字符以上),保持启动终端运行。 -另开终端,把 `FLYBOT_BASE_URL` 换为打印的 **Shared Project workbench** 地址, -邮箱使用 `alice@example.com` 或 `bob@example.com`,按第 2 节登录并调用 API。 -通过 `projects` 查询项目;空列表时可在这个隔离演示中创建测试项目: - -```bash -fb_api project-create '{"name":"API demo","repository":"example/delivery-fixture"}' -``` - -保存返回的 `project.id` 为 `FLYBOT_PROJECT_ID`,再查询任务或取得公共 Chief。 -这里的仓库是演示器创建的临时 fixture;其他部署必须使用操作者登记的仓库能力。 -这个隔离启动器沿用其确定性 Workflow 验证配置,不是当前共享服务的 Codex/原生任务池部署模板。 -新环境的真实 Codex 开发还需要显式配置可用仓库、测试命令、资源、SDK/profile/model 和 GitHub 发布身份; -仅安装依赖或启动此隔离演示不代表这些真实资源已配置成功。 -更多真实执行配置及限制见[共享演示](docs/shared-demo.md)、[资源契约](docs/subsystems/project-resources.md) -和 [Harness 契约](docs/subsystems/project-harness.md)。Ctrl-C 会清理临时演示的数据和进程。 - -## 8. 验证与交付边界 - -安装依赖后,在源码 checkout 内运行: - -```bash -TZ=UTC npm run verify -npm run demo:service-recovery -- --list -npm run demo:service-recovery -- --output /tmp/flybot-recovery-new.json -``` - -恢复报告必须使用尚不存在的文件路径;该命令在隔离 fixture 上检查真实本地 store/Git 与受控 SDK/GitHub 适配器, -**不会向正在运行的服务注入故障,也不等价于真实供应商或生产任务验收**。 - -基线证据:`ceacfc76` 全量 4,477 项通过;`1811e3b8f` 的 121 项受影响回归、 -16 个恢复场景、服务 UID 检查和基础 CI 通过。分支 README 的后续提交不冒用这些旧 SHA 的完整验证。 -真实 Codex/GitHub 反馈→原 Task→Dev→Tester/Reviewer→更新原 PR 曾跑通; -新前端视觉、弱网、个人 Chief 首次使用及最终服务验收仍单独跟踪,不由这些测试数证明。 - -本次文档交付另外检查了 Bash 示例语法、JSON/本地链接、接口索引与源码一致性, -并实测本文函数的登录、只读查询、错误拒绝、退出和撤销后访问,以及源码隔离演示的 -启动/登录/停止/清理。未重新安装所有依赖或重新运行整套业务测试:本分支只增加文档,业务源码与1811基线一致。 - ---- - -## 原版 FlyBot 介绍 - -以下保留上游应用介绍;本交付分支后端服务的使用方式以上文为准。 - -

- - - FlyBot - -

- -

- Long-lived AI Bots that work as a team in your browser: one request goes in, the team takes it apart, one answer comes back. -

- -

- Test - Release - Node - License -

- -

English | 中文

- -FlyBot is a local team of AI Bots you run in your own browser, on top of -[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). You talk to one Bot — the **Chief**. It keeps -the rest of the team, hands each part of a job to the Bot whose role fits, and comes back with one answer. Every Bot -keeps its own memory, working folder, model and schedules between turns, so the team you set up on Monday is still -there on Friday. - -## Is this for you? - -**Yes, if** you want work that outlives one chat — a team that keeps its own notes, folders and schedules and picks -it up again tomorrow; you would rather run it yourself, on your machine, with your own model keys; or you want your -Bots to be able to talk to a friend's Bots. - -**Probably not, if** you just need one quick answer (any chat app does that), or you want a framework to build your -own agents on — FlyBot is an app you run, on top of DeepSeek Harness. - -## See it work - -### One request, a whole team - -![Tree plugin: Chief splits a Hangzhou trip across Travel, Food, and Budget](docs/images/demo-tree.gif) - -*Demo: Chief starts alone in the sidebar, creates the child Bots Travel, Food and Budget, hands each one a part, and -then combines their answers into one plan. Some waiting is sped up.* -[Watch the full video (MP4)](docs/images/demo-tree.mp4) - -- **You only talk to the Chief.** It splits the request and sends each part one level down; a Bot with children - coordinates, a Bot without children does the work itself. -- **The tree grows itself.** Any Bot can create a child for a slice of work. Bots are grouped by role, duplicates - are merged, and a Bot idle for 30 days is archived — never deleted, and each reorganization can be undone in one - click. -- **You can always stop it.** A running turn ends on `stop_bot`, and no message rule ever blocks a stop. - -### Your Bots and your friends' Bots - -![Social plugin: invite a friend, message their Bot, approve the held reply, and see it arrive](docs/images/demo-social.gif) - -*Demo: one owner invites a friend to their Chief from **Sharing**; the friend pastes the link in **Add friend** and -messages that Chief. The reply waits in the owner's chat with an **Approve** card, and after **Approve** it arrives in -the friend's chat. Some waiting is sped up.* [Watch the full video (MP4)](docs/images/demo-social.mp4) - -- **The meeting point is yours.** A [Bot Social](https://github.com/Birfy/bot-social) server you self-host connects - two homes. With no server configured, FlyBot simply stays local. -- **Sharing is one Bot at a time, and off by default.** An invite grants exactly one Bot; **People with access** lists - who has it, and **Revoke** takes it back. -- **Your replies can wait for you.** What a Bot sends can sit in the chat with **Approve** / **Reject** until you - decide, or go straight out where **Auto-send** is on for that Bot and that person. What a friend's Bot writes - arrives fenced as untrusted text. - -### Several Bots in one room - -![Face-to-face room: two homes join with the same 6-digit number and chat with their Bots](docs/images/demo-face.gif) - -*Demo: each home picks its Bots in **+** → **Face-to-face room** and types the same 6-digit number, then **Enter -chat**. A plain line is answered by the Bots in the room; **@Planner** is answered by that Bot alone. Auto-send was on -here, so Bot lines post without a hold. Some waiting is sped up.* -[Watch the full video (MP4)](docs/images/demo-face.mp4) - -- **No friendship needed.** Two homes type the same 6-digit number within about ten minutes and are in one room: a - plain line is answered by the Bot it fits, `@Name` by that Bot alone, `@all` by everyone. -- **Only the Bots you brought.** The other home sees neither your other Bots nor your other chats. - -## Also in the app - -- **Task board.** One card per piece of work — owner, column, and how far it got — and a card can be handed to a Bot - or to a Workflow. -- **Group chats.** You and some Bots in one room; the room's board says who does what, and a plain message is answered - by the member it fits. -- **Workflows** (`@flybot/workflow`). A pipeline drawn as a flowchart of your Bots: drag a Bot onto the canvas, - connect it, save. A run walks the graph and posts its progress in the Workflow chat. In v1 a step posts that progress - and moves on; it does not call that Bot's model yet. -- **Schedules.** A Bot wakes at a fixed time to do one job, then sleeps again. -- **Connectors.** GitHub and Composio, connected once in Settings and on for every one of your Bots, unless you turn - one off for a particular Bot. -- **Timeline** (`@flybot/timeline`, optional and off by default). Swimlanes per Bot, who waits on whom, and how busy - each Bot is. It stores ids, times and names — never message text, tool arguments or output. - -## Install - -**1. What you need** - -- **Node.js 22.19+** and **pnpm** (`npm install -g pnpm`, or `corepack enable`) -- **Git**, to install from GitHub -- An API key for at least one model provider — for example DeepSeek ([how to set one](docs/deepseek-api-key.md)) - -**2. Add FlyBot to DeepSeek Harness** - -```sh -npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/core" -npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/social" -npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/tree" -``` - -The first line is the FlyBot core (package `ds-bot`); the two plugins are what turn it into a team. Add -`/plugins/workflow` or `/plugins/timeline` the same way if you want them. - -On DSH Desktop: **Plugins → Add plugin**, paste one `github:` address at a time, then **Install** and **Enable now**. - -`0.2.1-rc.2` is the DeepSeek Harness release FlyBot is built and tested against — the same version `core/dev/harness` -pins and CI runs, so keep it in all four commands. - -**3. Run it** - -```sh -npx -y @deepseek-ai/dsh@0.2.1-rc.2 web --port 3080 -``` - -Open the printed `http://127.0.0.1:3080/?token=…` link, pick a model the first time, and say hello to the Chief. To -keep FlyBot running on a machine that stays on, use the container: [Run FlyBot in Docker](docs/docker.md). - -## Friends' Bots are opt-in - -Social stays idle until you point it at a server you host. - -1. Deploy [Bot Social](https://github.com/Birfy/bot-social) — FlyBot does not ship one. Its README covers - `PUBLIC_BASE_URL`. -2. Add the server to `$DSH_HOME/profiles/web/cordis.patch.yml` and restart DSH: - - ```yaml - - id: flybot-social - config: - server: https://your-server.example.com - displayName: Your Name - ``` - -3. On the first start FlyBot registers your home (`@.home`) and shares nothing. To share one Bot: that Bot's - settings → **Sharing** → **Copy invite link for **; your friend opens **+** → **Add friend**, pastes the link - and accepts. - -Change the server later in Settings → **Bot Social**, without editing YAML: a new server is a new identity, and the -old credentials are kept as a backup. Details: [social subsystem](docs/subsystems/social.md). - -## Docs - -- Core options, models and updates: [`core/README.md`](core/README.md) -- Plugins: [`tree`](plugins/tree/README.md), [`social`](plugins/social/README.md), - [`workflow`](plugins/workflow/README.md), [`timeline`](plugins/timeline/README.md) -- Architecture and subsystem pages: [docs/architecture.md](docs/architecture.md), - [docs/subsystems/](docs/subsystems/README.md) -- Known issues: [docs/known-issues.md](docs/known-issues.md) -- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md), [AGENTS.md](AGENTS.md), [LESSONS.md](LESSONS.md) - -## Built on - -FlyBot is the upstream [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) core, kept byte-identical and pinned in -`ds-bot.pin.json`, plus a short patch series (`patches/` → `core/`) and the plugins under `plugins/`. It is developed -and tested against DeepSeek Harness `0.2.1-rc.2`, the version the commands above pin. The npm package keeps -the upstream name `ds-bot`, while everything you see in the app says FlyBot. - -## License - -[Apache License 2.0](LICENSE). Third-party notices in [NOTICE](NOTICE). - -## Disclaimer - -FlyBot is an independent community project. **It is not an official DeepSeek product.** Bots use your own model quota -and can read, write, and run commands when you ask them to — check important actions yourself. +| 对象、权限、全部24查询/26命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | +| Chief/角色/Task/Handoff、咨询、个人代理、安全与值守、状态与上下文 | [协作指南](docs/development-service-guide.md) | +| 数据目录、账号平台、模型/SDK/仓库、停止重启、备份升级、排障 | [部署运维](docs/backend-operations.md) | +| 个人Chief、多Bot、群聊、公共Bot、原版安装与桌面功能 | [原版FlyBot](README.native.zh.md) · [English](README.native.md) | +| 旧任务、原生群聊、Workflow和JSON/SQLite切换 | [兼容与迁移](docs/subsystems/project-compatibility.md) | +| 架构与实现边界 | [架构](docs/architecture.md) · [子系统](docs/subsystems/README.md) | + +真实部署入口是 `scripts/backend.mjs`。`dev/demo/` 是隔离开发验证工具,会使用测试模型或临时数据,不能作为生产启动方法。 +自动测试通过、真实 SDK 验证、浏览器验收、PR 合入与生产部署分别记录;本分支不承诺未验证的完整真实模型开发闭环。 diff --git a/README.native.md b/README.native.md new file mode 100644 index 000000000..364f9448d --- /dev/null +++ b/README.native.md @@ -0,0 +1,176 @@ +

+ + + FlyBot + +

+ +

+ Long-lived AI Bots that work as a team in your browser: one request goes in, the team takes it apart, one answer comes back. +

+ +

+ Test + Release + Node + License +

+ +

English | 中文

+ +FlyBot is a local team of AI Bots you run in your own browser, on top of +[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). You talk to one Bot — the **Chief**. It keeps +the rest of the team, hands each part of a job to the Bot whose role fits, and comes back with one answer. Every Bot +keeps its own memory, working folder, model and schedules between turns, so the team you set up on Monday is still +there on Friday. + +## Is this for you? + +**Yes, if** you want work that outlives one chat — a team that keeps its own notes, folders and schedules and picks +it up again tomorrow; you would rather run it yourself, on your machine, with your own model keys; or you want your +Bots to be able to talk to a friend's Bots. + +**Probably not, if** you just need one quick answer (any chat app does that), or you want a framework to build your +own agents on — FlyBot is an app you run, on top of DeepSeek Harness. + +## See it work + +### One request, a whole team + +![Tree plugin: Chief splits a Hangzhou trip across Travel, Food, and Budget](docs/images/demo-tree.gif) + +*Demo: Chief starts alone in the sidebar, creates the child Bots Travel, Food and Budget, hands each one a part, and +then combines their answers into one plan. Some waiting is sped up.* +[Watch the full video (MP4)](docs/images/demo-tree.mp4) + +- **You only talk to the Chief.** It splits the request and sends each part one level down; a Bot with children + coordinates, a Bot without children does the work itself. +- **The tree grows itself.** Any Bot can create a child for a slice of work. Bots are grouped by role, duplicates + are merged, and a Bot idle for 30 days is archived — never deleted, and each reorganization can be undone in one + click. +- **You can always stop it.** A running turn ends on `stop_bot`, and no message rule ever blocks a stop. + +### Your Bots and your friends' Bots + +![Social plugin: invite a friend, message their Bot, approve the held reply, and see it arrive](docs/images/demo-social.gif) + +*Demo: one owner invites a friend to their Chief from **Sharing**; the friend pastes the link in **Add friend** and +messages that Chief. The reply waits in the owner's chat with an **Approve** card, and after **Approve** it arrives in +the friend's chat. Some waiting is sped up.* [Watch the full video (MP4)](docs/images/demo-social.mp4) + +- **The meeting point is yours.** A [Bot Social](https://github.com/Birfy/bot-social) server you self-host connects + two homes. With no server configured, FlyBot simply stays local. +- **Sharing is one Bot at a time, and off by default.** An invite grants exactly one Bot; **People with access** lists + who has it, and **Revoke** takes it back. +- **Your replies can wait for you.** What a Bot sends can sit in the chat with **Approve** / **Reject** until you + decide, or go straight out where **Auto-send** is on for that Bot and that person. What a friend's Bot writes + arrives fenced as untrusted text. + +### Several Bots in one room + +![Face-to-face room: two homes join with the same 6-digit number and chat with their Bots](docs/images/demo-face.gif) + +*Demo: each home picks its Bots in **+** → **Face-to-face room** and types the same 6-digit number, then **Enter +chat**. A plain line is answered by the Bots in the room; **@Planner** is answered by that Bot alone. Auto-send was on +here, so Bot lines post without a hold. Some waiting is sped up.* +[Watch the full video (MP4)](docs/images/demo-face.mp4) + +- **No friendship needed.** Two homes type the same 6-digit number within about ten minutes and are in one room: a + plain line is answered by the Bot it fits, `@Name` by that Bot alone, `@all` by everyone. +- **Only the Bots you brought.** The other home sees neither your other Bots nor your other chats. + +## Also in the app + +- **Task board.** One card per piece of work — owner, column, and how far it got — and a card can be handed to a Bot + or to a Workflow. +- **Group chats.** You and some Bots in one room; the room's board says who does what, and a plain message is answered + by the member it fits. +- **Workflows** (`@flybot/workflow`). A pipeline drawn as a flowchart of your Bots: drag a Bot onto the canvas, + connect it, save. A run walks the graph and posts its progress in the Workflow chat. In v1 a step posts that progress + and moves on; it does not call that Bot's model yet. +- **Schedules.** A Bot wakes at a fixed time to do one job, then sleeps again. +- **Connectors.** GitHub and Composio, connected once in Settings and on for every one of your Bots, unless you turn + one off for a particular Bot. +- **Timeline** (`@flybot/timeline`, optional and off by default). Swimlanes per Bot, who waits on whom, and how busy + each Bot is. It stores ids, times and names — never message text, tool arguments or output. + +## Install + +**1. What you need** + +- **Node.js 22.19+** and **pnpm** (`npm install -g pnpm`, or `corepack enable`) +- **Git**, to install from GitHub +- An API key for at least one model provider — for example DeepSeek ([how to set one](docs/deepseek-api-key.md)) + +**2. Add FlyBot to DeepSeek Harness** + +```sh +npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/core" +npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/social" +npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/tree" +``` + +The first line is the FlyBot core (package `ds-bot`); the two plugins are what turn it into a team. Add +`/plugins/workflow` or `/plugins/timeline` the same way if you want them. + +On DSH Desktop: **Plugins → Add plugin**, paste one `github:` address at a time, then **Install** and **Enable now**. + +`0.2.1-rc.2` is the DeepSeek Harness release FlyBot is built and tested against — the same version `core/dev/harness` +pins and CI runs, so keep it in all four commands. + +**3. Run it** + +```sh +npx -y @deepseek-ai/dsh@0.2.1-rc.2 web --port 3080 +``` + +Open the printed `http://127.0.0.1:3080/?token=…` link, pick a model the first time, and say hello to the Chief. To +keep FlyBot running on a machine that stays on, use the container: [Run FlyBot in Docker](docs/docker.md). + +## Friends' Bots are opt-in + +Social stays idle until you point it at a server you host. + +1. Deploy [Bot Social](https://github.com/Birfy/bot-social) — FlyBot does not ship one. Its README covers + `PUBLIC_BASE_URL`. +2. Add the server to `$DSH_HOME/profiles/web/cordis.patch.yml` and restart DSH: + + ```yaml + - id: flybot-social + config: + server: https://your-server.example.com + displayName: Your Name + ``` + +3. On the first start FlyBot registers your home (`@.home`) and shares nothing. To share one Bot: that Bot's + settings → **Sharing** → **Copy invite link for **; your friend opens **+** → **Add friend**, pastes the link + and accepts. + +Change the server later in Settings → **Bot Social**, without editing YAML: a new server is a new identity, and the +old credentials are kept as a backup. Details: [social subsystem](docs/subsystems/social.md). + +## Docs + +- Core options, models and updates: [`core/README.md`](core/README.md) +- Plugins: [`tree`](plugins/tree/README.md), [`social`](plugins/social/README.md), + [`workflow`](plugins/workflow/README.md), [`timeline`](plugins/timeline/README.md) +- Architecture and subsystem pages: [docs/architecture.md](docs/architecture.md), + [docs/subsystems/](docs/subsystems/README.md) +- Known issues: [docs/known-issues.md](docs/known-issues.md) +- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md), [AGENTS.md](AGENTS.md), [LESSONS.md](LESSONS.md) + +## Built on + +FlyBot is the upstream [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) core, kept byte-identical and pinned in +`ds-bot.pin.json`, plus a short patch series (`patches/` → `core/`) and the plugins under `plugins/`. It is developed +and tested against DeepSeek Harness `0.2.1-rc.2`, the version the commands above pin. The npm package keeps +the upstream name `ds-bot`, while everything you see in the app says FlyBot. + +## License + +[Apache License 2.0](LICENSE). Third-party notices in [NOTICE](NOTICE). + +## Disclaimer + +FlyBot is an independent community project. **It is not an official DeepSeek product.** Bots use your own model quota +and can read, write, and run commands when you ask them to — check important actions yourself. diff --git a/README.native.zh.md b/README.native.zh.md new file mode 100644 index 000000000..089641350 --- /dev/null +++ b/README.native.zh.md @@ -0,0 +1,160 @@ +

+ + + FlyBot + +

+ +

在你自己的浏览器里干活的长期存活 AI Bot 团队:一句话进去,全队拆开做,一个答案回来。

+ +

+ Test + 发布 + Node + 许可 +

+ +

English | 中文

+ +FlyBot 是跑在你浏览器里的一支本地 AI Bot 团队,构建在 +[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 之上。你只跟一个 Bot 说话——**Chief**。它带 +着其余的人:把一件事拆开,交给职责合适的 Bot,再把答案合成一份给你。每个 Bot 都有自己长期保存的记忆、工作 +目录、模型和定时任务,所以周一搭好的团队,周五还在。 + +## 适合谁用? + +**适合你,如果**你要的活不止一个聊天窗口——团队自己记笔记、管目录、排定时任务,明天接着干;你更愿意用自己的 +机器、自己的模型额度来跑;或者你想让自己的 Bot 能和朋友家的 Bot 说话。 + +**大概不适合,如果**你只想要一个快速答案(任何聊天应用都能做),或者你在找一个能二次开发的 agent 框架—— +FlyBot 是一个直接跑起来的应用,建在 DeepSeek Harness 之上。 + +## 看看它怎么工作 + +### 一个请求,一整支团队 + +![树插件:Chief 把杭州行程拆给 Travel、Food、Budget](docs/images/demo-tree.gif) + +*演示:侧栏里一开始只有 Chief;它建出子 Bot Travel、Food、Budget,各分一块,再把答案汇总成一份行程。等待的 +部分做了加速。* [观看完整视频(MP4)](docs/images/demo-tree.mp4) + +- **你只跟 Chief 说话。** 它把请求拆开、往下一层各发一块;有子节点的 Bot 当协调者,没子节点的自己动手。 +- **树会自己长。** 任意 Bot 都能为一块活建子 Bot。Bot 按角色分组、重名合并,闲置 30 天的归档(从不删除), + 每次整理都能一键撤销。 +- **随时叫停。** 运行中的回合用 `stop_bot` 结束,任何消息规则都不会挡住这次叫停。 + +### 你的 Bot 和朋友的 Bot + +![社交插件:邀请好友、给对方的 Bot 发消息、批准被暂扣的回复、回复送达](docs/images/demo-social.gif) + +*演示:一方在 **Sharing(分享)** 里邀请好友使用自己的 Chief;好友在 **添加好友** 里粘贴链接,并给这个 Chief +发消息。回复停在主人的聊天里,出现 **Approve(批准)** 卡片,批准后到达好友的聊天。等待的部分做了加速。* +[观看完整视频(MP4)](docs/images/demo-social.mp4) + +- **碰头的地方是你自己的。** 两台机器之间靠你自己部署的 [Bot Social](https://github.com/Birfy/bot-social) + 服务器相连;没配服务器时,FlyBot 就只是本地的。 +- **一次只共享一个 Bot,而且默认不共享。** 一次邀请只授权一个 Bot;**People with access(访问权限)** 列出谁 + 有它,**Revoke(撤销)** 收回。 +- **回复可以等你点头。** Bot 要发的内容会停在聊天里等你 **Approve / Reject**;对这个 Bot 和这个人开了 + **Auto-send(自动发送)** 时直接发出。好友 Bot 写来的内容一律放进不可信围栏。 + +### 一个房间里的几个 Bot + +![面对面房间:两家输入同一组 6 位数字加入房间,和各自的 Bot 聊天](docs/images/demo-face.gif) + +*演示:双方在 **+** → **面对面房间** 里各选自己的 Bot、输入同一组 6 位数字,再点 **Enter chat(进入聊天)**。 +普通一句话由房间里的 Bot 回答;**@Planner** 只由那个 Bot 回答。演示中开了自动发送,所以 Bot 的消息不会被暂扣。 +等待的部分做了加速。* [观看完整视频(MP4)](docs/images/demo-face.mp4) + +- **不用先加好友。** 两家在十分钟内输入同一组 6 位数字就进同一个房间:普通一句话由合适的 Bot 回答,`@名字` + 只由那个 Bot 回答,`@all` 叫全员。 +- **只带你带来的 Bot。** 对方看不到你的其他 Bot,也看不到你的其他聊天。 + +## 应用里还有 + +- **任务看板。** 一件活一张卡:谁在干、在哪一列、做到几成;卡可以直接交给某个 Bot 或某个 Workflow。 +- **群聊。** 你和几个 Bot 在同一个房间;房间的看板决定谁做什么,普通一句话由合适的那位回答。 +- **工作流**(`@flybot/workflow`)。把流水线画成一张 Bot 流程图:拖一个 Bot 上画布、连线、保存。运行时沿着图 + 走,把进度发在 Workflow 聊天里。v1 的一步只发这条进度就往下走,还不会调用那个 Bot 的模型。 +- **定时任务。** Bot 到点醒来做一件事,做完再睡。 +- **连接器。** GitHub 与 Composio,在设置里连一次,你的每个 Bot 都能用;也可以单独给某个 Bot 关掉。 +- **时间线**(`@flybot/timeline`,可选,默认关闭)。每个 Bot 一条泳道、谁在等谁、各自多忙。只存 id、时间和 + 名称,不存消息正文、工具参数和输出。 + +## 安装 + +**1. 你需要** + +- **Node.js 22.19+** 与 **pnpm**(`npm install -g pnpm`,或 `corepack enable`) +- **Git**(从 GitHub 安装) +- 至少一个模型提供商的 API Key,例如 DeepSeek([怎么设置](docs/deepseek-api-key.md),英文) + +**2. 把 FlyBot 加到 DeepSeek Harness** + +```sh +npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/core" +npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/social" +npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/tree" +``` + +第一行是 FlyBot 核心(包名 `ds-bot`),后两行才把它变成一支团队。想要工作流或时间线,照样再加 +`/plugins/workflow`、`/plugins/timeline`。 + +桌面版:**插件 → 添加插件**,一次粘一个 `github:` 地址,然后**安装**并**立即启用**。 + +`0.2.1-rc.2` 是 FlyBot 构建与测试所用的 DeepSeek Harness 版本,也正是 `core/dev/harness` 钉住、CI 在跑的那一版, +四条命令保持一致。 + +**3. 跑起来** + +```sh +npx -y @deepseek-ai/dsh@0.2.1-rc.2 web --port 3080 +``` + +打开打印出的 `http://127.0.0.1:3080/?token=…`,第一次先选一个模型,然后跟 Chief 打个招呼。想让它在一台一直开着 +的机器上常驻,用容器:[用 Docker 运行 FlyBot](docs/docker.zh.md)。 + +## 朋友功能是可选的 + +没把社交插件指向服务器之前,它一直空闲。 + +1. 部署 [Bot Social](https://github.com/Birfy/bot-social)——FlyBot 不附带服务器,部署见它的 README + (`PUBLIC_BASE_URL`)。 +2. 把服务器写进 `$DSH_HOME/profiles/web/cordis.patch.yml`,重启 DSH: + + ```yaml + - id: flybot-social + config: + server: https://your-server.example.com + displayName: 你的名字 + ``` + +3. 首次启动会登记你家(`@<名字>.home`),什么都不共享。要共享某一个 Bot:该 Bot 设置 → **Sharing** → + **Copy invite link for **;对方在 **+** → **添加好友** 里粘贴链接并接受。 + +之后换服务器不用改 YAML:设置 → **Bot Social** 里换。换服务器等于换新身份,旧凭证会作为备份留着。细节见 +[社交子系统](docs/subsystems/social.md)。 + +## 文档 + +- 核心选项、模型与更新:[`core/README.md`](core/README.md) +- 插件:[`tree`](plugins/tree/README.md)、[`social`](plugins/social/README.md)、 + [`workflow`](plugins/workflow/README.md)、[`timeline`](plugins/timeline/README.md) +- 架构与子系统页:[docs/architecture.md](docs/architecture.md)、[docs/subsystems/](docs/subsystems/README.md) +- 已知问题:[docs/known-issues.md](docs/known-issues.md) +- 参与开发:[CONTRIBUTING.md](CONTRIBUTING.md)、[AGENTS.md](AGENTS.md)、[LESSONS.md](LESSONS.md) + +## 它建立在什么之上 + +FlyBot 是上游 [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) 的核心,保持字节一致并钉在 `ds-bot.pin.json` +里,加上一组简短的补丁(`patches/` → `core/`)和 `plugins/` 下的插件。开发和测试针对 DeepSeek Harness +`0.2.1-rc.2`,也就是上面命令钉住的版本。npm 包名沿用上游的 `ds-bot`,而你在界面里看到的都是 FlyBot。 + +## 许可 + +[Apache License 2.0](LICENSE)。第三方声明见 [NOTICE](NOTICE)。 + +## 声明 + +FlyBot 是独立社区项目,**不是 DeepSeek 官方产品**。Bot 用的是你自己的模型额度,并可能按你的要求读写文件、执行 +命令——重要操作请自己核对。 diff --git a/README.zh.md b/README.zh.md index f88494b67..9240363a2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,162 +1,86 @@ -> 本交付分支的自动开发后端服务使用说明请先阅读 [README.md](README.md);另见[逐接口参考](docs/development-service-api.md)及[个人代理/流程/状态指南](docs/development-service-guide.md)。 +# FlyBot:AI 协作开发后端 -

- - - FlyBot - -

+Project 管理项目、成员、角色与仓库;Chief 常驻协调,Dev/Reviewer/Tester +按 Task 使用独立角色 Session,通过持久化 Handoff 自主接手、打回和转交。 +普通提问直接咨询 Chief,不创建 Task。个人 Chief、个人 Bot、群聊与私有记忆继续保留。 -

在你自己的浏览器里干活的长期存活 AI Bot 团队:一句话进去,全队拆开做,一个答案回来。

+本交付分支包含后端收敛工作,尚未合入主仓 main 或部署到现有实例。 +它复用 FlyBot/DSH、原账号服务、任务池和执行器;没有另建一套调度系统。 -

- Test - 发布 - Node - 许可 -

+## 启动持久服务 -

English | 中文

+需要 **Node.js22.22+、npm、Git**,首次安装需要访问 npm。Linux 是当前验证环境。 -FlyBot 是跑在你浏览器里的一支本地 AI Bot 团队,构建在 -[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 之上。你只跟一个 Bot 说话——**Chief**。它带 -着其余的人:把一件事拆开,交给职责合适的 Bot,再把答案合成一份给你。每个 Bot 都有自己长期保存的记忆、工作 -目录、模型和定时任务,所以周一搭好的团队,周五还在。 - -## 适合谁用? - -**适合你,如果**你要的活不止一个聊天窗口——团队自己记笔记、管目录、排定时任务,明天接着干;你更愿意用自己的 -机器、自己的模型额度来跑;或者你想让自己的 Bot 能和朋友家的 Bot 说话。 - -**大概不适合,如果**你只想要一个快速答案(任何聊天应用都能做),或者你在找一个能二次开发的 agent 框架—— -FlyBot 是一个直接跑起来的应用,建在 DeepSeek Harness 之上。 - -## 看看它怎么工作 - -### 一个请求,一整支团队 - -![树插件:Chief 把杭州行程拆给 Travel、Food、Budget](docs/images/demo-tree.gif) - -*演示:侧栏里一开始只有 Chief;它建出子 Bot Travel、Food、Budget,各分一块,再把答案汇总成一份行程。等待的 -部分做了加速。* [观看完整视频(MP4)](docs/images/demo-tree.mp4) - -- **你只跟 Chief 说话。** 它把请求拆开、往下一层各发一块;有子节点的 Bot 当协调者,没子节点的自己动手。 -- **树会自己长。** 任意 Bot 都能为一块活建子 Bot。Bot 按角色分组、重名合并,闲置 30 天的归档(从不删除), - 每次整理都能一键撤销。 -- **随时叫停。** 运行中的回合用 `stop_bot` 结束,任何消息规则都不会挡住这次叫停。 - -### 你的 Bot 和朋友的 Bot - -![社交插件:邀请好友、给对方的 Bot 发消息、批准被暂扣的回复、回复送达](docs/images/demo-social.gif) - -*演示:一方在 **Sharing(分享)** 里邀请好友使用自己的 Chief;好友在 **添加好友** 里粘贴链接,并给这个 Chief -发消息。回复停在主人的聊天里,出现 **Approve(批准)** 卡片,批准后到达好友的聊天。等待的部分做了加速。* -[观看完整视频(MP4)](docs/images/demo-social.mp4) - -- **碰头的地方是你自己的。** 两台机器之间靠你自己部署的 [Bot Social](https://github.com/Birfy/bot-social) - 服务器相连;没配服务器时,FlyBot 就只是本地的。 -- **一次只共享一个 Bot,而且默认不共享。** 一次邀请只授权一个 Bot;**People with access(访问权限)** 列出谁 - 有它,**Revoke(撤销)** 收回。 -- **回复可以等你点头。** Bot 要发的内容会停在聊天里等你 **Approve / Reject**;对这个 Bot 和这个人开了 - **Auto-send(自动发送)** 时直接发出。好友 Bot 写来的内容一律放进不可信围栏。 - -### 一个房间里的几个 Bot - -![面对面房间:两家输入同一组 6 位数字加入房间,和各自的 Bot 聊天](docs/images/demo-face.gif) - -*演示:双方在 **+** → **面对面房间** 里各选自己的 Bot、输入同一组 6 位数字,再点 **Enter chat(进入聊天)**。 -普通一句话由房间里的 Bot 回答;**@Planner** 只由那个 Bot 回答。演示中开了自动发送,所以 Bot 的消息不会被暂扣。 -等待的部分做了加速。* [观看完整视频(MP4)](docs/images/demo-face.mp4) - -- **不用先加好友。** 两家在十分钟内输入同一组 6 位数字就进同一个房间:普通一句话由合适的 Bot 回答,`@名字` - 只由那个 Bot 回答,`@all` 叫全员。 -- **只带你带来的 Bot。** 对方看不到你的其他 Bot,也看不到你的其他聊天。 - -## 应用里还有 - -- **任务看板。** 一件活一张卡:谁在干、在哪一列、做到几成;卡可以直接交给某个 Bot 或某个 Workflow。 -- **群聊。** 你和几个 Bot 在同一个房间;房间的看板决定谁做什么,普通一句话由合适的那位回答。 -- **工作流**(`@flybot/workflow`)。把流水线画成一张 Bot 流程图:拖一个 Bot 上画布、连线、保存。运行时沿着图 - 走,把进度发在 Workflow 聊天里。v1 的一步只发这条进度就往下走,还不会调用那个 Bot 的模型。 -- **定时任务。** Bot 到点醒来做一件事,做完再睡。 -- **连接器。** GitHub 与 Composio,在设置里连一次,你的每个 Bot 都能用;也可以单独给某个 Bot 关掉。 -- **时间线**(`@flybot/timeline`,可选,默认关闭)。每个 Bot 一条泳道、谁在等谁、各自多忙。只存 id、时间和 - 名称,不存消息正文、工具参数和输出。 - -## 安装 - -**1. 你需要** - -- **Node.js 22.19+** 与 **pnpm**(`npm install -g pnpm`,或 `corepack enable`) -- **Git**(从 GitHub 安装) -- 至少一个模型提供商的 API Key,例如 DeepSeek([怎么设置](docs/deepseek-api-key.md),英文) - -**2. 把 FlyBot 加到 DeepSeek Harness** - -```sh -npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/core" -npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/social" -npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/tree" +```bash +git clone --branch feat/2189-backend-quickstart https://github.com/ThinkFlowLab/FlyBot.git +cd FlyBot +node scripts/backend.mjs init +node scripts/backend.mjs start ``` -第一行是 FlyBot 核心(包名 `ds-bot`),后两行才把它变成一支团队。想要工作流或时间线,照样再加 -`/plugins/workflow`、`/plugins/timeline`。 +`init` 安装锁定的依赖及 pnpm,默认数据保存在 `~/.flybot-backend`;不会创建临时项目或删除退出后的数据。 +`start` 输出服务地址,默认 `http://127.0.0.1:3080`。Ctrl-C 正常停止,再次 `start` 恢复同一数据。 +端口占用或需要指定数据目录时,见[配置和运维](docs/backend-operations.md)。 -桌面版:**插件 → 添加插件**,一次粘一个 `github:` 地址,然后**安装**并**立即启用**。 +**执行 AI 工作前**,为启动服务的环境配置真实模型,例如 `DEEPSEEK_API_KEY`, +或沿用 DSH 的模型配置。密钥放在私有环境/凭据存储中,不写入 Git。 +Codex/Claude 执行还需配置角色的 Harness、Resource、独立登录环境及仓库权限, +见[执行配置](docs/backend-operations.md#执行配置)。未配置时任务会等待配置或资源,不会伪装成已执行。 -`0.2.1-rc.2` 是 FlyBot 构建与测试所用的 DeepSeek Harness 版本,也正是 `core/dev/harness` 钉住、CI 在跑的那一版, -四条命令保持一致。 +## 登录和第一次调用 -**3. 跑起来** +另开终端,在同一源码目录执行: -```sh -npx -y @deepseek-ai/dsh@0.2.1-rc.2 web --port 3080 +```bash +node scripts/backend.mjs login +node scripts/backend.mjs request projects +node scripts/backend.mjs request project-create --json '{"name":"My project","goal":"维护产品代码与技术决策"}' ``` -打开打印出的 `http://127.0.0.1:3080/?token=…`,第一次先选一个模型,然后跟 Chief 打个招呼。想让它在一台一直开着 -的机器上常驻,用容器:[用 Docker 运行 FlyBot](docs/docker.zh.md)。 +未连接账号平台时,首次 `login` 通过原生邀请机制建立本机 Owner 会话,后续复用; +凭据仅保存在数据目录的私有文件中。已配置 control-plane 时使用 +`node scripts/backend.mjs login --email YOUR_EMAIL`,在提示中输入已有密码,账号和权限复用原平台。 +配置账号平台不需要修改它的代码或操作其管理 UI。 -## 朋友功能是可选的 +将下列 `PROJECT_ID` 换成创建项目返回的 `project.id`: -没把社交插件指向服务器之前,它一直空闲。 - -1. 部署 [Bot Social](https://github.com/Birfy/bot-social)——FlyBot 不附带服务器,部署见它的 README - (`PUBLIC_BASE_URL`)。 -2. 把服务器写进 `$DSH_HOME/profiles/web/cordis.patch.yml`,重启 DSH: - - ```yaml - - id: flybot-social - config: - server: https://your-server.example.com - displayName: 你的名字 - ``` - -3. 首次启动会登记你家(`@<名字>.home`),什么都不共享。要共享某一个 Bot:该 Bot 设置 → **Sharing** → - **Copy invite link for **;对方在 **+** → **添加好友** 里粘贴链接并接受。 - -之后换服务器不用改 YAML:设置 → **Bot Social** 里换。换服务器等于换新身份,旧凭证会作为备份留着。细节见 -[社交子系统](docs/subsystems/social.md)。 +```bash +node scripts/backend.mjs command consultation.ask --project PROJECT_ID --request-id ask-1 --json '{"text":"解释一下项目当前的情况"}' +node scripts/backend.mjs query consultation.list --project PROJECT_ID +node scripts/backend.mjs query tasks --project PROJECT_ID +``` -## 文档 +咨询异步返回,结果留在当前个人代理的私有咨询记录中,Task 数量不增加。 +创建有项目影响的工作时才发布 Task: -- 核心选项、模型与更新:[`core/README.md`](core/README.md) -- 插件:[`tree`](plugins/tree/README.md)、[`social`](plugins/social/README.md)、 - [`workflow`](plugins/workflow/README.md)、[`timeline`](plugins/timeline/README.md) -- 架构与子系统页:[docs/architecture.md](docs/architecture.md)、[docs/subsystems/](docs/subsystems/README.md) -- 已知问题:[docs/known-issues.md](docs/known-issues.md) -- 参与开发:[CONTRIBUTING.md](CONTRIBUTING.md)、[AGENTS.md](AGENTS.md)、[LESSONS.md](LESSONS.md) +```bash +node scripts/backend.mjs command task.propose --project PROJECT_ID --request-id change-1 --json '{"title":"修复明确的代码问题","criteria":"复现用例及必要回归通过","impact":"恢复项目功能"}' +node scripts/backend.mjs query tasks --project PROJECT_ID +``` -## 它建立在什么之上 +返回 `result.id` 是 Task ID。发布后先等待 Chief 接纳;配置好仓库、模型、角色与资源后, +Owner/Maintainer 可通过 `chief.configure` 启用 Chief。Chief 接纳会提交启动意图,再由现有任务池推进。 +首个代码任务的[完整配置与推进步骤](docs/development-service-guide.md#首个代码任务)单独列出,避免把“发布成功”当作开发完成。 -FlyBot 是上游 [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) 的核心,保持字节一致并钉在 `ds-bot.pin.json` -里,加上一组简短的补丁(`patches/` → `core/`)和 `plugins/` 下的插件。开发和测试针对 DeepSeek Harness -`0.2.1-rc.2`,也就是上面命令钉住的版本。npm 包名沿用上游的 `ds-bot`,而你在界面里看到的都是 FlyBot。 +```bash +node scripts/backend.mjs query task --project PROJECT_ID --json '{"taskId":"TASK_ID"}' +node scripts/backend.mjs events --project PROJECT_ID +``` -## 许可 +CLI 输出与 HTTP 是同一套接口;查询无需模型,事件支持游标恢复。 +HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, +成员或个人代理使用 `X-Flybot-Member-Session`;原始 Harness 端口和凭据留在服务内部。 -[Apache License 2.0](LICENSE)。第三方声明见 [NOTICE](NOTICE)。 +## 进一步使用 -## 声明 +| 需要 | 文档 | +| --- | --- | +| 对象、权限、全部24查询/26命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | +| Chief/角色/Task/Handoff、咨询、个人代理、安全与值守、状态与上下文 | [协作指南](docs/development-service-guide.md) | +| 数据目录、账号平台、模型/SDK/仓库、停止重启、备份升级、排障 | [部署运维](docs/backend-operations.md) | +| 个人Chief、多Bot、群聊、公共Bot、原版安装与桌面功能 | [原版FlyBot](README.native.zh.md) · [English](README.native.md) | +| 旧任务、原生群聊、Workflow和JSON/SQLite切换 | [兼容与迁移](docs/subsystems/project-compatibility.md) | +| 架构与实现边界 | [架构](docs/architecture.md) · [子系统](docs/subsystems/README.md) | -FlyBot 是独立社区项目,**不是 DeepSeek 官方产品**。Bot 用的是你自己的模型额度,并可能按你的要求读写文件、执行 -命令——重要操作请自己核对。 +真实部署入口是 `scripts/backend.mjs`。`dev/demo/` 是隔离开发验证工具,会使用测试模型或临时数据,不能作为生产启动方法。 +自动测试通过、真实 SDK 验证、浏览器验收、PR 合入与生产部署分别记录;本分支不承诺未验证的完整真实模型开发闭环。 diff --git a/ROADMAP.md b/ROADMAP.md index 4017fb43a..f1417830a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,10 +28,10 @@ execution ancestor through collaboration/context/Task/state/API branches. ## Active fork roadmap — 2026-10-31 -Backend delivery branch `delivery/ai-centric-development-service` consolidates S1–S6 at1811e3b8f. -Use the root README for login, direct API operations and isolated setup. This -documentation handoff does not complete original frontend #41 acceptance or merge -any staged PR; standalone process packaging/OpenAPI remain outside this branch. +The earlier delivery snapshot at1811e3b8f consolidated S1–S6. Current persistent +source deployment and API use follow the root README and #2189; OpenAPI remains +unprovided. Backend delivery does not complete original frontend #41 acceptance +or merge staged PRs. S6/#55 integrates current service stages with original native frontend work and adds repeatable isolated fault/replay acceptance. Actual PR feedback→original Task→ @@ -457,3 +457,5 @@ Native prior-host reconciliation now reuses verified supervisor process-group fa Canonical ongoing role/resource authority checks now reuse native step/SDK input and the existing scheduler + pool stop. Frozen role context remains stable; current revoked rights stop execution, with unknown exit held. Compose descendants and verify parent Task resume/API projections next. #2186 adds the original-capability inventory and committed legacy cutover on the existing Store, group/Chief channels and Workflow controller. Validate same-version JSON/SQLite rollback separately from real-provider and UI acceptance; final real backend packaging/README #2189 follows. + +#2189 supplies the persistent source init/start/login/API client and final object/API/operations documentation. Verify isolated fresh installation, retained auth/Task receipt across restart, consultation privacy, external safe/watch and SSE revoke; keep real-provider/remote/browser/production rollout evidence separate. Existing service/frontend flows are untouched. diff --git a/changelog.d/2189.added.md b/changelog.d/2189.added.md new file mode 100644 index 000000000..53b522033 --- /dev/null +++ b/changelog.d/2189.added.md @@ -0,0 +1 @@ +Add a persistent backend init/start/login and API/event CLI using the original FlyBot/DSH host and shared gateway. Refresh concise deployment, object/API and recovery documentation while preserving original guides. Confirm writable storage before readiness and preserve external safe-proposal requirements through publication, with authenticated restart/privacy/revocation regressions. diff --git a/core/src/host/personal-agent-auth.js b/core/src/host/personal-agent-auth.js index 5e0362464..47b3ea8fa 100644 --- a/core/src/host/personal-agent-auth.js +++ b/core/src/host/personal-agent-auth.js @@ -111,7 +111,9 @@ export function install(rt) { const origin = source(actor, body.projectId, { proposal: true }) if (rt.requireProjectAccess(actor, body.projectId).status !== 'active') throw refusal('Project is closed to new work') const requestId = bounded(body.requestId, 'proposal request ID', 100) - const fields = { title: bounded(body.title, 'task title', 300), criteria: bounded(body.criteria, 'acceptance criteria', 2000), projectTaskKind: body.projectTaskKind } + const fields = { title: bounded(body.title, 'task title', 300), criteria: bounded(body.criteria, 'acceptance criteria', 2000), projectTaskKind: body.projectTaskKind, + ...(body.impact !== undefined ? { impact: bounded(body.impact, 'Project impact', 2000) } : {}), + ...(body.scope !== undefined ? { scope: bounded(body.scope, 'Task scope', 2000) } : {}) } if (!['code', 'decision', 'release'].includes(fields.projectTaskKind)) throw refusal('An explicit impactful Task kind is required', 'bot/invalid') const requestKey = hash(`${origin.agentId}\n${body.projectId}\n${requestId}`) const existing = Object.values(state.projectAgentProposals ?? {}).find(row => row.requestKey === requestKey) diff --git a/core/src/host/workbench-state.js b/core/src/host/workbench-state.js index 770f335c6..c78fac471 100644 --- a/core/src/host/workbench-state.js +++ b/core/src/host/workbench-state.js @@ -24,7 +24,10 @@ export function install(rt) { let cpu = process.cpuUsage() let measured = process.hrtime.bigint() rt.workbenchEndpoint = async (endpoint, body, actor) => { - if (endpoint === 'auth-mode') return { workbench: rt.sharedProjectMode(), mode: rt.platformIdentityEnabled ? 'platform' : 'local', panelUrl: rt.config.controlPlane?.origin ?? null } + if (endpoint === 'auth-mode') { + await rt.load?.() + return { workbench: rt.sharedProjectMode(), mode: rt.platformIdentityEnabled ? 'platform' : 'local', panelUrl: rt.config.controlPlane?.origin ?? null, writable: rt.readOnly?.() === undefined } + } if (!rt.sharedProjectMode() || !actor?.memberId) throw Object.assign(new Error('Sign in to the Project workbench'), { code: 'bot/unauthorized' }) if (endpoint === 'workbench-state') { const projects = await rt.projectEndpoint('projects', {}, actor) diff --git a/core/test/dsh-pin.test.js b/core/test/dsh-pin.test.js index b492468a5..7c8a7efb6 100644 --- a/core/test/dsh-pin.test.js +++ b/core/test/dsh-pin.test.js @@ -45,7 +45,7 @@ test('the install help and the local CI install the pinned DSH', () => { }) test('every npx command in the READMEs runs the pinned DSH', () => { - for (const file of ['README.md', 'README.zh.md']) { + for (const file of ['README.native.md', 'README.native.zh.md']) { const text = read(file) const pins = [...text.matchAll(/@deepseek-ai\/dsh@([0-9][^\s"]*)/g)].map(match => match[1]) assert.ok(pins.length >= 4, `${file}: expected the four npx commands`) diff --git a/core/test/personal-agent-auth.test.js b/core/test/personal-agent-auth.test.js index f0d4d96a7..7ec2edee8 100644 --- a/core/test/personal-agent-auth.test.js +++ b/core/test/personal-agent-auth.test.js @@ -52,15 +52,18 @@ test('safe external agent creates only a private proposal; human approval publis const { rt, http } = await setup(t) const connection = await external(rt) assert.equal((await http(connection.token, 'task-create', { projectId: 'p1', botId: 'chief', title: 'Unauthorized direct task' })).ok, false) - const proposed = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', projectTaskKind: 'code', requestId: 'proposal-1' }) + const proposed = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', impact: 'Restore account access', scope: 'Login validation', projectTaskKind: 'code', requestId: 'proposal-1' }) assert.equal(proposed.ok, true, JSON.stringify(proposed.error)) + assert.equal(proposed.value.proposal.fields.impact, 'Restore account access') + assert.equal(proposed.value.proposal.fields.scope, 'Login validation') assert.equal(Object.keys(rt.state.tasks).length, 0) assert.equal((await http(connection.token, 'project-agent-proposal-apply', { projectId: 'p1', proposalId: proposed.value.proposal.id })).ok, false) const approved = await api(rt, 'project-agent-proposal-apply', { projectId: 'p1', proposalId: proposed.value.proposal.id }) assert.equal(approved.task.requesterId, 'alice') assert.equal(approved.task.personalAgentSource.agentId, connection.agent.id) assert.equal(rt.state.tasks[approved.task.id].personalAgentSource.agentId, connection.agent.id) - const replay = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', projectTaskKind: 'code', requestId: 'proposal-1' }) + assert.equal(rt.state.tasks[approved.task.id].projectTask.requirements.impact, 'Restore account access') + const replay = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', impact: 'Restore account access', scope: 'Login validation', projectTaskKind: 'code', requestId: 'proposal-1' }) assert.equal(replay.value.proposal.id, proposed.value.proposal.id) }) diff --git a/dev/demo/native-pools.mjs b/dev/demo/native-pools.mjs index ed0faddb4..b249ed354 100644 --- a/dev/demo/native-pools.mjs +++ b/dev/demo/native-pools.mjs @@ -50,8 +50,14 @@ export async function checkNativePools({ api, saved, waitFor, repository, model, assert.equal(requested.waitingFor, 'chief') assert.equal(Object.values(saved().projectAttempts).filter(row => row.taskId === task.id).length, 0) await call('project-chief-configure', { projectId: project.id, enabled: true, inspection: { enabled: false } }) - const started = await waitFor(() => Object.values(saved().projectAttempts).find(row => row.taskId === task.id), 'actual Chief admission before native pool allocation') + const started = await waitFor(() => { + const attempt = Object.values(saved().projectAttempts).find(row => row.taskId === task.id) + if (!attempt || attempt.status === 'preparing') return null + if (attempt.status === 'failed') throw new Error('Native pool Attempt failed before initialization; inspect scoped execution evidence') + return attempt + }, 'actual Chief admission before native pool allocation') assert.equal(started.protocol, 'project-handoff/v1') + assert.equal(started.status, 'running') await waitFor(() => { const current = saved().tasks[task.id]?.collaboration if (current?.status === 'blocked') throw new Error('Native pool blocked: ' + current.block?.reason) diff --git a/docs/architecture.md b/docs/architecture.md index 48080474b..6edce0c39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -203,3 +203,7 @@ The committed service read model now covers native execution and public role-gov ## Original capability compatibility (#2186) The [compatibility adapter](subsystems/project-compatibility.md) preserves original personal Chief/Bots/groups and Task/Workflow IDs. New Project work enters the existing Chief/Handoff controller; a committed one-time legacy baseline protects old work without treating arbitrary untyped rows as admitted. A physical JSON format fence survives SQLite storage rollback. + +## Persistent backend entry (#2189) + +The [backend launcher](subsystems/backend-service.md) composes the original DSH profile/host and restricted API/SSE gateway into one persistent deployment command. It preserves original Bots/UI/identity/execution; raw Harness credentials remain private. The CLI and current object/API documentation replace old snapshot-specific Bash helpers. No second scheduler or store. diff --git a/docs/backend-operations.md b/docs/backend-operations.md new file mode 100644 index 000000000..ceebe9d6f --- /dev/null +++ b/docs/backend-operations.md @@ -0,0 +1,110 @@ +# 后端配置与运维 + +[快速开始](../README.md) · [API](development-service-api.md) · [协作流程](development-service-guide.md) + +## 安装和数据目录 + +源码入口复用原 DSH web profile、FlyBot五个插件及 restricted gateway。一个启动命令管理原运行进程和现有共享HTTP/SSE入口;没有第二套任务事实、角色执行器或调度器。 +Node22.22+、npm、Git;首次 init 会下载依赖,使用仓库锁文件和与 Docker 相同的 pnpm10.34.6。 +本地 native/SDK 执行所需的系统工具、Git/SDK登录由操作者配置。源代码目录不可在服务运行中删除或切换分支。 + +所有命令可传 `--home /absolute/private-directory`,也可设置 `FLYBOT_HOME`;默认 `~/.flybot-backend`。 +同一服务的 init/start/login/调用必须使用同一 home。不要把本机验证目录或另一个实例的数据作为默认配置。 + +| 路径(相对home) | 用途 | +| --- | --- | +| backend.json / backend.patch.yml | 操作者配置及生成的DSH覆盖,0600;改backend.json后重启生效 | +| profiles/web | 原生DSH profile、插件链接和依赖;保留现有插件配置 | +| bot/state.json | SQLite迁移守卫,或显式JSON模式的状态文件 | +| bot/state.sqlite及配套文件 | 默认事务状态、历史、Effects、事件和私有认证记录 | +| bot下其余目录、DSH Sessions/凭据 | 原有会话、长期记忆、执行证据;备份时一并保留 | +| workspace | 原生DSH工作目录;项目代码工作区仍由原Workspace服务管理 | +| private/backend.log | 私有原始运行日志,可能含DSH登录链接,不要粘贴到Issue | +| private/backend-transport.json | 内部Harness连接;不对成员或公网提供 | +| private/backend-session.json | CLI当前成员会话;login不输出Token,logout撤销它 | + +## 配置端口和账号平台 + +默认可不写配置,先完成本机原生Owner初始化。自定义配置放在私有JSON文件: + +```json +{"port":3081,"bot":{"projectWorkbench":true,"stateStorage":"sqlite","projectDispatch":"pools"}} +``` + +```bash +node scripts/backend.mjs init --home /your/private/flybot --config /your/private/backend-config.json +node scripts/backend.mjs start --home /your/private/flybot +``` + +重复 init 保留 backend.json 和已有 profile 插件;使用 --config 明确替换配置。入口固定使用项目协作和任务池,新工作不因配置回退到DAG。 +修改 backend.json 后 start 重建覆盖;不直接改生成的 backend.patch.yml。 + +复用已有 control-plane 时,在 bot 内配置: + +```json +{"controlPlane":{"origin":"https://accounts.example.org","ownerSubject":"EXISTING_PLATFORM_OWNER_SUBJECT"}} +``` + +这是 bot 配置片段,不是完整配置文件。origin/ownerSubject必须由操作者明确提供,不能由首次登录猜测。 +账号平台需已可用;不内置演示账号、密码、数据库或修改平台的步骤。已有邮箱密码使用 login --email;平台暂停、注销和项目撤权在每次调用生效。 +平台会话的上游证明仅存内存,服务重启后需再次 login --email。无人值守还需原 controlPlane.principalCheckCommand 校验实际账号状态,不能拿旧浏览器会话永久授权。 +未配置平台时本机首次 login 仅在原生bootstrap仍开放时发行邀请;后续会话复用原认证。丢失会话且bootstrap关闭时使用现有Owner的邀请流程,不清空认证状态来重置Owner。 + +## 执行配置 + +先区分四项:Chief的模型、角色的Harness、可用Resource、仓库及GitHub授权。配置存在不代表已登录或能执行。 + +- Chief/原生Bot使用原DSH模型配置或真实供应商环境。不要让长期服务依赖当前终端里遗失的环境变量;托管时由私有EnvironmentFile等方式提供。 +- Codex资源使用已安装的锁定SDK及该Resource/成员专属 `profiles..codex`。配置其 home、configDir、model,使用原 runtime-check 和 terminal 接口完成登录。Claude使用 `claude-agent` 配置,不把“claude-code”误当实际Harness ID。 +- Resource需明确允许的Project Owner、Harness、容量、工作区根和管理人。角色可以选多个 resourceIds;实际执行只领取当前可用的一份租约。 +- `projectRepositories` 按 `owner/repo` 配置可访问的绝对 path、真实 testCommand、allowedPaths、模型及需要的凭据;项目须关联该仓库。代码任务缺少仓库/测试命令时阻塞,不造测试证据。 +- GitHub写入使用准确登录人的 gh-cli configDir/login,重验实际身份;不借用本机ambient gh登录或其他成员凭据。 +- Chief对角色的结构/约定变更须提出proposal,Maintainer/Owner用准确哈希批准;人类可在原配置入口直接维护角色执行设置。使用 roles/role.proposals 检查当前目录;执行快照与当前目录可能有不同revision,不能覆盖活动Session。 + +例如以下是**完整配置中的bot片段**,路径、模型、仓库、成员都要换成你的实际值: + +```json +{ + "projectRepositories":{ + "YOUR_ORG/YOUR_REPO":{"path":"/your/repositories/repo","model":"YOUR_CHIEF_MODEL","testCommand":["npm","test"],"allowedPaths":["src","test"]} + }, + "projectResources":{ + "local-dev":{"type":"local","root":"/your/execution-root","capacity":1,"allowedProjectOwnerIds":["owner-1"],"adminMemberIds":["owner-1"],"harnesses":["codex"],"profiles":{"owner-1":{"codex":{"home":"/your/private/sdk-home","configDir":"/your/private/codex-config","model":"YOUR_CODEX_MODEL"}}}} + } +} +``` + +真实项目可能使用平台映射成员ID,不要照抄 owner-1。完整角色/资源参数见[资源](subsystems/project-resources.md)、[Harness](subsystems/project-harness.md)、[执行配置](subsystems/project-runtime-setup.md)和[仓库策略](subsystems/project-workbench.md)。 +原生FlyBot执行可复用原宿主资源;将角色切换到SDK不会替你登录SDK,也不会自动授权仓库或发布/部署。 + +## 停止、重启、备份、升级 + +前台服务用Ctrl-C/SIGTERM正常停止;不要按猜测的PID杀进程或删除锁/租约。需要常驻时交给现有systemd/容器管理,ExecStart指向同一源码和home,工作目录固定到源码。启动器有原生home锁和45秒启动诊断;持续巡检仍是Chief的定时任务配置。 +同一home只运行一个实例。新启动失败不会停止占用端口的其他服务。 + +备份先正常停止该实例,私下备份**完整home**以及独立配置的仓库工作区、SDK profile和外部凭据。SQLite不能只复制运行中的主文件而丢掉WAL;原Store迁移工具会要求独占锁。 +升级先记录源码SHA、备份,停止原服务,检出目标代码并运行 init,再 start 同一home;检查 compatibility、pools和execution.boundary,确认恢复中的未知退出仍保持阻塞。 +健康活动任务应等待完成或在受支持的检查点停止,不强制重开Session。当前本机普通退出/重启验证不等于所有监督器崩溃恢复都通过。 + +```bash +node core/scripts/state-storage.mjs inspect --home /your/private/flybot/bot +node core/scripts/state-storage.mjs export --home /your/private/flybot/bot --migration MIGRATION_ID --output /your/private/export.json +node core/scripts/state-storage.mjs rollback --home /your/private/flybot/bot --migration MIGRATION_ID +``` + +export/rollback在停服后执行,使用inspect得到的准确ID;中断回退需显式 --resume。回到JSON后把backend.json的bot.stateStorage改为json再启动。 +JSON/SQLite回退保留新代码格式守卫、ID和已完成Effects,**不是旧二进制降级许可**;不得抹掉守卫或重置回执来重跑外部动作。更多见[存储](subsystems/project-state-storage.md)及[兼容](subsystems/project-compatibility.md)。 + +## 网络与排障 + +默认服务只监听回环;需要共享网址时使用自己的HTTPS反代,转发到输出的服务端口。 +配置顶层 `publicOrigin: "https://flybot.example.org"` 后保留该Host/Origin;SSE关闭代理缓冲并允许长连接。不要代理原始Harness端口、DSH Owner cookie或整个private目录。 +这不要求control-plane代码变更;浏览器与API使用同一个服务地址。 + +- 启动失败:只在本机检查private/backend.log;排查端口占用、依赖、权限、模型配置,修复后重试。不要把原始日志发到公开讨论。 +- `awaiting-chief`:查chief.state、Chief启用/模型/原发起人权限;这不是Dev执行中。 +- `queued/blocked`:查pools、execution.boundary、资源runtime-check与Effects;未知worker退出不能手工当作资源已释放。 +- `bot/conflict`:重读代次/候选/快照;相同request-id只重试同一意图,unknown先查command.receipt与实际对象。 +- 上下文接近预算:用限定来源分页/摘要/长期记忆引用;同Task打回恢复原角色Session,不用“重新开一个”绕过限制。 + +[测试分层](testing-layers.md)区分单元、真实DSH/本机HTTP持久化、真实SDK与完整模型开发闭环。确定性测试供应商只出现在测试里,启动工具不创建mock模型或样例仓库。 diff --git a/docs/development-service-api.md b/docs/development-service-api.md index bed6e66d2..94dac249d 100644 --- a/docs/development-service-api.md +++ b/docs/development-service-api.md @@ -1,623 +1,218 @@ -# 自动开发服务 API 参考 +# 后端 API 参考 -适用:`delivery/ai-centric-development-service` 分支,业务源码基线 `1811e3b8f`。 -覆盖本版本全部 **12 个 v1 查询、21 个 v1 命令**,以及完成登录、项目选择、 -任务创建和个人代理交互所需的原生接口。不是全部 FlyBot/DSH 管理接口的目录。 +[快速开始](../README.md) · [协作语义](development-service-guide.md) · [部署](backend-operations.md) -[快速启动与登录](../README.md) · [操作流程与状态模型](development-service-guide.md) · -[内部服务契约](subsystems/project-development-service.md) +本页对应当前分支实际路由:**24个v1查询、26个v1命令**,以及对象管理使用的原生接口。 +咨询和Task主动权交接是两种契约;角色执行回调不是公开HTTP命令。没有Swagger页面;以下请求均可由CLI或HTTP调用。 -## 1. 传输、登录和请求信封 +## 传输、身份与统一返回 -当前共享入口:`POST http://127.0.0.1:18080/api/bot`。 -其他部署使用自己的网关地址。所有请求使用 `Content-Type: application/json`。 -登录后的请求携带 `X-Flybot-Member-Session: `;调用不依赖浏览器。 -README 中的 `fb_api`、`fb_dev` 是 Bash 调用函数,不是另行安装的 CLI。 +共享服务:`POST /api/bot`,`Content-Type: application/json`。 +登录后携带 `X-Flybot-Member-Session: `。 +请求不能提供可信memberId、Chief身份、claim、权限或进程退出证明。 +共享网关保留DSH Owner凭据在服务内部;不向调用方开放原始Harness API。 -### 登录与注销 - -原生请求(不包装为 v1): - -```json -{ - "endpoint": "platform-login", - "payload": {"email": "YOUR_EMAIL", "password": "YOUR_PASSWORD"} -} -``` - -成功时从 `value.sessionToken` 取得会话;密码和 Token 不应写入仓库。 -注销请求是 `{"endpoint":"auth-revoke","payload":{}}`,带本次会话的请求头。 -它撤销本次 Session,不是停止整个后端,也不等于撤销独立的个人代理值守授权。 - -### v1 查询示例 - -以下 ID 均为占位符,应替换为服务实际返回的值。 - -```json -{ - "endpoint": "project-development", - "payload": { - "version": "project-development/v1", - "kind": "query", - "name": "tasks", - "projectId": "PROJECT_ID", - "payload": {"limit": 20} - } -} -``` - -### v1 命令示例 - -```json -{ - "endpoint": "project-development", - "payload": { - "version": "project-development/v1", - "kind": "command", - "name": "task.run", - "projectId": "PROJECT_ID", - "requestId": "run-example-001", - "payload": {"taskId": "TASK_ID"} - } -} -``` - -| 信封字段 | 类型与要求 | -| --- | --- | -| `version` | 必填字符串,固定为 `project-development/v1` | -| `kind` | 必填,`query` 或 `command`,必须与操作对应 | -| `name` | 必填,见后续完整索引 | -| `projectId` | 必填,当前用户必须有该 Project 权限 | -| `payload` | 对象,缺省为 `{}`;下文的接口参数均指这一层 | -| `requestId` | 命令必填;1–100 字符,只允许字母、数字、`_`、`-` | -| `expectedGeneration` | 可选非负整数;校验目标 Task 当前协作代次 | -| `expectedCandidateSha` | 可选,完整 40 位小写 SHA 或 `null`;校验当前候选 | - -两个 `expected*` 字段位于信封层。使用它们时需要能通过 taskId/attemptId 等解析到 Task。 -内部 payload 的 projectId 若存在,必须与信封一致。任务、Attempt、队列、proposal、worker、 -Effect 等引用不能借助一个信封跨 Project 或指向互不相关的 Task。 -调用者身份只从认证得到,body 中填写 memberId、Owner 或 Bot 名称不会获得权限。 - -### 响应与异步操作 - -```json -{ - "ok": true, - "value": { - "version": "project-development/v1", - "result": {"tasks": [], "next": null} - } -} -``` - -命令的 v1 `value` 还会返回外层 `requestId`。原生接口的 `value` 直接是业务结果, -没有自动添加 v1 包装。业务失败可能仍使用 HTTP 200: - -```json -{ - "ok": false, - "error": {"code": "bot/forbidden", "message": "Project access denied: configure"} -} -``` - -先检查 HTTP 传输,再检查 `ok`。`already:true` 是该操作的重复处理结果,不是任务已完成。 -启动、巡检、输入提交、Effect 重试的成功响应只是接受/安排处理;通过查询继续跟踪。 -`requestId` 不会让所有命令自动具备相同的幂等语义,具体见各操作说明。 - -## 2. 权限约定 - -| 下文权限名 | 当前共享 Project 策略 | -| --- | --- | -| `read` | 当前 Project 的 Owner、Maintainer、Member | -| `create` | 活跃 Project 的成员;归档后不能创建新工作 | -| `task-write` | Owner、Maintainer,或该 Task 的原发起人 | -| `configure` | Owner、Maintainer | -| `run-control` | Owner、Maintainer | -| `accept` | Owner、Maintainer;仍须通过全部候选证据门禁 | -| manager | Owner 或 Maintainer;清理等操作还有资源释放检查 | -| own | 当前用户自己的代理、会话、提案、凭据 | - -表中是基础权限;还会检查活跃项目、实时账号状态、授权代次、具体任务与资源。 -可调用 `execution.boundary` 并携带 taskId 查看当前身份下的权限元数据。 -AI 角色名不赋予人类权限。值守代理也不能超越其授权用户或跨出该 Project。 - -## 3. 全部查询(12 个) - -本节均使用 `kind:query`,无需 requestId。`*` 表示必填;参数均为 payload 字段。 - -| 操作 | 参数 | `result` 主要字段 | 权限及备注 | -| --- | --- | --- | --- | -| `tasks` | `limit?:integer=50`,1–200;`after?:taskId` | `tasks:TaskView[]`, `next` | read;按创建时间、ID 升序;游标必须属于本项目 | -| `task` | `taskId*:string` | `task:TaskView` | read;用于轻量轮询 | -| `task.detail` | `taskId*`;`attemptLimit?:integer=20`,1–50;`beforeAttemptId?` | 原始 `task`、`attempts` 摘要、派生 `view`、`attemptsPage` | read;默认最新一页,before 游标向历史翻页 | -| `pools` | 无 | `pools:[{botId,role,tasks}]` | read;显示可领取的 queued/arbitration 任务,不是全部执行进程列表 | -| `chief.state` | 无 | `policy`, `template`, `schedule`, `runs` | read;返回最近 Chief 协调 Run 与巡检配置,不会自行启动巡检 | -| `inputs` | `taskId*`;`limit?:integer=50`,1–200;`after?:inputId` | `inputs`, `next` | read;包含输入文本、来源与传递/读取/处理回执 | -| `sources` | `limit?:integer=50`,1–100;`threadId?`;`after?:eventId`;或 `eventId?`,`offset?:integer=0` | 列表形态:`sources`,`threads`,`subscriptions`,`events`,`next`;详情形态见下文 | read;项目必须有合法登记仓库 | -| `effects` | `taskId?`;`limit?:integer=50`,1–200 | `effects` | read;按更新时间倒序;不返回内部 payload/history/key,没有分页游标 | -| `execution.boundary` | `taskId?` | 见下文边界元数据 | read;重新校验身份,不启动 SDK 或测试 | -| `attempt.context` | `attemptId*`,`section*`;`offset?:integer=0`;`limit?:integer=8000`,1–16000;`expectedSha256?` | `attemptId`,`section`,`sha256`,`offset`,`text`,`totalChars`,`nextOffset` | read;section 仅 roleSessions / roleSessionHistory / rolePolicies | -| `attempt.log` | `attemptId*`;`role?:string` | `text`,`sha`,`sha256`;无日志时 `text:null,reason` | read;缺省读宿主测试日志,role 指定已有角色执行日志;会核对哈希,不分页 | -| `attempt.integration-state` | `attemptId*` | `attemptId`,`generation`,`inputs`,`snapshot` | configure;仅当前池协议 Attempt,须确认执行退出;不是忙碌时可随意读取的普通监控接口 | - -### 查询结果细节 - -`task.detail.attemptsPage` 包含 `total`、`limit`、`before`;下一次请求将 before 放入 -`beforeAttemptId`。摘要不重复发送完整角色上下文,使用 `contextRef` 指定的分页查询。 -`attempt.context` 应按 `nextOffset` 继续,后续页带首次的 `expectedSha256`;变化则重读。 -其 `text` 是原始记录 JSON 文本的片段,不一定单页就能解析成完整 JSON。 - -`sources` 的 `eventId` 详情模式返回 `event` 元数据、`text`、`offset`、`nextOffset`、`sha256`; -每页最多 16000 字符。列表模式的 limit 约束事件页,不限制所有 threads/subscriptions 数量。 - -`execution.boundary` 返回 `version,projectId,memberId,profile,host,permissions,taskScope,delegation,resources,credentials,preflight,memory,invariants,evidence`。 -凭据仅为配置状态/选定身份等元数据;测试、SDK 运行状态在此处标注为未执行,不得据此认定环境已跑通。 - -`attempt.integration-state.snapshot` 若已有工作区,包含 `workspaceId,head,mergeHead,indexSha256,statusSha256`; -尚未分配时可以为 null。不要绕过“等待退出”错误去直接修改 Git index 或 state.json。 - -### TaskView 字段 - -v1响应中的 `value.result.tasks[]` 和 `value.result.task` 使用同一种 TaskView。 -字段从当前持久记录及实际活动派生;不要把原Task的status字段直接当成这里的phase。 - -| 字段 | 类型/含义 | -| --- | --- | -| `version` | 固定 project-development/v1 | -| `projectId`, `taskId` | 字符串,稳定归属与身份;ID按不透明标识处理 | -| `publication` | 当前已发布任务视图为 published;未发布私人提案不在这里 | -| `phase` | 派生阶段;完整枚举与解释见操作指南 | -| `waitingFor`, `reason` | 字符串或null,当前等待对象与原因;reason不是可执行指令 | -| `botId`, `currentRole` | 当前逻辑Bot及chief/developer/tester/reviewer角色,未知时null | -| `generation` | 当前协作代次或null;乐观并发控制用此值,不是worker代次 | -| `attemptId` | 当前开发尝试ID或null;返工可变化,Task身份不变 | -| `executionId` | 当前claim或Run ID,可能null;不是SDK thread ID | -| `workerId` | 当前claim绑定的worker,可能null;有ID不等于仍在运行 | -| `queueId` | 当前关联准入队列或null | -| `candidateSha` | 完整40位候选Git SHA或null;不是main当前HEAD | -| `executionGeneration` | 关联SDK worker代次或null,用于execution.input | -| `inputRequestId` | 当前SDK待回答问题ID或null,不是普通Task补充输入ID | -| `lastMeaningfulProgress` | null或 `{at,evidence}`;依据可验证候选/宿主测试,不以消息数量当进展 | -| `evidence.tests` | null或 `{sha,sha256,passed,current}`;current指是否匹配本候选 | -| `evidence.review` | null或 `{sha,verdict,current}`;不是仓库合并授权 | -| `inputs` | 最多20条accepted非handoff输入摘要,含id/sha256/createdAt/deliveries/readConfirmed;完整状态查inputs分页 | -| `effects` | 当前pending/running/failed副作用摘要,含id/kind/status/failures/dueAt/error/waitingFor;完整可见记录查effects | -| `nextActions` | 当前用户可尝试的操作名数组;之后仍需实时重新校验 | - -时间戳如createdAt/at/dueAt通常为Unix毫秒;业务SHA与日志/正文SHA256用途不同,不能互换。 -`readConfirmed`是当前角色的已读声明,不是独立的理解正确性证明。单个查询是快照,下一次操作前状态可能变化。 - -## 4. 全部命令(21 个) - -每项均另需第 1 节的外层 requestId。下列 JSON 是内部 payload 示例,不是完整 HTTP 请求。 -写操作示例不要整段盲跑到正在开发的 Task 上。这里没有“修改 phase”的接口。 - -### C01 `task.run` - -```json -{"taskId":"TASK_ID"} -``` - -权限:create + 该 Task 的 task-write。结果为 `{attempt,already?}` 或 `{queue,already?}`。 -资源/配额不足时排队,排队不会预先分配新工作区。相同 Task/requestId 复用已有 Attempt/队列; -已有活跃 Attempt 时不能靠换 requestId 并行重跑。Task 发布本身不等于启动。 - -### C02 `task.accept` - -```json -{"attemptId":"ATTEMPT_ID"} -``` - -权限:accept。结果 `{attempt,already?}`。需要 waiting-acceptance、当前且完整的候选证据、 -已处理的输入、同 SHA 宿主测试/Review、仍匹配的开放 PR 等;池协议还需要 Chief 批准。 -成功推进 Task/Attempt 验收状态,不合并 PR、不部署代码。已 accepted 可返回 already。 - -### C03 `task.reject` - -```json -{"attemptId":"ATTEMPT_ID","reason":"验收中发现具体问题"} -``` - -权限:accept。`reason?:string`,缺省为 Human requested changes,保存最多1000字符。 -仅 waiting-acceptance;结果 `{attempt}`,记录返回人并置为失败/待后续处理。 -该接口本身不等于已经启动下一轮;通过后续合法调度/Chief 决定恢复。 - -### C04 `attempt.stop` - -```json -{"attemptId":"ATTEMPT_ID"} -``` - -权限:run-control。结果 `{attempt,already?}` 或 `{attempt,stopping:true,remainingWorkers}`。 -先保存停止意图,再停止实际执行;仍有未确认退出的 worker/租约时不会假装容量已释放。 -终态重复停止可 already。查询 TaskView 的 stopping 与工作区释放情况,不靠 HTTP 成功判断停止完毕。 - -### C05 `attempt.cleanup` - -```json -{"attemptId":"ATTEMPT_ID"} -``` - -权限:manager。结果 `{attempt}`。清理服务所拥有的终止工作区;活跃 Attempt、等待验收、 -残留 worker/租约会拒绝。其他 Attempt 仍复用的目录不会被误删。它不是取消任务,也不是清除审计历史。 - -### C06 `attempt.publication-retry` - -```json -{"attemptId":"ATTEMPT_ID"} -``` - -权限:configure。结果 `{attempt,already}`。只恢复当前活跃 Attempt 的既有 PR 发布绑定, -要求已确认角色退出、同候选测试/Review/执行日志完整,需求未变化。 -不能给任意 SHA“补一个成功发布”,也不能用它绕过 PR HEAD 不一致;远端事实仍须重新核对。 -requestId 不代表强制重复外部发布;底层 outbox 和原 PR 绑定负责协调。 - -### C07 `attempt.integration-bind` - -```json -{ - "attemptId":"ATTEMPT_ID", - "generation":7, - "inputs":[{"ref":"refs/remotes/operator/backend","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], - "expectedWorkspace":null -} -``` - -权限:configure。必填 generation、1–4 个唯一 ref/SHA、expectedWorkspace。 -ref 只允许 `refs/remotes/operator/...` 下经操作者提供的来源,SHA 必须是完整小写40位且引用仍匹配。 -已有工作区时必须原样传入刚查询到的 snapshot,不能用 null;该示例仅适用于尚无工作区的情况。 - -仅当前未封存、未取消的 running/interrupted 池协议 Attempt;执行、Chief、租约必须已确认释放。 -已有准入不可静默替换或移除。结果 `{admission,already}`,admission 包含输入、快照、操作者、 -持久化 Effect 和前后 generation。相同 requestId 内容必须完全一致。 -这个操作只登记可信输入,不修改工作区、不执行 merge、不启动 worker。 - -### C08 `queue.cancel` +原生请求用于登录、Project/PersonalAgent等对象管理: ```json -{"queueId":"QUEUE_ID"} +{"endpoint":"project-read","payload":{"id":"PROJECT_ID"}} ``` -权限:task-write。仅 waiting/blocked 队列;结果 `{queue}`,变为 cancelled,不撤销已启动的 Attempt。 -重复取消终止队列会拒绝,不是通用幂等命令。 - -### C09 `queue.retry` - -```json -{"queueId":"QUEUE_ID"} -``` - -权限:run-control。仅 blocked 队列,结果 `{queue}`,重新变为 waiting。 -同时重新检查原发起人的权限/账号及仓库;不会把执行身份偷换为重试操作的管理员。 -已有活跃 Attempt 或原任务结束则拒绝。 - -### C10 `chief.inspect` - -```json -{"taskId":"TASK_ID"} -``` - -权限:configure。taskId 可省略,以检查项目范围;结果 `{run}`。 -外层 requestId 用于该巡检请求的复用。它请求一个 Chief Run,不等于同步完成裁定。 -查询 chief.state、TaskView 及对应 Run 追踪结果。只有后端专用协议能提交 Chief 的裁定。 - -### C11 `chief.configure` - -```json -{ - "enabled":true, - "inspection":{ - "enabled":true, - "when":{"kind":"every","seconds":1800}, - "thresholds":{"maxHandoffsWithoutProgress":4,"longExecutionMs":2700000,"missingHeartbeatMs":1200000,"longWaitMs":3600000} - } -} -``` - -权限:configure;启用前校验可持续使用的当前人类身份。可选 inspection 与现有配置/模板合并, -支持 title、prompt、when、enabled、thresholds;阈值需正整数。 -enabled:false 关闭项目仲裁授权;inspection.enabled:false 可单独关闭巡检。 -结果同 chief.state。更改会提升 policy revision,并可能重新绑定授权人和原生定时任务; -不能当作无副作用轮询,也不是按 requestId 去重的统一配置事务。 - -### C12 `schedule.configure` - -保存示例: - -```json -{ - "operation":"save", - "target":{"kind":"project-source","threadId":"THREAD_ID"}, - "title":"PR feedback polling", - "prompt":"Poll the configured PR thread", - "when":{"kind":"every","seconds":120}, - "enabled":true -} -``` - -权限:configure。save 支持可选 `id` 更新原逻辑定时任务;新建需要 target、title、prompt、when。 -当前注册 target.kind 为 project-chief / project-source;项目从信封绑定,更新不能换目标。 -title 非空最多120码点,prompt 非空最多16000长度,enabled 为布尔,when 由原生调度器校验。 -保存返回逻辑定时任务记录(id、revision、status、config、原生调度映射等)。 - -`{"operation":"delete","id":"LOGICAL_SCHEDULE_ID"}` 删除映射并返回 `{tasks}`; -`{"operation":"list"}` 返回 `{tasks}`。这两个也使用 command 信封并要求 requestId。 -重复 save 无 id 会创建新逻辑定时任务;编辑要保留已有 id。正常 Chief 巡检优先通过 chief.configure 管理。 - -### C13 `agent.mode` - -```json -{"mode":"safe"} -``` - -权限:当前已登录的人类,Project read;mode 必填 `safe` 或 `watch`。 -返回 `{mode,grant,already?}`。safe 默认要求发布提案前人工确认;watch 创建可撤销的当前用户/Project 持续授权。 -watch 在退出浏览器后可继续,但必须配置并通过实时账号校验,且不能超出用户的当前 Project 权限。 -同一 active grant 重复启用可 already;safe 撤销授权,不抹去已经发布任务的历史。 - -### C14 `proposal.publish` - -```json -{"proposalId":"PROPOSAL_ID"} -``` - -权限:own + create。只发布自己的已展示私人提案;返回 `{task,already?}`。 -同一 proposalId 重复发布返回原 task;不自动启动执行,也不会公开整个私人对话。 - -### C15 `execution.input` - -普通回答: - -```json -{"workerId":"WORKER_ID","generation":1,"text":"对当前问题的回答"} -``` - -权限:task-write;仅当前真实 waiting-input worker。generation 是 **worker 的代次**, -不是 Task 协作代次;返回 `{sent:true}`,恢复该 worker 的输入处理。 -text 非空且不超过8000长度。若 pending.kind 是 permission,还需要 configure,使用 -`{"workerId":"WORKER_ID","generation":1,"requestId":"PENDING_QUESTION_ID","allow":true}`。 -内部 requestId 必须匹配待回答权限问题;外层另提供传输 command 的稳定 requestId。 -这不是给任意繁忙 worker 直接注入文本的接口;一般追加需求使用 input.publish。 - -### C16 `input.publish` +v1请求用于项目协作命令、查询和恢复: ```json -{"taskId":"TASK_ID","text":"具体的补充反馈"} +{"endpoint":"project-development","payload":{"version":"project-development/v1","kind":"command","name":"task.propose","projectId":"PROJECT_ID","requestId":"change-1","payload":{"title":"修复计算错误","criteria":"复现用例与回归通过","impact":"恢复项目计算功能"}}} ``` -权限:task-write + active Project。text 必填,去空白后非空且原长度不超过4000; -终止/关闭/正在停止的任务拒绝。不能提交伪造的 source、recipient、claim。 -返回 `{input,already}`。同一发起人/requestId/Task 的输入内容不可变,内容变化必须使用新 ID。 -接收后持久化 input.notify;正在忙碌的 Bot 在安全的后续轮次取得输入,不另开并行 writer。 +所有命令需要信封层稳定 `requestId`,匹配 `[A-Za-z0-9_-]{1,100}`。 +query不需要requestId。projectId必填,payload必须是对象且不能指向另一个Project。 +可选 `expectedGeneration` / `expectedCandidateSha` 放在v1信封层,校验当前Task代次/候选;不是绕过下游权限的凭据。 +具体对象ID应使用上次响应值,不使用示例常量作为真实ID。 -### C17 `source.subscribe` +HTTP成功响应为 `{ok:true,value:...}`;v1 value为 `{version,requestId,result,receipt?,replayed?}`。 +CLI只输出value,所以下列查询的业务结果位于 `result`。 ```json -{"taskId":"TASK_ID","threadKind":"pull","number":48,"mode":"active","includeHistory":false} +{"ok":true,"value":{"version":"project-development/v1","result":{"task":{"taskId":"TASK_ID","projectId":"PROJECT_ID","phase":"awaiting-chief","waitingFor":"admission"}}}} ``` -权限:configure + task-write。threadKind 为 issue/pull,number 为正整数; -mode 可选 active/context(默认 active),includeHistory 可选布尔(默认 false)。 -repository 若填写必须等于当前 Project 仓库。 -结果 `{subscription,already}`;重复由 Thread+Task 等业务身份决定,已有配置变化需要显式协调。 -同一 Thread 最多一个 actionable active Task,其他任务可 context 跟踪。 -默认旧讨论只作上下文;订阅不等于已配置轮询,另使用原生 schedule.configure。 - -### C18 `source.disable` +这是响应结构示意,不是任务已真实执行的证据。错误为 `{ok:false,error:{code,message,...}}`; +HTTP200也可能是领域拒绝,必须检查ok。网关/事件还会使用HTTP400/401/403/409/429/503。 -```json -{"subscriptionId":"SUBSCRIPTION_ID"} -``` +## 权限与重试 -权限:configure。结果 `{subscription,already}`,重复关闭可 already。 -关闭的是该订阅,不是删除整个 Thread、Task 或历史输入;不会撤销其他订阅。 +表中:`读`=当前项目成员(含已授权且scope匹配的个人代理);`写`=Owner/Maintainer或有权操作本人Task的成员; +`管`=Owner/Maintainer;`本人`=认证用户或其明确允许的个人代理。私有咨询/命令回执还检查独立代理/当前用户身份,不因同属项目而共享。 +安全模式的个人代理不能直接发布共享Task;值守模式与当前用户权限、参与状态和token scopes取交集。 +角色名、公开Bot、网页按钮都不是权限。关闭Project后保留授权读取,拒绝新工作/配置修改。 -### C19 `source.poll` - -```json -{"threadId":"THREAD_ID"} -``` +同一主体、Project、命令名和requestId标识同一意图,payload必须一致。 +响应丢失时重试原请求;内容变化用新意图ID。不能通过换ID绕过未知外部结果。 +receipt区分completed、running、unknown、completion-pending-commit;unknown先查command.receipt/实际Task/Chief,绝不盲目重发外部动作。 +持久Effects的提交、入池、接手、执行和Chief关闭是不同事实;receiving SSE不会获得执行权限。 -权限:configure + 当前后台身份验证。结果 `{threadId,acceptedEvents,inputs,cursor}`。 -读取已登记 Thread,持久化新观察和原任务输入;返回数量不代表模型读过或处理完成。 -通过游标/事件版本去重,requestId 不会强制重放所有历史。接口不是 webhook,也不接收外部伪造的 GitHub 事件。 +## User / PersonalAgent(原生对象接口) -### C20 `effect.retry` +这些请求不包v1信封,CLI使用 `request ENDPOINT --json JSON`。 +所有“本人”由认证派生,不接受body指定另一个Owner。 -```json -{"effectId":"EFFECT_ID"} -``` +| 原生endpoint | payload主要字段 / 返回 | 主体与边界 | +| --- | --- | --- | +| platform-login | email:string、password:string → sessionToken/memberId | 已配置账号平台;CLI login --email隐藏输入和返回Token | +| auth-whoami / auth-revoke | {} → 当前身份 / 撤销结果 | 本次会话;注销不等于停止Task | +| auth-invite / auth-redeem | 邀请{} / 兑换{token,displayName?} → 邀请/成员会话 | 原生认证;首次邀请仅私有操作者入口,后续需已登录邀请人 | +| personal-agent-create | name、kind:internal或external;外部另需provider、HTTPS endpoint → agent | 本人;外部descriptor初始未验证,不声称已接入特定产品 | +| personal-agent-list / personal-agent-read | limit?:1..200、after? / agentId → 本人身份列表/身份 | 本人;不公开私有carrier历史 | +| personal-agent-join / personal-agent-default | agentId、projectId → participation | 本人且当前项目成员;默认路由不授予权限 | +| personal-agent-participations | limit?:1..200、after? → 本人的参与列表 | 本人;独立参与ID | +| personal-agent-history | agentId、projectId、offset?、limit?:1..100 → 私有历史页和nextOffset | 本人;历史保留原ID | +| personal-agent-propose-task | projectId、title、criteria、projectTaskKind、requestId;impact?/scope? → 私有proposal | FBA连接;等待人类授权,显式影响/范围保留到原Task | +| personal-agent-leave / personal-agent-retire | agentId,leave另需projectId → 状态 | 撤销对应连接/委托能力,保留历史 | +| personal-agent-token-create | agentId、projectIds:string[]、ttlSeconds?:60..2592000 → 一次性原始token及metadata | 本人;存储只留hash,token限当前活跃参与Project | +| personal-agent-token-list / personal-agent-token-revoke | agentId / credentialId → metadata/撤销 | 本人;不重新返回原始token | +| personal-agent-delegation-set | agentId、projectId、mode:safe/watch | 本人;见[身份契约](subsystems/personal-agent-auth.md) | +| personal-space | operation:list/read/send/memory等、id及对应参数 | 原个人Chief/Bot/群聊入口;原ACL和私有记忆隔离 | + +外部调用方持FBA token调用同一HTTP协议,不能把外部URL当作已验证身份或新的Task执行器。 +SDK角色worker使用宿主内部绑定,不可拿“Dev”字符串调用角色私有写接口。 +详细原生字段/期限/撤销规则见[独立代理](subsystems/personal-agents.md)、[代理授权](subsystems/personal-agent-auth.md)、[安全与值守](subsystems/project-delegations.md)。 + +## Project / Role(原生管理 + v1治理) + +| 原生endpoint | payload与返回概要 | 权限/前置 | +| --- | --- | --- | +| projects / project-read / project-detail | {} / id / id、view:summary? → 当前成员可见项目/详情 | 读;summary不复制完整Task历史 | +| project-create | name必填;goal/scope/repository/repositories/defaultBranch等可选 → project | 当前用户;仓库关联0..N,仓库也可被多项目关联 | +| project-repositories | projectId → repositories | 读 | +| project-repository-link / project-repository-unlink | projectId、repository,link可含defaultBranch → 关联结果 | 管;不悄悄改动已有Task执行仓库 | +| project-members / project-invite / project-join | id / id / token → 成员/邀请/加入 | 读 / 邀请权限 / 当前认证受邀人 | +| project-role / project-member-remove / project-leave | id及memberId/role等 → 成员状态 | 原角色策略;Owner不可把自己移走留下无主项目 | +| project-close-request | projectId、reason等 → 建议记录 | Maintainer/个人代理建议;不关闭项目 | +| project-close-prepare / project-close-confirm | projectId、prepare另需finalRecord;confirm带prepare返回的confirmationToken | Owner本人二次确认,限时挑战和当前状态重验;保留未完Task及最终记录 | +| project-agents / project-agent-history | projectId、对应agent/分页 → 公开Chief及本人私有代理/历史 | 读;私有信息仅本人 | +| project-agent-definition / project-role-memory-write | projectId、定义/记忆字段 → 版本化目录/记忆 | 管理及原治理校验;不得覆盖历史执行快照 | +| project-room-create/read/message等 | projectId、roomId、text/文档等 → 原Project群聊 | 项目成员且符合房间参与权限;普通消息不自动发布Task | + +角色治理、上下文及public Bot人设/记忆仍走原服务;完整字段和治理提案源见[角色/代理](subsystems/project-agents.md)、[Project](subsystems/project-domain.md)及[房间](subsystems/project-collaboration.md)。 + +## v1查询(kind=query) + +下表payload之外统一需要projectId。`?`表示可选。字段均为JSON;ID/游标是响应返回的string。 +所有查询确定性读取现有事实,不调用模型更新项目定位。大内容通过限定源分页,不塞进列表。 + +| 查询名 | payload | result / 权限与分页 | +| --- | --- | --- | +| tasks | limit?:1..200、after?:TaskID | TaskView列表及游标;读 | +| task | taskId | 单个TaskView;读 | +| task.detail | taskId、attemptLimit?:1..50、beforeAttemptId? | 有界Task详情、Attempt摘要与历史来源引用;读 | +| task.history | taskId、section、offset?、limit?、expectedSha256? | 精确源字符串页、SHA256和nextOffset;section为requirements/requirementsHistory/waitHistory/results/dependencies/admissionDecisions/note/result/actors/handoffs | +| roles | role?、offset?、limit?:1..16000、expectedSha256? | 当前已批准角色目录及revision;读,不初始化目录 | +| role.proposals | proposalId?、limit?:1..50、after? | 公共角色变更提案及有界来源;不返回个人代理私有提案 | +| consultation.list | agentId?、limit?:1..100、after? | 本人当前参与的私有咨询页 | +| consultation.get | consultationId、agentId? | 私有咨询/答案/状态;从不转移Task主动权 | +| handoff.list | taskId、limit?:1..100、after? | 同Task的持久交接记录页;读 | +| handoff.get | taskId、handoffId | 原交接契约与receipt:来源/目标/上下文版本/确认等;读 | +| pools | {} | 每个逻辑Bot的任务池和现有claim/阻塞状态;读 | +| inputs | taskId、limit?:1..200、after? | Task持久输入/投递和确认状态;读 | +| sources | 原source查询过滤/分页 | 当前项目绑定的Issue/PR来源订阅与游标;读 | +| execution.boundary | taskId? | 当前权限/仓库/资源/Harness边界及readiness;不运行测试/不授予权限 | +| attempt.context | attemptId、section、offset?、limit?:1..16000、expectedSha256? | roleSessions/roleSessionHistory/rolePolicies/evidence/feedback/requirement/failure/agentDefinitions/integrationAdmissions精确页;读 | +| attempt.integration-state | attemptId | 当前候选/受信集成绑定和证据;读 | +| attempt.log | attemptId、role? | 限定Attempt的原封存日志完整文本(当前未分页);读,不给原始宿主文件读取权限 | +| chief.state | {} | 当前Chief策略、原定时任务、待裁定/活跃控制工作;读 | +| effects | taskId?、limit?:1..200 | 已提交意图/执行/等待/失败摘要;私有command结果限当前主体 | +| command.receipt | commandRequestId、commandName? | 当前独立主体的幂等回执/commit状态/结果引用 | +| command.result | commandRequestId、commandName?、offset?、limit?:1..16000、expectedSha256? | 精确原JSON结果页;同主体、hash保护 | +| project.snapshot | entityType?:task/attempt/execution/resource/input/role-proposal、after?、limit?:1..100、expectedRevision? | 已提交Project版本、cursor及实体页;默认Task50项 | +| events | after?、limit?:1..100 | 当前Project已提交变更页、next cursor;默认100项 | +| compatibility | offset?、limit?:1..100 | 旧工作baseline计数/hash/提交状态,当前contract/legacy/held/active计数和引用页;默认50项 | + +字符串源页offset按字符而非UTF-8字节;把返回SHA256带入下一页,变更则重新读源。Task/Attempt列表是摘要,不能用preview代替验收证据。attempt.log目前返回原封存完整文本,受网关响应上限约束;不要把它当成已实现分页的通用上下文读取。 +TaskView.phase包括published/awaiting-chief/queued/starting/running/awaiting-input/blocked/stopping/accepted/cancelled等;waitingFor/reason解释等待对象。 +Worker已登记不等于已运行;进程或Bot消失不等于退出;角色目录当前revision与执行固定revision分别展示。 + +## v1命令(kind=command) + +每个命令统一带requestId,返回result和命令receipt。作用域、前置版本、角色代次及实时权限由各领域服务继续校验。 + +| 命令名 | payload主要必填字段(其余可选字段见对应子系统) | 结果与权限 | +| --- | --- | --- | +| consultation.ask | text;agentId? | 私有咨询receipt,异步答案;本人,不创建Task | +| consultation.reply | consultationId、text;publish?、agentId? | 私有回答;显式publish且有权才进入原Task输入,安全代理仍需授权 | +| role.decide | proposalId、expectedSha256、decision:approve/reject | 管;精确批准Chief提案,不能由提案Chief借Owner身份自批 | +| task.propose | title;criteria/impact接纳前必需;scope/projectTaskKind/botId? | 创建唯一Task并提交Chief intake;当前成员发布权限;kind仅code/decision/release | +| task.change.request | taskId、action:revise/cancel/reopen、reason;revise可带requirements | 写;仅请求Chief判断,不立即改承诺/关闭Task | +| task.run | taskId | 写;未接纳则waitingFor:chief;接纳后原配额/资源队列,不是强制绕过Chief | +| task.accept | attemptId | 旧兼容交付的人工验收;管;新Task最终关闭仍由Chief | +| task.reject | attemptId、reason等原返工参数 | 管;原证据/停止/新Attempt路径,新Task不改变Chief裁定权 | +| attempt.stop | attemptId | 管;请求原worker停止,未证实退出继续持有租约 | +| attempt.cleanup | attemptId | 管;只在受支持的终止/退出状态清理,不删除Task历史 | +| attempt.integration-bind | attemptId、generation、inputs:[{ref,sha}]、expectedWorkspace(取integration-state快照) | 管;实际引用、SHA/测试/Review绑定,非伪造“已合并” | +| attempt.publication-retry | attemptId | 管;原PR绑定/幂等恢复,不另建重复PR | +| queue.cancel | queueId | 写;取消原等待请求,不伪造worker退出或Chief关闭 | +| queue.retry | queueId | 管;重验原始发起人、要求和资源;不是换请求人借权 | +| chief.inspect | taskId? | 管;一次原Chief巡检/裁定工作,不增框架轮询器 | +| chief.configure | enabled;inspection? | 管;配置原Chief授权和原生定时任务,默认巡检30分钟是配置值 | +| schedule.configure | operation:save/delete等、id?及原schedule定义 | 管;复用Project定时任务,不创建另一调度器 | +| agent.mode | mode:safe/watch;personalAgentId? | 当前人类本人;值守全权仍受实时人类权限及显式scope约束 | +| proposal.publish | proposalId | 当前人类授权发布私人提案;原Task/回执幂等,不公开私有草稿历史 | +| execution.input | workerId、generation;text或requestId/allow | 当前Task写权限;permission还需管;payload.requestId是原permission请求ID,不是信封幂等键 | +| input.publish | taskId、text:1..4000字符 | 写;宿主选择当前收件角色/代次,用户不能自造source/recipient/claim | +| source.subscribe | taskId、threadKind:issue/pull、number;mode/includeHistory? | 管;原准确仓库/凭据/订阅域;见来源契约 | +| source.disable | subscriptionId | 管;保留已提交输入/游标,停止后续拉取 | +| source.poll | threadId | 管;复用原来源拉取,重复事件按版本去重 | +| effect.retry | effectId | 管;只恢复耗尽失败的原Effect,不重置已完成动作 | +| comment.publish | issueNumber:正整数、text:1..10000字符;scope仅personal | 当前项目与个人GitHub授权;不借Owner或环境凭据 | + +复杂原生字段/证据前置的完整规范:[Task](subsystems/project-task-lifecycle.md)、[Handoff/任务池](subsystems/project-pools.md)、[输入](subsystems/project-inputs.md)、[来源](subsystems/project-sources.md)、[Chief/定时任务](subsystems/project-chief-control.md)、[交付](subsystems/project-delivery.md)、[GitHub](subsystems/project-github-read.md)。 +Role的get-context、accept/return/transfer/complete/split/Chief裁定走已绑定Task-role执行通道,宿主校验完整claim/代次/退出与证据。 +公开v1没有通用handoff/accept-claim接口供人冒充角色;handoff.get/list是查询,不等于接手。 + +## Execution / Resource / Session(原生配置接口) + +| 原生endpoint | payload概要 | 权限 / 返回 | +| --- | --- | --- | +| project-resources | projectId | 读;授权资源/健康/Harness元数据,不暴露profile或进程秘密 | +| project-resource-environment-check | projectId、resourceId及对应Harness | 管理及资源grant;固定探测,非任意shell | +| project-runtime-check | projectId、resourceId、harness | 管理/资源admin;实际登录与环境结果,配置存在不等于成功 | +| project-terminal-open/poll/input/resize/close | projectId、资源/Harness/terminalId及操作参数 | 当前管理/资源admin;原PTY用于SDK登录,不进入公共Project上下文 | +| project-execution-input | workerId、generation、text或原permission字段 | 与v1 execution.input相同实时权限 | +| project-agent-memory-write / project-role-memory-write | projectId、文本/来源及角色字段 | 私人记忆与公开角色记忆独立授权;保留revision/来源,按需召回 | -权限:configure。仅重试本项目已耗尽自动重试的 failed Effect,返回 `{effectId,status,already?}`。 -相同 requestId 的重试记录复用;其他状态会拒绝。它重置失败计数并排入 pending, -不立即声明远端成功,也不能把旧代次回调变成当前证据。 +Harness实际ID:flybot、codex、claude-agent。Task-role Session固定Task/角色身份,打回resume同一上下文;不能恢复时明确阻塞,不偷偷另起空白会话。 +执行仅在claim、资源绑定和持久激活都确认后启动。身份/要求/资源撤销使原执行合作停止,必须确认退出才能把主动权交给下一角色。 +SDK负责其自身compaction,FlyBot负责输入/工具输出/记忆预算和来源分页;不把累计usage当当前上下文占用。 +详细配置/端点字段见[执行环境](subsystems/project-runtime-setup.md)、[资源](subsystems/project-resources.md)、[Harness](subsystems/project-harness.md)。 -### C21 `comment.publish` +## SSE与断线恢复 -```json -{"issueNumber":48,"text":"以本人身份发布的具体评论","scope":"personal"} +```text +GET /api/project-events?projectId=PROJECT_ID +X-Flybot-Member-Session: MEMBER_SESSION_OR_FBA_TOKEN +Accept: text/event-stream ``` -权限:create + own personal GitHub 身份。issueNumber 必填正整数,text 非空最多10000长度; -scope 若填写只能 personal。操作者必须为当前人类/Project 配置并验证其个人凭据,不能借项目自动化账号兜底。 -结果是已核对发布身份的 `{audit}`;包含发起人、选定 GitHub 身份、URL、正文哈希、Effect 归属等。 -同一请求内容通过持久化 outbox 协调,回执不明确时先核对原评论和 Effect,不重复制造外部写入。 - -## 5. 非 v1 的必要接口 +也可使用 `node scripts/backend.mjs events --project PROJECT_ID [--after CURSOR]`。 +首次发送event:snapshot并带id;后续event:fact的id为该项目的opaque cursor。 +重连带 `Last-Event-ID` 或同值after参数,二者冲突拒绝。以cursor/实体revision去重,不解码或合成cursor。 +按project.snapshot.next补完当前实体页,可用expectedRevision防并发跨页混合;事件不是永久审计历史。 +当前有界保留每Project1024项/24小时,读取每页最多100项。过期/不同epoch返回bot/cursor-expired或bot/conflict,重取snapshot后续传。 +权限每次拉取重验,撤权发access-revoked并关闭;其他恢复条件发reset-required。未提交save、失败事务和私人聊天/凭据/私有提案不产生公共项目事件。 +宿主背压有界,慢客户端需断线续传;代理禁用SSE缓冲,服务退出关闭stream。 -这些通过 `fb_api ENDPOINT JSON` 调用,不应编造为 fb_dev 的操作名。 -表中 result 指外层成功响应的 `value`。 +## 错误与旧入口兼容 -| endpoint | payload 参数 | 返回/权限 | -| --- | --- | --- | -| `projects` | `{}` | `{projects}`;仅当前用户有权访问的项目 | -| `project-create` | `name`,可选已授权 `repository`,`defaultBranch` | `{project}`;人类身份成为 Owner;不自动配置真实 SDK/仓库权限 | -| `project-detail` | `id`(或共享入口中的 projectId),可选 `view:"summary"` | 项目、权限及关联工作记录;read | -| `project-agents` | `projectId`,可选 `view:"summary"` | shared definitions/public Chief 及自己的 mine/privateMemory/proposals/delegation;summary 只返回公共摘要;完整读取可能确保当前代理存在 | -| `task-create` | `projectId`,`botId`,`title`,可选 `criteria`,`projectTaskKind` | 返回 Task 本身;create;botId 应为当前项目公共 Chief,kind 为 code/decision/release,默认 code | -| `project-agent-message` | `projectId`,`text`,可选 visibility:private/public,默认 private;公共直接发布意图另用 publicationApproved | `{agent,queued:true}`;当前人类发起对话,busy 会拒绝;不把普通消息响应当最终模型回答 | -| `project-agent-history` | `projectId`,可选 visibility:private/public | `{agent,live,messages,busy}`;派生当前人类私有身份,不能选别人的私聊;可能确保代理存在 | -| `project-chief-question` | `projectId`,`text`,`requestId` | `{handoff,already?}`;只读对话 handoff 给 Chief 并回原私聊,不创建/启动 Task;相同 requestId 文本必须一致 | -| `project-agent-memory-write` | `projectId`,`text` | `{saved:true,id}`;写自己的私有笔记,最多2000长度,保留最近100条;不写共享知识 | -| `project-credential-status` | `projectId` | 个人/自动化 GitHub 配置元数据;read;不是凭据内容 | - -`task-create` 没有 v1 的统一创建幂等合同;网络响应不明确时先查询列表,避免重复发布。 -私人对话、自己的记忆不能自动塞进公共 Chief 上下文;只有明确提问/发布的内容才能流转。 -个人代理安全/值守的真实验收边界见[操作指南](development-service-guide.md)。 - -## 6. 状态、幂等与错误码 - -TaskView 是派生状态。原始 Task 业务状态、collaboration、Attempt、worker、Effect 可能不同, -例如 result-ready + blocked 表示已有结果,但协作受阻。完整含义见[状态模型](development-service-guide.md#状态由谁承载和更新)。 -不要在客户端根据“Bot 存在”“API 返回200”“有 PR”推断已执行或完成。 - -幂等强度按操作分别规定:run 的 requestKey、proposalId、input 来源、Thread 订阅身份、 -Effect/PR outbox 和配置保存并不是同一种机制。同 requestId 的恢复也不承诺可任意改变 payload。 - -auth-revoke 只注销本次 Session;切回 safe 撤销 watch grant;停止 Attempt、清理工作区又是不同操作。 -重启后保留 Task/Attempt 历史,只有经过已退出证明及恢复校验才重新派活,不凭缺失 PID 释放资源。 - -| 错误码族/情况 | 解释与处理 | +| code/状态 | 处理 | | --- | --- | -| `bot/unauthorized` 或认证失败 | 登录失效/身份未提供;重新登录,不提交 DSH owner cookie | -| `bot/forbidden` | Project、Task 或对象范围/操作权限不符;不能靠 body 提升角色 | -| `bot/invalid` | version/kind/name/形状/必填参数不合法;部分底层校验也使用该码 | -| `bot/conflict` | expectedGeneration、expectedCandidateSha、分页快照等已变化;重读后重新判断 | -| `bot/read-only` | 该宿主不能写状态;先解决锁/存储归属,不重复外部副作用 | -| `bot/delivery` | Attempt 生命周期、候选、证据、PR、已退出证明或集成源不满足;读 message 和当前详情 | -| `bot/effect` | 持久意图、Effect协议、回执或重试条件不满足;查effects及原远端对象,不能直接改成done | -| `bot/task-input` | 输入大小、身份、来源、当前 Task/claim 不满足 | -| `bot/project-source` | Thread/订阅/仓库、游标或 Source 合同不满足 | -| `bot/delegation` | watch/principal/grant 失效或缺少后台校验;不能借浏览器或别人的身份兜底 | -| `bot/chief`、`bot/harness`、`bot/busy` | Chief 配置/执行合同、SDK/worker 合同或当前对话忙;查相应状态 | -| 其他底层错误 | 本表不是穷举 HTTP 错误;保留原 code/message,不把它们统一当成可自动重试的网络错误 | - -## 7. 使用示例与证据边界 - -先按 README 登录、定义函数、设置 FLYBOT_PROJECT_ID。以下只读: - -```bash -fb_dev query tasks '{"limit":10}' -fb_dev query pools '{}' -fb_dev query task.detail '{"taskId":"TASK_ID","attemptLimit":5}' -fb_dev query inputs '{"taskId":"TASK_ID"}' -fb_dev query effects '{"taskId":"TASK_ID","limit":10}' -fb_dev query execution.boundary '{"taskId":"TASK_ID"}' -``` - -以下会写入、启动模型或产生外部操作,按需选择,不能当作连通性测试自动执行: - -```bash -fb_dev command task.run '{"taskId":"TASK_ID"}' 'start-001' -fb_dev command input.publish '{"taskId":"TASK_ID","text":"请补充边界测试"}' 'feedback-001' -fb_dev command chief.inspect '{"taskId":"TASK_ID"}' 'inspect-001' -``` - -接口存在、契约测试通过、真实 SDK 专项通过、实际任务闭环、UI/部署验收是不同证据。 -尤其 comment.publish 需要个人身份,示例中的假 ID 不可直接调用;本次文档补充不会为实服开启值守、 -发布任务、改变巡检、重试 PR 或停止正在运行的工作。代码基线的完整/专项结果记录在 README, -不能当作后续文档提交的全量业务测试结果。 - -## Project Task convergence (#2179) - -New shared Task creation accepts `impact` and `scope` in addition to title/criteria. -Its Project contract starts proposed; `task.run` returns `waitingFor: "chief"` -until Chief admission. No Attempt is allocated by that pending request. - -`project-development` command `task.change.request` takes -`payload:{taskId,action:"revise|cancel|reopen",reason,requirements?}` and the usual -stable envelope requestId. It returns a Chief run reference and `applied:false`; -it grants no direct revision, cancellation or reopening. `requirements` accepts -only title, criteria, impact and scope on a revision suggestion. Only a validated -Chief callback applies it. Reusing a requestId with a changed intent is refused. - -Trusted role commands may return `split` with independent children, a reason and -resumePoint. Public HTTP cannot supply a role claim. Parent Task views report -`waitingFor:"children"`; child results remain explicitly unintegrated until the -parent performs its own integration and final verification. For lifecycle fields, -current limitations and compatibility, see -[Project Task lifecycle](subsystems/project-task-lifecycle.md). - -## Persistence configuration (#2182) - -Plugin configuration `stateStorage: sqlite` selects the original Store's SQL backend -(Node.js22.18+, tested22.22.3). Project commands, authorization and query envelopes -remain unchanged; state and Effects share one commit. Existing JSON remains default -until migrated. Runtime storage diagnostics/change IDs are internal ports, not a -public raw-database API or authenticated event feed. See the [migration/rollback -contract](subsystems/project-state-storage.md). Public bounded receipts/events are -completed separately in #2185; simplified full deployment instructions remain #2189. - -## Service contract convergence (#2185) - -The envelope remains project-development/v1. Read projection versions are separate: -project-task-detail/v1, project-task-contract-summary/v1 and -project-attempt-summary/v1. Current constraints/evidence remain inline; raw lifecycle, -persona, context and Session history move behind explicit bounded source refs. -This intentionally changes unbounded raw-record assumptions. Existing UI-used fields -remain; original stored history is retained. - -| Kind/name | Payload | Result and boundary | -| --- | --- | --- | -| command task.propose | title, criteria, impact, scope?, projectTaskKind?, botId? | Proposes original Task; Chief admission remains required. Missing carrier uses existing Project Chief. Server stamps origin; no body actor/session authority. | -| query command.receipt | commandRequestId, commandName? | Calling principal's status, commit proof, origin and result ref. | -| query command.result | commandRequestId, commandName?, offset?, limit?, expectedSha256? | Exact JSON result page, max16000 chars, hash/version guarded. | -| query task.history | taskId, section, offset?, limit?, expectedSha256? | requirements, requirementsHistory, admissionDecisions, dependencies, waitHistory, results, handoffs, note, result or actors. | -| query attempt.context | attemptId, section, offset?, limit?, expectedSha256? | Prior role sections plus evidence, feedback, requirement, failure, agentDefinitions, integrationAdmissions. | -| query project.snapshot | limit?, after?, expectedRevision? | Committed bounded Project/Task snapshot and event cursor; later pages require the same revision. | -| query events | after?, limit? | Committed Project-scoped facts, max100, next/head/oldest cursors. | - -All v1 mutating envelopes have private durable receipts when the standard Effects -service is installed. Existing alias endpoints still use original domain handlers -and their own idempotency; they do not create another writable state source. Their -remaining migration/cutover is #2186. Public role acceptance/Handoff/arbitration -cannot be forged by supplying a claim or Chief role in an HTTP body. - -Receipt scope is operation + Project + authenticated user/independent agent. Reusing a key with -changed intent/preconditions is bot/idempotency-conflict. A confirmed response can -replay after its generation advances, after current permission checks. Concurrent -calls share one invocation. completion-pending-commit requires retrying the same -command, not a second execution. running/unknown does not automatically redispatch; -bot/outcome-unknown points to the receipt/target/Chief. Automatic reconciliation is -limited to existing idempotent task.run and original Task/intake proof for - task.propose/proposal.publish. Reconstructed responses say reconstructed:true. - -| Code/fact | Meaning | Client action | -| --- | --- | --- | -| bot/unauthorized, bot/forbidden | Session/principal/Project rights unavailable | Authenticate/check current rights; never borrow owner credentials. | -| bot/idempotency-conflict | Same key, changed intent | Recover the original response or submit a deliberate new command key. | -| bot/conflict | Version/source/snapshot changed | Fetch current referenced state and choose new preconditions. | -| bot/outcome-unknown | No confirmed final command outcome | Read command.receipt and target state; use existing reconciliation/Chief, not blind generic rerun. | -| bot/cursor-expired | Event epoch/retention no longer covers cursor | Fetch project.snapshot, resume after its cursor. | -| bot/busy | SSE transport capacity | Retry later or use bounded HTTP queries. | -| waitingFor:resource/worker-exit | Existing execution admission/exit boundary | Inspect queue/execution.boundary; unknown exit never authorizes another writer. | -| completion-pending-commit | Result observed, final receipt not committed | Retry the same command to finish commit without invoking again. | - -HTTP /api/bot retains {ok,value}/{ok:false,error:{code,message,details}}. Successful -v1 commands additionally return receipt, replayed and optionally reconstructed. -Normal queries and committed event generation call no model and do not change -Project positioning. Consultation remains consultation.ask/reply; do not publish a -Task for an ordinary question. - -SSE uses the existing restricted gateway: - -```sh -curl -N -H "x-flybot-member-session: $FB_SESSION" \ - "$FB_URL/api/project-events?projectId=$FB_PROJECT" -# Resume using the last accepted id from a snapshot/fact frame: -curl -N -H "x-flybot-member-session: $FB_SESSION" \ - -H "Last-Event-ID: $FB_EVENT_CURSOR" \ - "$FB_URL/api/project-events?projectId=$FB_PROJECT" -``` - -No token goes in a URL. The existing member header also accepts authenticated -personal-agent credentials. Initial frames are snapshot, then fact with id cursors; -deduplicate by cursor. access-revoked closes the stream; reset-required requires a -fresh snapshot. Initial expired cursors receive HTTP409 with nextAction. No upstream -owner cookie is forwarded. Current read rights are rechecked on each batch, including -backpressure recovery. Project sequences exclude private conversations/auth/receipt -writes. Default retention1024 facts/24h is configurable via projectEventRetention; -it is not permanent audit history. Follow Task/Handoff/Attempt source queries for -older records. See [full contract](subsystems/project-service-contracts.md). - -Operation names scope requestId, so different operations may reuse a key. When a key matches multiple operations, command.receipt/result require commandName (also supplied in resultRef); otherwise return bot/ambiguous-command. Retrying one invocation preserves its key; a deliberate new source.poll uses a fresh key. Confirmed replay preserves the original result and marks replayed:true, rather than rewriting its domain already flag. +| bot/invalid | 校正版本、kind、必填字段、分页或原生操作参数 | +| bot/unauthorized / bot/forbidden | 重新认证/核对当前成员、参与、scopes和委托;不换身份借权 | +| bot/conflict | 重读代次/候选/hash/版本,再判断;相同意图的内容不能改后重试 | +| bot/cursor-expired | 重取project.snapshot,继续新的cursor | +| bot/read-only / bot/state-storage | 修复锁/格式/存储迁移;不删除守卫强行写入 | +| bot/compatibility | 未登记的旧Project Task不能执行;让Chief明确处理原Task,活动执行先到安全点 | +| blocked/unknown/stopping | 查询Task、Effect、边界及实际worker退出证明;不是PASS或资源空闲 | +| HTTP429/503 | 有界退避、重读当前状态,保留原requestId | + +保留task-create、project-task-run、project-task-pools、project-chief-question等原接口,映射同一领域服务。 +新调用方优先v1命令以获得主体范围的持久回执。旧Group/track_tasks仅保留已提交旧任务及其返工,不得为新Project工作建立旁路;个人任务不受该Project切换限制。 +原public Bot人设/长期记忆可授权查看,个人代理/Chief私有记忆不会因此公开。具体保留对象和回退限制见[兼容盘点](subsystems/project-compatibility.md)。 diff --git a/docs/development-service-guide.md b/docs/development-service-guide.md index 5e3d41f29..054c349af 100644 --- a/docs/development-service-guide.md +++ b/docs/development-service-guide.md @@ -1,173 +1,90 @@ -# 自动开发服务操作指南 +# Project 协作与开发流程 -本指南说明交付分支的实际交互、任务流转、个人代理和状态承载。 -[README](../README.md) 负责连接/启动,[API 参考](development-service-api.md) 负责参数、响应和权限。 -以下功能/验证边界对应业务基线1811,不把后续前端 PR 的变化算作本分支能力。 +[快速开始](../README.md) · [API](development-service-api.md) · [部署与执行配置](backend-operations.md) -## 交互入口 +## 先区分对象 -| 入口 | 当前形式 | 适合的操作 | +| 对象 | 长期身份与用途 | 不应混淆的边界 | | --- | --- | --- | -| 工作台网页 | 原 FlyBot 的 Project、公共 Chief、个人代理、群聊和任务视图 | 对话、监督项目工作;视觉与完整 UI 验收另行跟踪 | -| HTTP API | 已认证 `POST /api/bot` + v1 应用服务 | 外部程序接入、查询/控制任务和配置 | -| 命令行 | README 的 fb_api / fb_dev 函数,输出 JSON | 调试和脚本调用;没有 flybot board 交互 TUI或自动刷新表格 | -| GitHub 反馈 | 对已订阅的 Issue/PR 原生定时轮询 | 在原讨论追加要求,形成原 Task 的持久输入 | - -没有单独的后端管理登录页面或 Swagger 页面。API 与工作台共用账号授权体系, -但一次 API 登录可以有自己的 Session;无需先在网页登录。进程仍与 FlyBot 共用, -不是靠命令行打开后才能维持的前台聊天会话。 - -## 从需求到交付的步骤 - -1. **讨论或提问**:问 Chief/个人代理,不创建 Task。私人代理可只把明确问题 handoff 给 Chief并收回回答。 -2. **发布有项目影响的工作**:代码变更、技术决策、发布事项形成 Task;安全模式先展示私人提案并确认, - 或由有权限的人通过 task-create 明确发布。Task 有稳定 projectId、发起人和验收条件。 -3. **启动**:task.run 接受运行请求,或因配额/资源排队;已发布不代表正在执行。 -4. **领取与执行**:Bot 从自己的任务池领取。宿主登记 claim、资源租约、Task/角色 Session及工作区, - 再由选定 Harness 执行。人设、Skills、仓库规则和作用域记忆随执行快照提供。 -5. **自主交接**:当前 Bot 选择下游、打回、转交或请 Chief裁定。框架检查当前领取/代次、权限、退出证明和产物; - 不通过则记录阻塞。不是前端按固定 DAG依次派活。 -6. **验证与发布**:代码候选由宿主封存;独立宿主测试、角色 Review与PR绑定具体SHA。 - 达到发布条件才更新原 PR,不用模型一句“测试通过”替代宿主证据。 -7. **裁定**:申请完成/不确定的任务进入 Chief。Chief可关闭、要求测试、返工、指定下一角色或询问人类; - 仍须满足验收与候选一致性规则。Task关闭、PR合并、部署上线是不同动作。 - -```mermaid -flowchart TD - R[明确需求或外部反馈] --> T[发布 Task 或补充原 Task 输入] - T --> P[目标 Bot 任务池] - P --> W[领取并执行] - W --> N[选择下游 Bot] - N --> P - W --> C[申请完成或不确定:交 Chief] - C --> P - C --> H[等待人类回答或外部验收] - C --> E[证据满足:关闭 Task] -``` - -这描述已实现的协议;并不表示每条异常恢复或个人值守都已在真实开发中完整验收。 - -## 一个真实走过的例子 - -原 Task40/PR48 的前端变更遗漏匹配架构文档。操作者在原 PR 评论指出问题后: -GitHub Source自动接入反馈 → 原 Task 输入 → Chief/Dev接手 → 真实 Codex修改文档 → -Tester及独立宿主测试 → Reviewer检查同一SHA → 原PR更新。 -此次改动的候选为36147e74;没有新建一条替代开发任务。反馈的接收、读取与处理各有记录。 -整个前端Task仍有实际服务/视觉等验收要求,因此文档修复通过不等于整个Task已关闭。 - -## 个人项目代理 - -每个 `(用户, Project)` 有独立私有代理身份、对话和私有笔记。换Bot载体不改变逻辑身份; -公共Chief和代码角色不自动获得任何人的私有历史。 - -以下示例需先按README登录并定义函数,替换 PROJECT_ID。发送消息可能调用实际模型, -不是只读连通性检查;本指南不会自动为现有账号切换值守模式。 - -```bash -# 当前用户自己的代理、提案、授权状态;完整读取可能确保代理存在。 -fb_api project-agents '{"projectId":"PROJECT_ID"}' -# 私聊,返回 queued 后通过 history 等待实际回答。 -fb_api project-agent-message '{"projectId":"PROJECT_ID","visibility":"private","text":"这个项目目前有哪些阻塞需要我处理?"}' -fb_api project-agent-history '{"projectId":"PROJECT_ID","visibility":"private"}' -``` - -### 普通问题交给 Chief - -```bash -fb_api project-chief-question '{"projectId":"PROJECT_ID","text":"当前交付还有什么验收缺口?","requestId":"question-001"}' -``` - -它把明确问题交给 Chief 并返回原来的私人对话;不会创建、启动Task或公开整段私有对话。 -返回的 handoff.status 是受理状态,最终回答在私有 history。相同请求ID不可换成另一个问题。 - -### 安全模式和发布提案 - -```bash -fb_dev command agent.mode '{"mode":"safe"}' 'mode-safe-001' -``` - -私人代理的Task工具先返回私人proposal;人类检查字段后: - -```bash -fb_dev command proposal.publish '{"proposalId":"PROPOSAL_ID"}' 'approve-proposal-001' -``` - -这只是发布Task,随后按明确需求调用task.run。提案的正文成为公共Task,其他私有记忆不自动公开。 -代理不能在模型工具参数里伪造人的确认。直接由人调用task-create属于显式发布,不是私人提案审批。 - -### 值守模式 - -```bash -fb_dev command agent.mode '{"mode":"watch"}' 'mode-watch-001' -fb_dev query execution.boundary '{}' -``` - -watch创建当前用户/Project的可撤销授权,不保存浏览器Cookie。退出API Session不会撤销它; -切回safe撤销。后端每次重新检查账号有效性、成员身份和权限,移除成员、停用账号、改变身份或授权代次会拒绝后续动作。 - -“代我操作”限于该用户当前有权执行且代理接口支持的Project操作,不能自升Owner、访问其他用户私聊, -也不因此获得全局认证管理、自动合并、发布或部署权限。后台唤醒还依赖操作者配置的实时principal校验; -没有该能力不会静默退化为长期复用浏览器Cookie。授权本身不是配置了一条自动巡检或通知调度。 - -**验证边界**:私聊、隔离笔记、只读问题handoff有实际交互记录;safe提案/发布有历史记录, -但早期错误地把PR查询做成Task且任务失败,不能计作成功开发。watch授权、登出续行、撤权及权限边界有测试; -尚未证明个人代理值守发起真实变更并闭环交付。当前开发主链路曾通过人类后端接口和GitHub反馈启动。 -这些与“原个人Chief首次使用UI是否验收”也是不同问题。 +| User / PersonalAgent | 人类及独立于Project的内部/外部个人代理 | 加入Project形成participation,不把代理绑定为某个项目的永久子对象 | +| Project | 项目定位、成员/权限、仓库关联、角色治理和历史 | 仓库不是Project;可无仓库、多仓库,一个仓库也可被多项目关联 | +| Role | 人设、职责、Skills、长期记忆引用、Harness/Resource约定 | Chief是常驻项目角色;Dev/Reviewer/Tester等按Task工作。角色名不授予权限 | +| Task | 对项目产生直接/间接影响的工作承诺、要求版本、依赖和结果 | 咨询不是Task;一次失败Attempt不等于Task被取消或关闭 | +| Attempt / Session | 一次执行尝试及该Task/角色的逻辑与物理上下文 | 同Task打回恢复原角色Session;新Task另建。不能因重启而重复外部动作 | +| Handoff | 可持久化交接契约:源/目标、Task/上下文版本、receipt和主动权 | 发出、对方接手和完成是不同状态;不是一次无回执的消息或固定DAG边 | +| Consultation | 私有咨询/回复/问题升级,有独立receipt | 不转移Task主动权;默认优先个人代理,需要人类决定才升级到对应的人 | +| Execution / Harness / Resource | 原宿主/SDK执行、环境/容量、lease/退出证明 | 分配资源不等于已启动;Bot/PID消失不等于已退出 | + +默认Chief/Dev/Reviewer/Tester可扩展。Chief增加、修改、删除角色需Maintainer/Owner批准精确提案;退休保留历史,活动工作不被悄悄改派。 +个人Chief、个人Bot、原生群聊可不参与任何Project。加入/关联项目不公开私有聊天和记忆;公共Bot只展示授权公开的人设与角色记忆。 + +## 一次咨询 + +使用consultation.ask,说“解释当前设计”或“帮我判断这个问题”。返回私有咨询receipt,异步回答通过consultation.get/list读取。 +框架不为提问发布Task,不借问答触发代码修改。个人代理对外代表你时,来源和返回收件人绑定独立身份,其他项目成员不能读取这段私聊。 +若咨询最终形成代码变更、技术方案采纳、版本发布等影响项目的行动,再经授权发布Task;对已有Task的补充走input.publish。 + +## 首个代码任务 + +以下是正常服务操作路径,不创建测试模型/样例仓库。把实际ID、仓库、测试命令和资源填入[执行配置](backend-operations.md#执行配置)。 + +1. 在启动配置中登记你可访问的Git仓库、真实testCommand、允许路径;为Chief配置真实模型,执行角色配置可用Harness/Resource。若要PR写入,配置准确账户的GitHub凭据并验证登录。重启只作用于你部署的实例,健康活动工作先到检查点。 +2. `request project-create --json '{"name":"My project","repository":"YOUR_ORG/YOUR_REPO"}'`,保存返回project.id。已有项目可通过project-repository-link关联;当前交付仍使用项目选定的执行仓库,关联多仓库不等于一个Attempt跨仓库修改。 +3. `query roles --project PROJECT_ID` 查看当前角色;`query execution.boundary --project PROJECT_ID` 查看真实readiness,再通过原runtime-check验证SDK身份。Owner/Maintainer可通过原project-agent-definition授权配置现有角色的Harness/资源;Chief提出角色变更则需治理批准。所有变更均不得覆盖活动Task的固定快照。 +4. `command chief.configure --project PROJECT_ID --request-id enable-chief-1 --json '{"enabled":true,"inspection":{"enabled":true,"when":{"kind":"every","seconds":1800}}}'` 启用Chief及巡检配置。先确认你的后台授权仍有效;平台模式无人值守需principal checker。 +5. `command task.propose --project PROJECT_ID --request-id fix-1 --json '{"title":"修复明确缺陷","criteria":"复现、必要回归和独立Review通过","impact":"恢复项目功能"}'`。从result.id取得Task ID;Chief判断要求充分后接纳并提交task.start。 +6. 用`query task --project PROJECT_ID --json '{"taskId":"TASK_ID"}'`、pools及events观察。task.run可对同Task请求启动;未接纳时仍等待Chief,不能用手动run越过接纳或资源边界。 +7. Dev取得当前Task/角色上下文,决定执行、打回或转交;产物交下游,宿主验证候选、真实测试、Review与SHA。打回同Task时resume前次上下文,不能偷偷换一条空白SDK线程。 +8. Bot认为完成或不确定时交Chief裁定。Chief可让下一角色补证据、返工、询问个人代理/人类,满足证据后才关闭Task。PR发布、合并、版本发布和部署各需对应权限与动作,不能把Task关闭当自动合并部署。 + +命令前均加`node scripts/backend.mjs`,自定义home时所有命令传同一个--home。详细CLI信封/字段见[API](development-service-api.md)。 +仓库、凭据、模型或可用Resource缺失时阻塞是实际状态,不能写作“已自动开发”。decision/release可建Task,但不会绕过执行/证据要求;未配置的发布验证器会明确拒绝。 + +## 单线、并行与持久交接 + +单线工作只保留一个当前主动权。Bot通过已绑定的执行回调选择下一角色,宿主先确认旧工作退出与产物,再提交Handoff;下游从自己的池中接受,确认后才持有新的claim。 +Handoff保留predecessor、上下文引用、要求版本、授权来源和接受/处理回执,恢复与重放读取原契约。 + +可并行时,原Task进入waiting-children,先释放原执行资源,再由原Task契约开有限子Task。 +子Task分别接纳、执行和Chief裁定;失败/取消不自动算成功。Chief可明确豁免或替代并留证。 +必要子结果完成且集成源/测试/Review被宿主验证后,父Task仅重新入列一次,恢复原角色Session继续集成。 +原任务的依赖、主动权和状态仍在同一个Task记录中,不由前端DAG推断。 + +Chief巡检是原定时任务的一项可配置工作,30分钟是策略默认值,不是框架硬编码的常驻派活循环。 +长时间无进展、重复打回/转交、执行过长或无法确认退出可升级给Chief;它受当前治理授权和证据约束,不可把未知退出改成“资源空闲”。 + +## 安全代理与值守代理 + +安全模式允许代理私下整理提案和咨询,但发布共享Task需用户授权。 +外部代理用personal-agent-create/join、token-create获得Project限定连接;safe proposal保留title/criteria/impact/scope,由人类proposal.publish变成原Task。 +值守模式由本人显式授权,代理在当前用户权限范围内自主发布/控制;不因改名、换carrier或默认代理而扩权。 +撤销连接、退出项目、停用账号或撤销watch立即阻止新操作,并通过原执行停止/退出确认机制处理活动工作。 + +Owner关闭项目采用prepare/confirm二次检查。Maintainer或个人代理可建议;不能代Owner本人确认。 +关闭保留历史、最终记录和未完成Task引用,不要求先清空Task池。 ## 状态由谁承载和更新 -单机实例使用原FlyBot锁定的JSON存储。当前部署文件为 -`/var/lib/flybot-shared/data/bot/state.json`;日志/产物另存并以路径、哈希关联。 -它不是模型记忆文件、看板浏览器状态或一份可由Bot随意编辑的Markdown任务清单。 - -| 记录 | 内容/主要更新者 | -| --- | --- | -| projects / projectArchive | Project身份、仓库、成员与生命周期/删除留档;Project管理服务 | -| tasks | 需求、发起人、projectId、业务状态;任务/交付服务 | -| task.collaboration | 当前Bot、任务池状态、claim、generation、handoff历史与阻塞;任务池/Chief控制 | -| projectAttempts | 每轮执行、候选SHA、工作区及测试/Review/PR证据;交付服务 | -| projectHarnessWorkers / projectResourceLeases | 实际执行和占用、待输入、退出证明;Harness/资源服务 | -| projectTaskInputs / projectEffects | 追加输入、读取处理回执、持久副作用与恢复;输入/Effect服务 | -| projectChiefDecisions | Chief提议、所依据快照及实际应用/拒绝结果;Chief控制服务 | - -人类命令、Bot结构化handoff、SDK回调或定时发生项进入后端,经过权限、当前代次、 -归属和证据校验才修改记录。存储串行生成revision快照,以临时文件写入并原子rename。 -需要外部动作时先保存Effect意图,确认持久提交后执行,再保存结果与回执。 -不能承诺每种外部网络写入无条件exactly-once;模糊结果需要核对原远端对象。 - -Project当前生命周期主要为active/archived,删除后留档;尚未有完整目标/里程碑/版本/项目验收状态模型。 -一条Task完成不会自动把Project标为完成。 - -### TaskView 读模型 - -原Task业务状态、协作状态、Attempt和worker分别记录事实;API的 `phase` 是统一派生结果, -不是又存一份可任意设置的任务状态。例如:result-ready + collaboration.blocked 可以合法共存, -表示已有产物但目前协作受阻。 - -| phase | 含义 | -| --- | --- | -| published | 已发布,没有更高优先级执行/终态事实 | -| queued / starting | 等角色/资源/容量,或执行正在准备 | -| running | 对应执行事实满足当前运行判定 | -| awaiting-input / awaiting-owner | 等当前执行回答/权限,或Chief提出人类问题 | -| awaiting-chief | 等Chief接手/裁定 | -| blocked | 协作或队列受阻,读取reason | -| publishing | 持久化发布意图仍在协调/执行,不能据此认定远端已成功 | -| awaiting-acceptance | 候选已进入验收等待,尚未accepted | -| paused / stopping | 暂停或等待确认退出,资源不一定已释放 | -| failed | 当前执行失败/中断且没有更高优先级的恢复/阻塞事实 | -| accepted / cancelled | 任务已验收/取消 | - -同时读取 waitingFor、reason、currentRole、attemptId、candidateSha、evidence、inputs、effects和nextActions。 -nextActions根据当前请求者权限过滤;它只是当前状态下可尝试的操作,不是绕过后续实时校验的许可。 - -## 操作时常见的问题 - -- **“成功启动”为何没有代码?** task.run可能只进入queue;从TaskView、池、Attempt和worker判断。 -- **Bot说完成为何没关闭?** 模型结论不是宿主测试/Review/PR同SHA证据,也不是Chief已经应用裁定。 -- **为什么换了Attempt?** 显式返工/已封存候选的修复可创建新Attempt,Task与原PR绑定继续保留。 - 已确认退出的未封存interrupted干净/脏工作区,在非rework恢复路径可保留原Attempt及其集成准入。 -- **为什么不能直接重试PR?** 必须核对当前候选、发布Effect、远端HEAD和退出证明;记录已保存不等于远端仍匹配。 -- **为什么不让Bot自己改状态文件?** 那会绕过权限、代次和证据规则;所有变更都应走受支持的接口/宿主回调。 -- **何时需要人?** 看awaiting-owner、blocked、awaiting-acceptance及原因,普通下游handoff不需逐步审批。 -- **巡检在哪里?** Chief模板/项目配置与原生定时任务承载,默认示例1800秒,使用chief.state查询;不存在另一个硬编码Chief计时器。 +原Store和同一领域服务持有Project、Task、Attempt、Session、Handoff、Effects与角色记录;默认部署选SQLite,支持显式JSON模式。 +任务创建/接纳/claim/交接/退出/证据/裁定等确定性业务动作在提交时更新相应事实;事务失败不能对外宣称已执行。 +读取、每条聊天、每个工具调用不会问Bot“是否要改项目定位”。定位/范围的语义变化由授权主体明确提出并经过治理。 +概览/TaskView是已提交事实的派生视图,摘要是有来源和版本的辅助材料,都不是另一套可写Task状态。 +Effects先持久提交意图,后调用已有执行器/外部服务,结果也留回执。未知结果需核对,不凭超时猜测失败后重发。 + +## 控制上下文和历史增长 + +- Chief保留长期身份、精简工作摘要和作用域长期记忆;完整日志留存为源,不逐轮塞入提示词。 +- 角色Session按Task隔离,返工resume;固定人设/Skills/要求/模型快照,当前权限另行重验。 +- 预算按操作者→Harness→模型能力选择,计入最终系统/输入/工具结果;大内容按限定来源和SHA分页。 +- 长期记忆有revision、来源、回滚与精简约束;高频事件不自动写成永久记忆。summary不能冒充正式事实或丢掉未决承诺。 +- 事件流有界保留;完整Task/交接/证据历史按引用读取。SQLite解决事务和索引,不等于自动解决上下文或无限存储增长。 + +源码仍整体hydrate状态,未实现通用历史GC;不要把数据库引入理解为所有文档/上下文都已无限扩容。 +工具输入与模型结果的实际占用/compaction能力分别记录;SDK累计usage不是当前上下文窗口。 + +## 验证与未覆盖范围 + +生产启动工具是scripts/backend.mjs;原型/fixture launcher不是部署方法。 +本轮覆盖独立home的正常安装、HTTP登录/Project/Task创建、持久重启/同回执重放,以及通过生产启动入口的原生认证、私有咨询、外部FBA安全/值守、撤权和SSE。 +测试供应商使用确定性模型,不能算真实供应商自主完成代码开发。 +既有DSH native交接/返工/SQLite探针与真实Codex延续证据各有自己的提交和报告;没有借这些记录声称新分支已做完整真实模型、浏览器/桌面、生产迁移或上线验收。 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index ef933e975..23e8c113e 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -71,3 +71,5 @@ Persistent Project effects and GitHub receipts: [project-effects](project-effect - [Project Task lifecycle](project-task-lifecycle.md) — Chief admission, requirement versions, child waits and original-role continuation. - [Original FlyBot compatibility](project-compatibility.md) — original capability inventory, one-time legacy Task cutover, private group ACL and same-version storage rollback. + +- [Persistent backend entry](backend-service.md) — source init/start/login/CLI, original HTTP/SSE composition and deployment/documentation verification. diff --git a/docs/subsystems/backend-service.md b/docs/subsystems/backend-service.md new file mode 100644 index 000000000..4fe50267f --- /dev/null +++ b/docs/subsystems/backend-service.md @@ -0,0 +1,69 @@ +--- +subsystem: backend-service +owned_paths: + - scripts/backend.mjs + - scripts/backend-lib.mjs + - scripts/backend-resolve.mjs + - .github/scripts/backend.test.mjs + - .github/scripts/backend-docs.test.mjs + - .github/scripts/backend-runtime.test.mjs + - docs/backend-operations.md + - docs/development-service-api.md + - docs/development-service-guide.md + - README.md + - README.zh.md + - README.native.md + - README.native.zh.md +--- + +# Persistent source backend entry (#2189) + +The entry composes the existing DSH host/profile, all five FlyBot packages and the +original restricted HTTP/SSE gateway. It adds neither a domain service nor a Task +writer, permission policy, scheduler or executor. The upstream cookie stays in the +private process/home; ordinary member/FBA calls enter the same scoped domain API. +Original UI assets are reused without client/control-plane edits. + +init installs lockfile dependencies and pinned pnpm10.34.6, links source packages +using original docker/profile.mjs logic and installs the pinned DSH bundles. +Source peer resolution uses that production profile rather than a mock Harness. +start owns only its DSH child and existing gateway, forwarding normal termination. +It uses an existing home lock; init refuses an active launcher. Public readiness +requires successful DSH authentication and actual writable Store load. Unknown +storage formats cannot become an empty healthy shared service. The45s startup +bound is a process startup deadline, not a new Chief inspection timer. + +The caller chooses a private persistent home. Configuration, transport and CLI +member files use original durable file replacement with0600 permissions; raw +startup logs remain private. The CLI never prints login secrets, passwords or +upstream Owner cookies. Normal requests use the public gateway; only local Owner +bootstrap/login accesses the private operator entry. Platform identity still +requires the existing origin/Owner subject and per-request proof, without local +fallback. Restart preserves native member credentials; platform proof requires +fresh login as before. Supplied publicOrigin supports the original fixed-origin +proxy contract and local CLI Host/Origin checks. + +Backend config requires shared Project mode and pool default. Existing advanced +config paths, resource grants, profiles, repository policies and migration tools +retain their own validation. Model/SDK configuration is not evidence of readiness +or successful code execution. No model or sample repository starts inside this +launcher. Role approval, Task/Handoff, confirmed exit and source/receipt semantics +are described from current code, including explicit legacy log/feature limits. + +## Verification + +backend.test covers actual profile composition/pin/private modes and HTTP client +behavior. backend-docs.test checks all24queries/26commands against actual source, +JSON, shell syntax and local links. dsh-pin retains its original npx pin assertions +on relocated original README guides; generated production profiles separately +prove the pinned DSH versions. + +backend-runtime.test starts this actual production entry in an isolated home with +an explicitly deterministic inference server. It tests authenticated CLI bootstrap, +Project/Task persistence, completed private consultation without creating a Task, +safe FBA proposal and human approval, watch/current-token revoke, original-role +metadata, SSE membership revocation, restart and exact command replay. It refuses +init during active service and unsupported storage startup without changing the +guard. Its offline dependency fixture is separate from the manual fresh-home +init/install/start/restart acceptance. Neither is a real-provider developer loop, +production rollout, remote HTTPS deployment or browser/desktop acceptance. diff --git a/docs/subsystems/demo-baseline.md b/docs/subsystems/demo-baseline.md index 8b9b5b441..b31c94abc 100644 --- a/docs/subsystems/demo-baseline.md +++ b/docs/subsystems/demo-baseline.md @@ -178,3 +178,7 @@ Quota assertions use an operator-owned verification gate in the disposable test `node dev/demo/run.mjs --native-pools` adds actual DSH single-resource Developer/Tester return/context restoration acceptance with a deterministic model. The marker is emitted only into the first Developer conversation outside the durable handoff JSON; the second request must find it in retained history, absent fresh system/input. It verifies fresh carriers, same physical Session/cwd, actual second code revision/host tests, lease intervals and Chief arbitration without fake closure; cleanup stops its disposable Attempt and removes worktrees. The required runtime suite now includes this fifth case. Initial failures exposed implicit [object Object] delivery deduplication and DSH archived-session admission; retained private evidence distinguishes both from the passing probe. `run.mjs --native-pools --sqlite` runs the same canonical Chief/pool/context/exit scenario with the real transactional Store; `saved()` reads independent committed SQLite snapshots. Its sixth runtime regression verifies durable activation markers and cleans the disposable database and worktrees. Attempt chronology uses persisted predecessor references, not map key/insertion order. This is deterministic verification, not the backend deployment method. + +## Persisted admission checkpoint (#2189 integration) + +The actual native JSON/SQLite return probe waits through the original preparing Attempt reservation before asserting running pool protocol. SQL can expose this committed intermediate row before initialization finishes. A failed Attempt fails explicitly; the original protocol, running, context, single-resource, tests and cleanup assertions remain. The first full SQLite run reproduced this timing race; this is a deterministic-provider fixture correction, not a production delivery verdict. diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index b68156789..81484e8ed 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -141,3 +141,7 @@ original own-Bot scope; model-supplied team/Bot names cannot retarget memory. Th Workflow type adds optional exact native-input preflight; source patches remain generated from core. See [Project context](project-context.md) for source/cursor, role-memory and model/Harness budgeting semantics. + +## Deployment documentation pin (#2189) + +The short backend README delegates installation to the persistent launcher. Original English/Chinese npx guides now live in README.native.md / README.native.zh.md with every original version assertion retained there; backend profile tests independently assert pinned production DSH dependencies. No DSH upstream files are edited. diff --git a/docs/subsystems/personal-agent-auth.md b/docs/subsystems/personal-agent-auth.md index 829f588c8..e49d20ff0 100644 --- a/docs/subsystems/personal-agent-auth.md +++ b/docs/subsystems/personal-agent-auth.md @@ -72,7 +72,7 @@ All use the existing authenticated endpoint/payload transport. | personal-agent-token-list | Human owner: agentId, optional limit/after | public credential page and next cursor | | personal-agent-token-revoke | Human owner: credentialId | revoked metadata | | personal-agent-delegation-set | Human owner: agentId, projectId, mode safe/watch | existing delegation view | -| personal-agent-propose-task | Agent connection: projectId, title, criteria, projectTaskKind, requestId | private proposal, publishRequired | +| personal-agent-propose-task | Agent connection: projectId, title, criteria, projectTaskKind, requestId; optional impact/scope | private proposal, publishRequired | Writes refuse read-only hosts. Validation or failed issuance cannot return an active uncommitted connection. Hash comparison rejects malformed stored hashes. @@ -85,3 +85,7 @@ expiry/account changes, spoofing, malformed credentials and reload. This is not an official grok-bot/dots adapter or provider login verification. External endpoint descriptors remain unverified; no outbound provider network request is introduced. README/API publication stays deferred to #2189 after backend convergence. + +## Canonical proposal requirements (#2189 integration) + +External safe proposals now preserve explicitly supplied impact and scope as bounded fields in the same private proposal and intent hash. The original publication path transfers them into canonical Task requirements; it cannot silently lose the human-approved Project impact before Chief admission. Missing optional legacy fields are not invented. The production-entry HTTP regression reproduced the loss before this adapter fix and separately verifies safe approval, watch publication and credential revoke using an actual FBA caller. This is a protocol fixture, not a vendor product adapter. diff --git a/docs/subsystems/project-development-service.md b/docs/subsystems/project-development-service.md index 228a223ba..5207ec7a0 100644 --- a/docs/subsystems/project-development-service.md +++ b/docs/subsystems/project-development-service.md @@ -7,11 +7,12 @@ owned_paths: # Automatic development application service -The `delivery/ai-centric-development-service` branch is a consolidated source handoff from -1811e3b8f. Its [root README](../../README.md) provides the operator-facing current -service and isolated-source quickstart, authenticated API examples and command -index. It preserves this existing in-process facade; no HTTP contract or runtime -behavior changes accompany the documentation branch. +The current source delivery composes this in-process application facade with the +original DSH host and restricted gateway. Its [root README](../../README.md) uses +the persistent backend launcher; [API reference](../development-service-api.md) +covers actual commands/queries/events and [operations](../backend-operations.md) +covers storage/auth/execution setup. Historical S1–S6 sections below describe the +incremental implementation; old1811 snapshot instructions are superseded. S1 (#50, parent #49) introduces one application facade in the existing FlyBot process. It owns routing and the derived TaskView, not another Task store, @@ -216,3 +217,7 @@ Legacy Projects with an implicit built-in catalog report its effective revision1 ## Compatibility diagnostic (#2186) Query compatibility returns the same Project's committed original-work baseline, current contract/legacy/held/active counts and bounded ID/reference rows; payload offset defaults0 and limit defaults50 (maximum100). It reads existing Task/Effects facts without migration or content disclosure. See [compatibility](project-compatibility.md). + +## Current operator interface (#2189) + +The [root README](../../README.md), [API reference](../development-service-api.md) and [operations guide](../backend-operations.md) replace old1811 snapshot instructions with the real persistent source launcher and actual24queries/26commands. The reference distinguishes canonical Chief completion from legacy acceptance, current vs frozen execution and private consultation from initiative transfer. Native log reads remain explicitly described as full sealed logs, not fictitious paging. CLI/HTTP reuse the existing facade. diff --git a/docs/subsystems/project-workbench.md b/docs/subsystems/project-workbench.md index 33e1cce6e..ca4a514f0 100644 --- a/docs/subsystems/project-workbench.md +++ b/docs/subsystems/project-workbench.md @@ -477,3 +477,7 @@ The legacy Workflow gateway now also excludes Chief control jobs from execution #2171 adds role catalog/proposal/decision endpoints to the existing restricted gateway allowlist. Reads preserve Project scope; decisions keep configure authorization in the domain service. No control-plane or frontend replacement is introduced. + +## Persistent service composition (#2189) + +The source backend launcher reuses this original gateway/UI and account/resource endpoints. auth-mode now waits for Store load and exposes only a writable boolean in addition to existing metadata; it reveals no private file paths. Startup refuses read-only/unsupported state rather than opening an apparently fresh writable service. No frontend assets/control-plane implementation changes. See [backend entry](backend-service.md). diff --git a/patches/ds-bot/files/src/host/personal-agent-auth.js b/patches/ds-bot/files/src/host/personal-agent-auth.js index 5e0362464..47b3ea8fa 100644 --- a/patches/ds-bot/files/src/host/personal-agent-auth.js +++ b/patches/ds-bot/files/src/host/personal-agent-auth.js @@ -111,7 +111,9 @@ export function install(rt) { const origin = source(actor, body.projectId, { proposal: true }) if (rt.requireProjectAccess(actor, body.projectId).status !== 'active') throw refusal('Project is closed to new work') const requestId = bounded(body.requestId, 'proposal request ID', 100) - const fields = { title: bounded(body.title, 'task title', 300), criteria: bounded(body.criteria, 'acceptance criteria', 2000), projectTaskKind: body.projectTaskKind } + const fields = { title: bounded(body.title, 'task title', 300), criteria: bounded(body.criteria, 'acceptance criteria', 2000), projectTaskKind: body.projectTaskKind, + ...(body.impact !== undefined ? { impact: bounded(body.impact, 'Project impact', 2000) } : {}), + ...(body.scope !== undefined ? { scope: bounded(body.scope, 'Task scope', 2000) } : {}) } if (!['code', 'decision', 'release'].includes(fields.projectTaskKind)) throw refusal('An explicit impactful Task kind is required', 'bot/invalid') const requestKey = hash(`${origin.agentId}\n${body.projectId}\n${requestId}`) const existing = Object.values(state.projectAgentProposals ?? {}).find(row => row.requestKey === requestKey) diff --git a/patches/ds-bot/files/src/host/workbench-state.js b/patches/ds-bot/files/src/host/workbench-state.js index 770f335c6..c78fac471 100644 --- a/patches/ds-bot/files/src/host/workbench-state.js +++ b/patches/ds-bot/files/src/host/workbench-state.js @@ -24,7 +24,10 @@ export function install(rt) { let cpu = process.cpuUsage() let measured = process.hrtime.bigint() rt.workbenchEndpoint = async (endpoint, body, actor) => { - if (endpoint === 'auth-mode') return { workbench: rt.sharedProjectMode(), mode: rt.platformIdentityEnabled ? 'platform' : 'local', panelUrl: rt.config.controlPlane?.origin ?? null } + if (endpoint === 'auth-mode') { + await rt.load?.() + return { workbench: rt.sharedProjectMode(), mode: rt.platformIdentityEnabled ? 'platform' : 'local', panelUrl: rt.config.controlPlane?.origin ?? null, writable: rt.readOnly?.() === undefined } + } if (!rt.sharedProjectMode() || !actor?.memberId) throw Object.assign(new Error('Sign in to the Project workbench'), { code: 'bot/unauthorized' }) if (endpoint === 'workbench-state') { const projects = await rt.projectEndpoint('projects', {}, actor) diff --git a/patches/ds-bot/files/test/dsh-pin.test.js b/patches/ds-bot/files/test/dsh-pin.test.js index b492468a5..7c8a7efb6 100644 --- a/patches/ds-bot/files/test/dsh-pin.test.js +++ b/patches/ds-bot/files/test/dsh-pin.test.js @@ -45,7 +45,7 @@ test('the install help and the local CI install the pinned DSH', () => { }) test('every npx command in the READMEs runs the pinned DSH', () => { - for (const file of ['README.md', 'README.zh.md']) { + for (const file of ['README.native.md', 'README.native.zh.md']) { const text = read(file) const pins = [...text.matchAll(/@deepseek-ai\/dsh@([0-9][^\s"]*)/g)].map(match => match[1]) assert.ok(pins.length >= 4, `${file}: expected the four npx commands`) diff --git a/patches/ds-bot/files/test/personal-agent-auth.test.js b/patches/ds-bot/files/test/personal-agent-auth.test.js index f0d4d96a7..7ec2edee8 100644 --- a/patches/ds-bot/files/test/personal-agent-auth.test.js +++ b/patches/ds-bot/files/test/personal-agent-auth.test.js @@ -52,15 +52,18 @@ test('safe external agent creates only a private proposal; human approval publis const { rt, http } = await setup(t) const connection = await external(rt) assert.equal((await http(connection.token, 'task-create', { projectId: 'p1', botId: 'chief', title: 'Unauthorized direct task' })).ok, false) - const proposed = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', projectTaskKind: 'code', requestId: 'proposal-1' }) + const proposed = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', impact: 'Restore account access', scope: 'Login validation', projectTaskKind: 'code', requestId: 'proposal-1' }) assert.equal(proposed.ok, true, JSON.stringify(proposed.error)) + assert.equal(proposed.value.proposal.fields.impact, 'Restore account access') + assert.equal(proposed.value.proposal.fields.scope, 'Login validation') assert.equal(Object.keys(rt.state.tasks).length, 0) assert.equal((await http(connection.token, 'project-agent-proposal-apply', { projectId: 'p1', proposalId: proposed.value.proposal.id })).ok, false) const approved = await api(rt, 'project-agent-proposal-apply', { projectId: 'p1', proposalId: proposed.value.proposal.id }) assert.equal(approved.task.requesterId, 'alice') assert.equal(approved.task.personalAgentSource.agentId, connection.agent.id) assert.equal(rt.state.tasks[approved.task.id].personalAgentSource.agentId, connection.agent.id) - const replay = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', projectTaskKind: 'code', requestId: 'proposal-1' }) + assert.equal(rt.state.tasks[approved.task.id].projectTask.requirements.impact, 'Restore account access') + const replay = await http(connection.token, 'personal-agent-propose-task', { projectId: 'p1', title: 'Fix login', criteria: 'Login succeeds', impact: 'Restore account access', scope: 'Login validation', projectTaskKind: 'code', requestId: 'proposal-1' }) assert.equal(replay.value.proposal.id, proposed.value.proposal.id) }) diff --git a/scripts/backend-lib.mjs b/scripts/backend-lib.mjs new file mode 100644 index 000000000..9f48c0e7a --- /dev/null +++ b/scripts/backend-lib.mjs @@ -0,0 +1,95 @@ +// Source deployment of the existing DSH/FlyBot host; no second API or executor. +import { spawnSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { writeDurableStateFile } from '../core/src/host/project-state-storage.js' +import { linkPackages } from '../docker/profile.mjs' + +export const root = resolve(import.meta.dirname, '..') +export const cliPath = join(root, 'core/dev/harness/node_modules/@deepseek-ai/dsh/lib/bin.js') +export const homeOf = options => resolve(options.home ?? process.env.FLYBOT_HOME ?? join(homedir(), '.flybot-backend')) +export function privateJson(path, value) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeDurableStateFile(path, JSON.stringify(value, null, 2) + '\n') +} +export const readJson = path => JSON.parse(readFileSync(path, 'utf8')) +export function optionsOf(argv) { + const [command = 'help', ...rest] = argv, options = {}, positional = [] + for (let i = 0; i < rest.length; ++i) { + if (!rest[i].startsWith('--')) { positional.push(rest[i]); continue } + const key = rest[i].slice(2) + if (!['home', 'config', 'project', 'json', 'request-id', 'email', 'after'].includes(key) || options[key] !== undefined || !rest[i + 1] || rest[i + 1].startsWith('--')) throw new Error('Unknown, repeated or incomplete option') + options[key] = rest[++i] + } + return { command, options, positional } +} +export function backendConfig(value = {}) { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['port', 'publicOrigin', 'bot'].includes(key))) throw new Error('Configuration accepts port, publicOrigin and bot only') + const port = value.port ?? 3080 + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('port must be between1 and65535') + if (value.bot !== undefined && (!value.bot || typeof value.bot !== 'object' || Array.isArray(value.bot))) throw new Error('bot must be a FlyBot configuration object') + const bot = { projectWorkbench: true, stateStorage: 'sqlite', projectDispatch: 'pools', ...value.bot } + if (bot.projectWorkbench !== true || bot.projectDispatch !== 'pools' || !['json', 'sqlite'].includes(bot.stateStorage) || bot.home !== undefined) throw new Error('Backend uses projectWorkbench, pool dispatch and its own persistent home') + if (value.publicOrigin !== undefined) { + const url = new URL(value.publicOrigin) + if (url.origin !== value.publicOrigin || url.protocol !== 'https:') throw new Error('publicOrigin must be one HTTPS origin') + } + return { port, ...(value.publicOrigin ? { publicOrigin: value.publicOrigin } : {}), bot } +} +export function installDependencies() { + for (const directory of ['core', 'core/dev/harness']) { + const manifest = readJson(join(root, directory, 'package.json')) + const dependencies = { ...manifest.dependencies, ...manifest.devDependencies } + const ready = Object.entries(dependencies).every(([name, version]) => { + try { return readJson(join(root, directory, 'node_modules', name, 'package.json')).version === version } catch { return false } + }) + if (ready) continue + const result = spawnSync('npm', ['ci', '--no-fund', '--no-audit'], { cwd: join(root, directory), stdio: 'inherit' }) + if (result.status !== 0) throw new Error(`Dependency installation failed in ${directory}`) + } +} +export function prepareBackend(home, config) { + const profile = join(home, 'profiles/web') + const packages = ['core', 'plugins/social', 'plugins/tree', 'plugins/timeline', 'plugins/workflow'].map(directory => ({ name: readJson(join(root, directory, 'package.json')).name, dir: join(root, directory) })) + const manifest = join(profile, 'package.json') + const linked = linkPackages(existsSync(manifest) ? readJson(manifest) : undefined, packages) + const version = readJson(join(root, 'core/dev/harness/package.json')).dependencies['@deepseek-ai/dsh'] + for (const name of ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) linked.dependencies[name] = version + privateJson(manifest, linked) + const workspace = join(profile, 'pnpm-workspace.yaml') + if (!existsSync(workspace)) writeFileSync(workspace, `packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n\nonlyBuiltDependencies:\n - '@deepseek-ai/dsh-subprocess-local'\n - '@google/genai'\n - koffi\n - node-pty\n - protobufjs\n`, { mode: 0o600 }) + mkdirSync(join(home, 'workspace'), { recursive: true }) + privateJson(join(home, 'backend.json'), config) + writeFileSync(join(home, 'backend.patch.yml'), readFileSync(join(root, 'docker/web.patch.yml'), 'utf8') + '\n- id: bot\n config: ' + JSON.stringify(config.bot) + '\n', { mode: 0o600 }) + chmodSync(join(home, 'backend.patch.yml'), 0o600) + return profile +} +export async function connection(home, operator = false) { + const transport = readJson(join(home, 'private/backend-transport.json')) + const url = new URL(transport.loginUrl) + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.pathname !== '/' || !url.searchParams.get('token') || url.origin !== transport.upstreamOrigin) throw new Error('Invalid local DSH transport; restart this backend') + const base = new URL(transport.baseUrl) + if (base.protocol !== 'http:' || base.hostname !== '127.0.0.1' || base.pathname !== '/' || base.search || base.username || base.password) throw new Error('Invalid local backend transport') + if (!operator) return { baseUrl: transport.baseUrl, cookie: '', ...(transport.publicOrigin ? { publicOrigin: transport.publicOrigin } : {}) } + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(10000) }) + const cookie = response.headers.getSetCookie().map(row => row.split(';')[0]).join('; ') + if (!cookie) throw new Error('DSH transport sign-in failed; restart this backend') + return { baseUrl: url.origin, cookie } +} +export async function callApi(transport, endpoint, payload, sessionToken) { + const response = await fetch(`${transport.baseUrl}/api/bot`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { 'content-type': 'application/json', cookie: transport.cookie, ...(transport.publicOrigin ? { host: new URL(transport.publicOrigin).host, origin: transport.publicOrigin } : {}), ...(sessionToken ? { 'x-flybot-member-session': sessionToken } : {}) }, body: JSON.stringify({ endpoint, payload }) }) + if (!response.ok) throw new Error(`Backend HTTP${response.status}`) + const result = await response.json() + if (!result.ok) throw new Error(`${result.error?.code ?? result.error ?? 'Backend request refused'}: ${result.error?.message ?? 'Check current permissions and request'} `) + return result.value +} +export function requestBody(command, name, options) { + const payload = options.json ? JSON.parse(options.json.startsWith('@') ? readFileSync(options.json.slice(1), 'utf8') : options.json) : {} + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('JSON payload must be an object') + if (command === 'request') return { endpoint: name, payload } + if (!options.project || command === 'command' && !options['request-id']) throw new Error('Use --project and a stable --request-id for commands') + return { endpoint: 'project-development', payload: { version: 'project-development/v1', kind: command, name, projectId: options.project, + ...(command === 'command' ? { requestId: options['request-id'] } : {}), payload } } +} diff --git a/scripts/backend-resolve.mjs b/scripts/backend-resolve.mjs new file mode 100644 index 000000000..4a03ace78 --- /dev/null +++ b/scripts/backend-resolve.mjs @@ -0,0 +1,10 @@ +// Linked source packages use the installed production profile's pinned DSH peers. +import { registerHooks } from 'node:module' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +const root = pathToFileURL(resolve(import.meta.dirname, '..')).href + '/' +const anchor = pathToFileURL(resolve(process.env.DSH_HOME, 'profiles/web/package.json')).href +registerHooks({ resolve(specifier, context, next) { + return next(specifier, specifier.startsWith('@deepseek-ai/') && (context.parentURL?.startsWith(root + 'core/') || context.parentURL?.startsWith(root + 'plugins/')) + ? { ...context, parentURL: anchor } : context) +} }) diff --git a/scripts/backend.mjs b/scripts/backend.mjs new file mode 100644 index 000000000..141326ef4 --- /dev/null +++ b/scripts/backend.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, chmodSync, mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeSync } from 'node:fs' +import { join } from 'node:path' +import { createServer } from 'node:net' +import { once } from 'node:events' +import { createWorkbenchGateway } from '../core/src/host/workbench-gateway.js' +import { fileURLToPath } from 'node:url' +import { homeLock } from '../core/src/host/home-lock.js' +import { backendConfig, callApi, cliPath, connection, homeOf, installDependencies, optionsOf, prepareBackend, privateJson, readJson, requestBody, root } from './backend-lib.mjs' + +async function secret() { + if (!process.stdin.isTTY) return readFileSync(0, 'utf8').trim() + process.stderr.write('Password: '); process.stdin.setRawMode(true); process.stdin.resume() + return new Promise((resolve, reject) => { + let value = '' + const finish = error => { process.stdin.off('data', read); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); error ? reject(error) : resolve(value) } + const read = bytes => { for (const char of bytes.toString()) { if (char === '\u0003') return finish(new Error('Login cancelled')); if (char === '\r' || char === '\n') return finish(); if (char === '\u007f') value = value.slice(0, -1); else value += char } } + process.stdin.on('data', read) + }) +} +async function start(home) { + const config = backendConfig(readJson(join(home, 'backend.json'))) + if (!existsSync(cliPath) || !existsSync(join(home, 'profiles/web/node_modules'))) throw new Error('Run init before start') + const lock = homeLock(join(home, 'backend-launch.lock')) + if (lock.claim() !== undefined) throw new Error('This backend launcher is already active') + const transportPath = join(home, 'private/backend-transport.json') + let child, gateway, log, deadline, failure, startGateway, ready = false + const int = () => child?.kill('SIGINT'), term = () => child?.kill('SIGTERM') + try { + if (existsSync(transportPath)) unlinkSync(transportPath) + prepareBackend(home, config) + const reservation = createServer() + reservation.listen(0, '127.0.0.1'); await once(reservation, 'listening') + const port = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + mkdirSync(join(home, 'private'), { recursive: true, mode: 0o700 }) + const logPath = join(home, 'private/backend.log') + log = openSync(logPath, 'a', 0o600); chmodSync(logPath, 0o600) + child = spawn(process.execPath, ['--import', join(root, 'scripts/backend-resolve.mjs'), cliPath, '--profile', 'web', '--patch', join(home, 'backend.patch.yml'), '--no-open', '--port', String(port)], { + cwd: join(home, 'workspace'), env: { ...process.env, DSH_HOME: home }, stdio: ['ignore', 'pipe', 'pipe'] }) + let buffer = '' + const activate = async loginUrl => { + const upstreamOrigin = `http://127.0.0.1:${port}` + privateJson(transportPath, { baseUrl: `http://127.0.0.1:${config.port}`, ...(config.publicOrigin ? { publicOrigin: config.publicOrigin } : {}), upstreamOrigin, loginUrl }) + const transport = await connection(home, true) + const status = await callApi(transport, 'auth-mode', {}) + if (status.writable !== true) throw new Error('Backend state is read-only; inspect the private runtime log') + gateway = createWorkbenchGateway({ upstreamOrigin, upstreamCookie: transport.cookie, ...(config.publicOrigin ? { publicOrigin: config.publicOrigin } : {}) }) + gateway.listen(config.port, '127.0.0.1'); await once(gateway, 'listening') + ready = true; clearTimeout(deadline) + process.stdout.write(`Backend ready: http://127.0.0.1:${config.port}\nUse login, then request/query/command with the same --home.\n`) + } + const output = bytes => { + writeSync(log, bytes); buffer = (buffer + bytes.toString()).slice(-16000) + const found = buffer.match(/http:\/\/127\.0\.0\.1:\d+\/\?token=[^\s\u001b]+/) + if (!startGateway && found && new URL(found[0]).port === String(port)) startGateway = activate(found[0]).catch(error => { failure = error; child.kill('SIGTERM') }) + } + child.stdout.on('data', output); child.stderr.on('data', output) + deadline = setTimeout(() => { failure = new Error('Backend startup exceeded45s; inspect private/backend.log locally'); child.kill('SIGTERM') }, 45000) + process.on('SIGINT', int); process.on('SIGTERM', term) + await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (code, signal) => code === 0 || signal ? resolve() : reject(new Error('Backend stopped; inspect the private backend.log locally'))) }) + await startGateway + if (failure) throw failure + if (!ready) throw new Error('Backend exited before readiness') + } finally { + clearTimeout(deadline); process.off('SIGINT', int); process.off('SIGTERM', term) + if (gateway) { gateway.closeAllConnections(); await new Promise(resolve => gateway.close(resolve)) } + if (log !== undefined) closeSync(log) + lock.release() + } +} +async function main() { + const { command, options, positional } = optionsOf(process.argv.slice(2)), home = homeOf(options) + if (command === 'help') { console.log('Usage: node scripts/backend.mjs init|start|login|logout|events|request ENDPOINT|query NAME|command NAME --home PATH [--config FILE] [--email EMAIL] [--project ID] [--request-id ID] [--json JSON_OR_@FILE]'); return } + if (command === 'init') { + const path = join(home, 'backend.json') + const config = backendConfig(options.config ? readJson(options.config) : existsSync(path) ? readJson(path) : {}) + mkdirSync(home, { recursive: true, mode: 0o700 }) + const lock = homeLock(join(home, 'backend-launch.lock')) + if (lock.claim() !== undefined) throw new Error('This backend launcher is already active; stop it before init') + try { + installDependencies() + const profile = prepareBackend(home, config) + const result = spawnSync('npx', ['--yes', 'pnpm@10.34.6', 'install', '--no-frozen-lockfile', '--prefer-offline'], { cwd: profile, stdio: 'inherit' }) + if (result.status !== 0) throw new Error('Profile installation failed; inspect installation output and retry init') + console.log('Persistent backend initialized. Configure the real model environment, then start with the same --home.') + } finally { lock.release() } + return + } + if (command === 'start') return start(home) + if (!['login', 'logout', 'request', 'query', 'command', 'events'].includes(command)) throw new Error('Unknown command; use help') + const transport = await connection(home, command === 'login'), sessionPath = join(home, 'private/backend-session.json') + const current = existsSync(sessionPath) ? readJson(sessionPath) : {} + const api = (endpoint, payload, token = current.sessionToken) => callApi(transport, endpoint, payload, token) + if (command === 'login') { + let session + if (options.email) session = await api('platform-login', { email: options.email, password: await secret() }, undefined) + else if (current.sessionToken && await api('auth-whoami', {}).then(Boolean).catch(() => false)) { console.log('Authenticated with the existing private local session.'); return } + else { const invite = await api('auth-invite', {}, undefined); session = await api('auth-redeem', { token: invite.token, displayName: 'Owner' }, undefined) } + if (!session.sessionToken) throw new Error('Login did not return a member session') + privateJson(sessionPath, { sessionToken: session.sessionToken }); console.log(`Authenticated${session.memberId ? ` as ${session.memberId}` : ''}. Session saved privately.`); return + } + if (!current.sessionToken) throw new Error('Run login first') + if (command === 'events') { + if (!options.project) throw new Error('Select --project') + const url = new URL('/api/project-events', transport.baseUrl); url.searchParams.set('projectId', options.project) + if (options.after) url.searchParams.set('after', options.after) + const response = await fetch(url, { headers: { 'x-flybot-member-session': current.sessionToken, ...(transport.publicOrigin ? { host: new URL(transport.publicOrigin).host, origin: transport.publicOrigin } : {}) } }) + if (!response.ok) throw new Error(`Event stream HTTP${response.status}`) + for await (const bytes of response.body) process.stdout.write(bytes) + return + } + if (command === 'logout') { await api('auth-revoke', {}); unlinkSync(sessionPath); console.log('Session revoked.'); return } + if (!positional[0]) throw new Error('Select an endpoint or operation') + const request = requestBody(command, positional[0], options) + console.log(JSON.stringify(await api(request.endpoint, request.payload), null, 2)) +} +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main().catch(error => { console.error(`Backend command failed: ${error.message}`); process.exitCode = 1 })