Skip to content

Latest commit

 

History

History
901 lines (774 loc) · 54 KB

api.md

File metadata and controls

901 lines (774 loc) · 54 KB

API

All browser routes are HTML. Machine routes are JSON. Errors use:

{ "error": { "type": "quota_exhausted", "code": "FLEDGE_QUOTA_EXHAUSTED", "message": "..." } }

Browser routes

Method Path Auth Purpose
GET / public Landing or redirect to dashboard
GET /auth/github public Start OAuth
GET /auth/github/callback public OAuth callback
GET /auth/github/app/install approved Redirect to the GitHub App installation page
GET /auth/github/app/installed approved Bind an installation to the current user (falls back to a matching unbound installation)
GET /auth/github/app/callback approved Alias for the installed callback
GET /auth/github/reauthorize approved Re-authorize with repo scope for user-attributed App writes
GET /repos/new approved GitHub App installations and repository picker
POST /repos/app approved + CSRF Connect repositories through an installation
POST /api/github/webhook GitHub signature + delivery id GitHub App webhook ingestion, replay dedupe and CI/review/auto-merge follow-up
POST /auth/token public, rate-limited Sign in with a GitHub token when OAuth is not configured
POST /auth/logout session + CSRF Sign out
GET /apply session Disclaimer and information-security signature
POST /apply session + CSRF Submit the signed application once
GET /apply/status session Live queue position and status
GET /api/application session JSON status/queue for the status page
GET /dashboard approved + session Repos, quota, jobs, personal delivery metrics (?window=7 or 30)
GET /console approved + session Workspace overview (repos, jobs, quota)
POST /repos approved + CSRF, expensive rate limit Add an upstream repository; Fledge checks or creates the user's fork
GET /repos/:id owner Repo detail, including the selected role, delivery board and allowed roles (?workState=... filters the work-item list)
GET /repos/:id/ci-gate owner Read-only CI gate report: the workflows that trigger on pull_request, the test command they run, the default branch's required checks, and merged pull requests in the last 14 days that landed without a successful check. Names the file or branch it read; writes nothing to the repository
GET /repos/:id/gate owner Gate page: stored sessions, the plan each one hashed, and the create form for a confirmed plan
POST /repos/:id/gate/sessions maintainer + CSRF Store a proposed gate plan; refuses a stale baseSha and writes nothing to the repository
GET /repos/:id/gate/sessions/:sessionId owner The stored plan, hash, status, branch, PR URL and errorCode
POST /repos/:id/gate/sessions/:sessionId/confirm maintainer + CSRF Record the human decision (confirmed) after recomputing the plan hash
POST /repos/:id/gate/sessions/:sessionId/create maintainer + CSRF Apply a confirmed plan once: re-read the base branch, refuse a moved SHA, then create one branch, the gate files and one pull request. GATE_PR_ALREADY_CREATED on a session that already has a PR
GET /readyz public Database and per-repository instance readiness summary
GET /metrics Authorization: Bearer <METRICS_TOKEN> only Prometheus metrics; a production start refuses an empty METRICS_TOKEN, so there is no unauthenticated production path, and a ?token= query value is not accepted
GET /api/read/metrics approved Read-only metrics: a super admin gets the global window, users get their own personal window (?window=7 or 30)
GET /schedule approved Schedule preview page: a mode picker (interval, daily, weekdays, weekly, monthly, advanced cron), local times and DST skips
GET /api/schedule/preview approved The same preview as JSON
GET /repos/:id/schedule owner Schedule console: the stored schedule, the planner's next run, the newest decided instant (state, reason, attempts, lease) and the last success; an unknown value prints unknown
POST /repos/:id/schedule owner + CSRF Save the console form (cron or interval, zone, window); audits schedule.saved and writes nothing when the form is refused
POST /repos/:id/schedule/remove owner + CSRF Delete the schedule so the repository returns to its legacy interval; audits schedule.removed
POST /repos/:id/run owner + CSRF, expensive rate limit Enqueue a manual iterate job; plan-first repositories enqueue a plan
POST /repos/:id/rules maintainer + CSRF, destructive rate limit Save one repository rule override against its expected revision
POST /repos/:id/work owner + CSRF Create a work item
POST /repos/:id/work/:workItemId/state owner + CSRF Move a work item through the triage/assignment/review state machine
POST /repos/:id/work/:workItemId/run owner + CSRF Start a plan or iterate job for a work item
POST /repos/:id/work/:workItemId/scope owner + CSRF Queue a Product scope job for a work item
POST /repos/:id/work/:workItemId/verify owner + CSRF Queue a Test verify job for a work item with a PR
POST /repos/:id/role owner + CSRF Select maintainer or developer for this user-repository connection
POST /repos/:id/plan owner + CSRF Enqueue a plan-only job (no code, branch or PR)
POST /repos/:id/instance owner + CSRF Start the per-repository FlyBot instance
POST /repos/:id/instance/stop owner + CSRF Stop the per-repository FlyBot instance
POST /repos/:id/refresh owner + CSRF Re-check the upstream, fork and permissions
POST /repos/:id/enable owner + CSRF Enable the repository connection
POST /repos/:id/disable owner + CSRF Disable scheduling and cancel active jobs
POST /repos/:id/delete owner + CSRF Delete the Fledge connection after typing the repository name
GET /api/inbox/events approved Server-Sent Events stream of the user's inbox
POST /repos/:id/review owner + CSRF Enqueue a manual review job
GET /repos/:id/jobs/:jobId owner Job detail: evidence, cost, attribution, retry
POST /repos/:id/jobs/:jobId/retry owner + CSRF Retry a failed job, including its checkpoint
POST /repos/:id/jobs/:jobId/approve-plan maintainer + CSRF Approve a completed plan with allowed/forbidden paths
POST /repos/:id/jobs/:jobId/reject-plan maintainer + CSRF Reject a completed plan
POST /repos/:id/jobs/:jobId/execute maintainer + CSRF Execute an approved plan as an iterate job
POST /repos/:id/jobs/:jobId/recheck owner + CSRF Re-read the PR, checks, reviews and changed files; store verification and path-guard state
POST /repos/:id/jobs/:jobId/merge maintainer + CSRF Re-check paths, evidence, checks and protection, then squash-merge a Fledge PR
POST /repos/:id/delivery/merge-clean maintainer + CSRF Re-check paths, evidence and merge gates for all clean Fledge PRs, skipping blocked ones
POST /repos/:id/jobs/:jobId/cancel owner + CSRF Cancel a queued or running job
POST /repos/:id/jobs/:jobId/pause owner + CSRF Pause a queued or running job
POST /repos/:id/jobs/:jobId/resume owner + CSRF Resume a paused job from its checkpoint
GET /repos/:id/workspace owner Fledge-branded agent workspace shell
* /repos/:id/workspace/app/* owner Authenticated reverse proxy to the per-repo DSH UI
GET /repos/:id/team superadmin Team console
GET /api/repos/:id/team superadmin Team state JSON (polling fallback)
GET /api/repos/:id/team/events superadmin Server-Sent Events snapshot stream
POST /repos/:id/team/guidance superadmin + CSRF Send guidance to a role
POST /repos/:id/chief/message owner + CSRF Send guidance to the Chief from the simplified UI
GET /api/repos/:id/chief/messages owner Chief conversation snapshot (filtered/redacted)
GET /api/repos/:id/chief/events owner Server-Sent Events for new Chief messages
POST /api/repos/:id/team/answer superadmin + CSRF Answer a team question
POST /api/repos/:id/team/stop superadmin + CSRF Stop the active roles (hard when possible, otherwise a stop request)
POST /repos/:id/settings owner + CSRF Goal, target branch, role, plan-first, path constraints, merge method/protection, stage mode, budgets, model selection, bot models, cadence, enabled, reviewer interval
POST /repos/:id/delete owner + CSRF Delete the Fledge connection after typing the repository name; GitHub is untouched
GET /settings approved Account, model providers and models, API tokens
GET /admin/webhooks superadmin Recent GitHub webhook deliveries, filters and retry state
POST /admin/webhooks/:id/retry superadmin + CSRF Retry one failed delivery from its stored payload
GET /admin/metrics superadmin Product funnel, delivery efficiency, daily trend, failure breakdown, cost breakdown, actionable alerts, team participation, attribution and stage metrics (?window=7 or 30)
POST /settings/model approved + CSRF Save/clear own model
POST /settings/notifications approved + CSRF Notification email, job/question mail, per-job success mail, daily digest, test mail
POST /settings/tokens approved + CSRF Create a flg_ token
POST /settings/tokens/:id/revoke owner + CSRF Revoke a token
POST /notifications/read approved + CSRF Mark notifications read
GET /admin admin / super admin Scoped pending users, quotas, jobs and audit (admins see themselves and normal users only)
GET /admin/metrics owner Product funnel, success rate, job cost, team participation, events
POST /admin/users/:id/approve admin + CSRF Approve
POST /admin/users/:id/reject admin + CSRF Reject
POST /admin/users/:id/quota admin / super admin + CSRF Set quota in CNY; unlimited is super-admin-only
POST /admin/users/:id/role owner + CSRF, destructive rate limit Promote a normal user to admin or demote an admin
POST /admin/simulation owner + CSRF Toggle the normal-user UI preview
POST /admin/platform/stop admin + CSRF Stop all platform work: every enqueue is refused with PLATFORM_PAUSED, active jobs are paused or cancelled, one audit row records who and why. The read-only preview does not block it
POST /admin/platform/resume admin + CSRF Clear the platform stop and report the repositories the next tick will consider; it re-enqueues nothing
GET /approve/:token token Confirmation page
POST /approve/:token token Apply approve/reject

Management commands (internal)

There is no public management HTTP endpoint yet; #392 exposes one. The contract is internal (src/management/) and the console call it outlives:

POST /repos/:id/jobs/:jobId/cancel   // form adapter, carries expected_revision and idempotency_key
POST /repos/:id/jobs/:jobId/pause    // same adapter shape, behind MANAGEMENT_SERVICE
POST /repos/:id/jobs/:jobId/resume   // same adapter shape, fenced on the job
POST /repos/:id/jobs/:jobId/retry    // same adapter shape, fenced on the job
POST /repos/:id/run                  // task.create, fenced on the project
POST /repos/:id/plan                 // task.create, kind=plan
POST /repos/:id/review               // task.create, kind=review
POST /repos/:id/triage               // task.create, kind=triage
POST /repos/:id/work/:itemId/run     // task.create with a work item
POST /repos/:id/work/:itemId/scope   // task.create, kind=scope
POST /repos/:id/work/:itemId/verify  // task.create, kind=verify
POST /repos/:id/jobs/:jobId/execute  // task.create from an approved plan

A command is { operation, target: { projectId, jobId? }, arguments, expected_revision, idempotency_key, source_conversation?, source_message? }. The server fills the actor; a payload that carries an identity field is refused. task.create takes { kind, goal?, workItemId?, prUrl?, priority? }; a workItemId must belong to the project, checked again at execution time, and a priority outside 0..100 (or not an integer) is refused rather than clamped.

/execute is the only create drawn on a job page, so it is the one form whose hidden revision is the project's: cancel, pause, resume and retry fence the job they are printed on, but an execute starts a new job and must send the revision task.create compares. It also writes the job.executed audit row that links the plan to the job it started.

Outcome Meaning
succeeded The business write ran; the receipt records before/after revision and result
replay of a succeeded receipt Same key and same arguments; nothing runs again
idempotency_conflict Same key, different arguments; refused, the first receipt stands
management_revision_conflict The resource moved on; nothing was written
authorization_revoked The caller lost the capability between acceptance and execution; nothing was written
job_not_cancellable The job had already finished; the cancel operation refused
job_not_pausable The job had already finished; the pause operation refused
job_not_resumable The job is not paused; the resume operation refused
job_not_retryable The job is queued or running; the retry operation refused
invalid_arguments The command's arguments failed validation; nothing was written
work_item_not_found The named work item is not in this project
task_not_queued Jobs.enqueue refused without a code of its own
command_in_progress The receipt is accepted, running or unknown: the command is still unresolved, so replaying the same key answers this and never a success notice

An operation may also fail with its service's admission code (REPO_DISABLED, JOB_ACTIVE, BRANCH_MISSING, QUEUE_LIMIT, …). The receipt keeps that code verbatim in error_code, plus error_detail when a banner sentence interpolates a parameter, so a replay answers with the same refusal and the same sentence.

Operation names today: task.cancel, task.pause, task.create, task.resume, task.retry. The catalog is closed — an unknown operation is unknown_operation, never a fallback.

MANAGEMENT_SERVICE=off|shadow|on decides how far the task.pause adapter goes. off (the default) keeps the route that existed before it: the same write and the same outcome, with the notice now chosen through t(), so an English-locale viewer reads it in English where the old route always wrote Chinese; shadow reserves the receipt and then runs that same route, recording what the contract would have decided, so a divergence is visible in management_commands and not in the page; on runs the command and turns its receipt into the response. task.cancel, task.create, task.resume and task.retry run through the service in every mode: the switch was the pilot's safety net for one operation. A stale page in shadow still writes no receipt — the revision fence runs before the receipt insert, as it does everywhere else — and a replayed key settles nothing, so a receipt that already answered is never rewritten and the revision moves once per command.

Public API v1

Token-authenticated, versioned, and read-only today. A personal flg_ token is the only credential; a browser session is not accepted here, and a token from this surface is not accepted as a session anywhere.

Every route answers three stable failure shapes before it does any work, in the same words as the model gateway because both read the same authenticator (src/lib/apiToken.ts):

Status error.type When
401 invalid_api_key no flg_ token, an unknown hash, a revoked token, an expired expires_at, or a token whose user is not approved
403 insufficient_scope a live token whose scope is not the one the route requires; last_used_at is not written, so a refused token leaves no trace
429 rate_limited the shared API limiter refused the request

A token's scope is its whole capability. gateway reaches /v1/models and /v1/chat/completions; jobs:read reaches /api/v1/jobs. Neither substitutes for the other, and /settings mints both.

GET /api/v1/jobs

curl -H "Authorization: Bearer flg_…" 'http://localhost:8080/api/v1/jobs?limit=50'

Answers the caller's own jobs, newest first, as:

{ "data": [ { "id": "job_…", "kind": "implement", "status": "succeeded", "repo": "repo_…",
              "createdAt": "2026-11-15T03:00:00.000Z", "finishedAt": "2026-11-15T03:04:11.000Z",
              "prUrl": "https://github.com/acme/demo/pull/7", "issueUrl": null,
              "reviewScore": 91, "costUsd": 0.25 } ],
  "next_cursor": null }
  • limit is 1–100, default 50. A value outside the range is clamped, and a value that is not a number takes the default; the page size is not an error.
  • cursor is the id of the last job in the previous page. The page boundary is the sort order itself (created_at DESC, id DESC's stable pair), so a job created between two requests cannot shift a row across pages or repeat one. next_cursor is null on the last page.
  • A cursor that is not one of the caller's own job ids answers 400 with error.type: invalid_cursor; it is never resolved against another account.
  • The item carries those ten fields and nothing else. The row behind it also holds a callback hash, a platform token hash and a callback secret, so the mapper in src/app.ts is the boundary that keeps them out; no body in this section contains a token or a secret.
  • Write endpoints (POST /api/v1/jobs, pause, resume, cancel) are a later slice and a separate scope, jobs:write. v1 is read-only today.

Read-only metrics

GET /api/read/metrics

A session-authenticated, read-only dashboard contract for an external control room. window=7 (default) or window=30 selects the time window. A normal user receives only personal metrics for their own account. A super admin receives the global product, delivery, daily, team, repoCosts, modelCosts and alerts sections. The route never mutates state.

Schedule preview

GET /api/schedule/preview

Session-authenticated and read-only: it walks the planner and stores nothing. Query parameters:

  • cron or every — the legacy pair; exactly one. cron is a five-field expression; every is an interval with a unit, such as 90s, 15m, 2h or 1d, between one minute and 30 days.
  • mode — the friendly form used by the page: interval, daily, weekdays, weekly, monthly or custom. It builds the same planner input:
    • interval reads every_amount (1..10080) and every_unit (m, h, d) and ignores timezone, because an interval is absolute time.
    • daily, weekdays, weekly and monthly read time as HH:MM, timezone (default Asia/Shanghai), and weekday (0=Sunday..6=Saturday) or month_day (1..31) where the mode needs one. The page itself names each panel's clock after its mode — daily_time, weekdays_time, weekly_time, monthly_time — because all four panels are in one form and a shared name would arrive as an array; the mode's own field wins, and time is the fallback and the name for API callers.
    • custom reads cron; the timezone defaults to Asia/Shanghai when omitted. A mode ignores the legacy cron/every values, so switching between modes in the page cannot leave a stale expression in the request.
  • timezone — an IANA zone, required for the legacy cron form and refused for the legacy every form. The friendly modes default to Asia/Shanghai.
  • from — an ISO 8601 instant with an offset; defaults to now.
  • start_at, end_at — optional [start_at, end_at) window, same shape as from.
  • count — 1..20, default 5.

A bare date and time without an offset is refused: it names a different instant in each zone. A refusal is HTTP 400 with { "error": "<reason naming the field>" }; the sentence follows the request locale (fledge_lang or Accept-Language) because the planner authors it in both languages, and a request that names neither gets Chinese, the app default. Success is { schedule, from, count, times, truncated }, where each entry carries at (ISO), atMs, local, offset, timezone and skipped — the wall times that did not exist on the way there, which the planner skipped rather than shifted. local, offset and timezone are null for an interval, and truncated is true when the window ended before count.

Email notifications

Job lifecycle mail is sent from MAIL_FROM (production: fledge@hugoxk.online) when a job finishes, fails, or a team question is waiting. The user can set a notification email (blank uses the GitHub email), disable job mail, disable the daily digest, or send a test mail in Settings → Email notifications. Approval and quota mail are not affected. A question is mailed once per sessionId + questionId; the preference is stored on the user.

Inbound email

POST /api/inbound/email accepts a JSON message from a mail worker: { "from": "...", "to": "...", "subject": "...", "text": "..." }. It requires X-Fledge-Email-Secret to match INBOUND_EMAIL_SECRET, extracts the +token from the recipient, checks the sender against the user's notification email, and sends the cleaned text to the Lead as guidance. Tokens expire after 7 days; a reply to the daily digest is the only supported input.

Workspace

GET /repos/:id/workspace renders the Fledge shell and embeds /repos/:id/workspace/app/. The app path is an authenticated same-origin reverse proxy to the per-repository DSH instance: Fledge verifies the session and repository ownership, fetches the DSH cookie server-side, strips upstream Set-Cookie/CSP/frame headers, rewrites the HTML title/base and injects the Fledge brand, and proxies WebSocket upgrades. The raw DSH token is never sent to the browser.

Team endpoints

GET /api/repos/:id/team/events is an admin-only SSE stream. It emits a snapshot event when roles, questions, jobs or quota change, and a heartbeat comment otherwise. The snapshot includes bots (with tool, step, since, lastActivityAt), questions (with id, sessionId, question, detail, options, multiSelect), timeline (active/ended role activity), jobs (summary, status, issue/PR and attribution) and quota. Clients should fall back to the 5-second JSON poll if the stream drops.

POST /api/repos/:id/team/answer takes JSON:

{ "sessionId": "session-…", "questionId": "q1", "selected": ["Fix the bug"], "custom": "" }

The Fledge inbox uses the equivalent form endpoint POST /repos/:id/team/answer (owner + CSRF) with sessionId, questionId, selected, custom and a safe returnTo path; it redirects back with a localized notice or error. POST /repos/:id/team/stop is the admin-only form counterpart for the workspace shell: it takes target (empty for every working role) and returnTo, applies the same stop logic as the JSON API, and redirects with an honest hard-stop/request note.

POST /api/repos/:id/team/stop takes { "target": "lead" } or an empty target to stop every role that is currently thinking/working. The response distinguishes stopped (hard cancel) from requested (stop message delivered; it takes effect at the next safe point). Both endpoints require the x-csrf-token header.

GitHub webhook follow-up

X-Hub-Signature-256 is verified against GITHUB_APP_WEBHOOK_SECRET, and while an overlap window is open, also against GITHUB_APP_WEBHOOK_SECRET_PREVIOUS until GITHUB_APP_WEBHOOK_SECRET_PREVIOUS_UNTIL (#466). A signature that matches only the retired value after that instant is a 401 like any other and records one app.webhook_secret_superseded audit row; docs/security.md carries the rotation runbook.

A verified check_run/check_suite failure or a pull_request_review with changes_requested can enqueue a follow-up job when the PR URL matches an existing Fledge job for a repository with the same installation. The follow-up reuses the original branch and PR URL, skips a second PR/issue, and is deduped by repo + PR + head SHA + fingerprint(reason, review body) and by X-GitHub-Delivery. The changes_requested body is clipped to one line of at most 1000 characters, with an ellipsis marking the cut, and carried in the job goal, so two different reviews on one commit are two follow-ups while the same review twice is still a duplicate; a repo with an active job parks each pending request separately instead of overwriting it. Arbitrary PRs with no Fledge job are ignored.

A default-branch workflow_run.completed failure is recorded in workflow_failures. For a queued row, Fledge lists the run's jobs (GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs) and reads the failed job's log (GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs), capping the body at 8 MiB and following a signed redirect without the GitHub token. Only a redacted 4,000-character excerpt is stored; a fetch failure is stored as a reason on the row and the intake stays. This path reruns nothing and opens no issue.

Duplicate-work preflight

Before an iterate job is enqueued, Fledge reads the upstream repository's open pull requests (GET /repos/:owner/:name/pulls?state=open, three pages) and looks for one that references the job's issue — in the pull request body, title or head branch, as a bare #N or as an /issues/N URL for that same repository. A cross-repository reference such as other/repo#42, other/repo #42 or https://github.com/other/repo/issues/42 never matches, because team pull requests routinely cite other repositories' issues. When one does match, no job is created: the caller gets code: "DUPLICATE_WORK" with the existing pull request's URL in error, the attempt is audited as job.duplicate_work, and the job_duplicate_work event is recorded. No branch is created and no second pull request is opened.

A duplicate is one of two facts, and the preflight keeps them apart (#174). An open pull request means the work is in flight. If nothing is in flight, the same match is repeated over the merged pull requests (GET /repos/:owner/:name/pulls?state=closed, three pages, merged only), and a match there means the fix already landed: the caller gets code: "ALREADY_FIXED" instead, and the record names the evidence — the audit detail reads already fixed by <pull request url> (<merge commit>) and the event meta carries outcome=already-fixed;pr=<url>;commit=<sha>. The merged read costs up to three pages of 100, so it runs only when no open pull request matched, and the issue read happens only when a merged candidate matched. A pull request that was closed without merging is never evidence: it is dropped while the list is read.

A merged pull request that only mentions the issue is not proof the fix landed — its title, body or head branch can cite an issue it does not close. So the issue itself decides: after a merged candidate matches, the preflight reads GET /repos/:owner/:name/issues/:N once and answers ALREADY_FIXED only when the issue is closed; an open issue lets the job through and records nothing, because a mention is not a fact.

The refusal never creates a job, so it has no job page; the banner on the repository page is where a maintainer reads it, and it names the pull request as a link: the open one for DUPLICATE_WORK, the merged one for ALREADY_FIXED. The audit row and the event meta carry the same evidence as text, with the merge commit for an already-fixed answer.

The preflight is read-only and best-effort. It runs only for an iterate job that does not already carry a prUrl — a follow-up job owns its pull request — and a GitHub failure is logged and lets the job through, so an API outage cannot block dispatch. The issue number comes from the work item's issue URL, falling back to the goal text, and is read only from a reference to the same repository; a dispatch with no such reference makes no GitHub call.

A pull request body longer than 8,000 characters is matched at both of its ends — its first and last 4,000 characters, joined by a newline — because a closing Fixes #N line sits at the top of a description or at its bottom. Only the reference is matched: the body is not stored and not rendered, and the audit line and the job_duplicate_work event carry the pull request URL.

Changed-file listing and the path guard

GET /repos/:owner/:name/pulls/:number/files answers 100 entries per page, so a pull request that changes thousands of files cannot be read in one request. The listing walks up to 30 pages (3,000 files) and returns { files, complete } together with changedFiles from the pull request itself (changed_files), so the guard can compare what it saw with what GitHub reports.

Each entry in that listing carries the patch GitHub returns for the file when it returns one, truncated by GitHub at its own limit and absent for a binary file. The path guard never reads it; it matches filenames only, so an entry with a patch and one without are judged the same. The field exists because the last gate before an automatic merge (the adversarial pass in src/lib/adversarialReview.ts) reviews the diff itself rather than the author's summary, and it takes the text from this listing.

A list that stops short is never an all-clear: checkPathGuard returns ok: false with checkedFiles, reportedFiles and truncated, the merge path refuses with that reason, and the job page prints Checked X / Y reported files (已检查 X / 报告 Y 个文件) instead of a pass. A pull request above the cap is refused rather than judged, which is deliberate — an unread file is not a permitted one. Rows stored before these fields existed keep their old sentence and are upgraded on the next check, so there is no migration.

PullRequestInfo also carries the pull request body, which the reader never interprets: the job page shows the three sections the agent wrote into it. pullRequestDiffTotals reduces the same listing to { files, additions, deletions } for that page, and reports no totals when the walk was capped — the same rule as the path guard, stated once: a partial listing is unavailable, never rounded down and presented as the change.

Job endpoints

These are called by the FlyBot bridge/agent, not by a browser.

GET /api/jobs/:id/credential

Headers: Authorization: Bearer <callbackSecret>

{
  "token": "github_pat_…",
  "owner": "octo",
  "name": "demo",
  "defaultBranch": "main",
  "upstream": { "owner": "octo", "name": "demo", "defaultBranch": "main", "htmlUrl": "https://github.com/octo/demo" },
  "fork": { "owner": "alice", "name": "demo", "defaultBranch": "main", "htmlUrl": "https://github.com/alice/demo" },
  "isFork": true,
  "permission": "write",
  "repoRole": "maintainer",
  "autoMerge": true,
  "canReview": true,
  "canCreateIssue": true
}

owner/name is always the upstream. When the token belongs to the upstream owner, fork equals upstream and isFork is false. Otherwise the agent must push to fork and open the PR against upstream. repoRole is the role the user selected for this repository; autoMerge is true only for an iterate job when that role is maintainer and the repository auto-merge switch is on. Only while the job is queued or running.

A triage job is a read-only workflow, and this response is where that is enforced rather than described: its token is a GitHub App installation token narrowed to contents, issues and pull_requests read, never the user's repo-scoped OAuth token, which cannot be narrowed. When the repository has no installation — so no narrower token can be minted — the read is refused with 403 and a TRIAGE_READ_ONLY_TOKEN error instead of returning a write credential, and the refusal is audited as job.triage_token_unavailable. autoMerge is always false for triage.

Auto-merge only ever targets the pull request this job produced: the callback's prUrl must be this repository's own pull request — scheme and host equal to the recorded upstream_html_url/fork_html_url origin, or, when no html_url was recorded, to the web host derived from GITHUB_API_BASE — and the PR's head branch must be the job's branch with its base equal to the job's base branch. A URL that does not match, or a PR that does not belong to the job, is skipped without a merge and recorded as job.merge_skipped with the specific mismatch (host, repository, head or base).

The job state decides the automatic merge as well: for each pull request only the newest iterate job for that PR may merge, and only after it has succeeded (created_at DESC, rowid DESC as the tie-break). A queued, running, failed, cancelled or paused follow-up therefore holds the pull request back — an older succeeded job for the same PR never merges past its newer attempt. The scheduled reconciler skips those PRs, and POST /repos/:id/delivery/merge-clean reports them in skipped with the job state instead of merging them. A pull request that is already merged is not a candidate at all: no new iterate job for it can bring it back, and the bulk merge neither re-checks it nor counts it as skipped. Each skipped entry carries its reason, so the operator message tells a job-state skip apart from one where checks or branch protection refused the merge.

GET /api/jobs/:id/context

Headers: Authorization: Bearer <callbackSecret>

Serves the exact rule or skill text recorded on that job, so a running job can read a skill it was only told about at enqueue time:

GET /api/jobs/job_abc/context
                    -> {"commit": "<sha>", "files": [{"path": ".fledge/rules.md", "kind": "rule", "sha256": "…", "bytes": 1210}]}
GET /api/jobs/job_abc/context?path=.agents/skills/release-notes/SKILL.md
                    -> 200 text/plain with the recorded body and `x-content-sha256`

Without path it answers the manifest recorded at enqueue. With path it serves one recorded file and refuses anything else: 400 for a path over 300 bytes or containing a NUL, 404 for a path this job did not record, 409 once the job is no longer queued or running, 401 for a secret that is not this job's. The body comes from job_context_files, never from a live read, so a commit pushed after the job started cannot change what the job is told. Each served file is recorded as a job.context_read job event with the path, and the response is no-store. The endpoint is rate limited per job and caller.

POST /api/jobs/:id/callback

Headers: Authorization: Bearer <callbackSecret>, Content-Type: application/json

{
  "status": "succeeded",
  "branch": "fledge/fix-123",
  "prUrl": "https://github.com/octo/demo/pull/42",
  "issueUrl": "https://github.com/octo/demo/issues/41",
  "summary": "Fixed the off-by-one in the CSV splitter; added a regression test.",
  "error": "",
  "attribution": {
    "writtenBy": "Dev-1",
    "claimedBy": "Lead",
    "implementedBy": "Dev-1",
    "testedBy": "Test",
    "reviewedBy": "Reviewer"
  },
  "evidence": {
    "scope": "Fix the CSV splitter; do not change the public API",
    "acceptance": "A quoted comma stays one field",
    "testPlan": "Add a failing parser test, then a passing one",
    "testRed": "node --test tests/unit/csv.test.mjs — 1 fail",
    "testGreen": "node --test tests/unit/csv.test.mjs — 1 pass",
    "tests": "node --test tests/unit/foo.test.mjs — 12 pass",
    "checks": "CI green",
    "files": 3,
    "risk": "low",
    "rollback": "revert the commit",
    "findings": [
      { "rule": "js/path-injection", "path": "src/app.ts", "line": 120 }
    ]
  },
  "checkpoint": "implemented the parser; next: add the rejection test"
}

Review jobs use kind=review and return reviewScore (0–100) instead of a branch/PR:

{ "status": "succeeded", "summary": "Reviewed 6 PRs; average 78.", "reviewScore": 78 }

status may be running, succeeded or failed. A running callback is a progress heartbeat: it updates summary, checkpoint and optionally branch, attribution or evidence without finishing the job or extending JOB_TIMEOUT_MINUTES. checkpoint is shown on the job page and is injected into the goal when a failed job is retried. A plan job writes the plan to summary and must not create a branch, issue or PR; the super admin can execute it later as an iterate job. evidence is stored structurally and shown on the job detail page; the Fledge automation comment stays compact. evidence.findings is the security-finding report: up to 100 { rule, path, line, state? } entries, with rule at most 120 characters, path at most 200, a positive integer line and state one of open/dismissed. An entry that breaks any of that, or repeats an id, is dropped and counted in evidence.findingsDropped; the ledger keeps one row per id and is described in security.md. With repos.stage_mode = 'enforced', an iterate callback must also include scope, acceptance, testRed, testGreen and Dev/Test attribution, and a review callback must include a Reviewer and reviewScore; otherwise the job fails with STAGE_EVIDENCE_MISSING and does not auto-merge. shadow records the same check as an event without blocking. On success, Fledge adds a collapsible automation block to the PR with the job, team and the attribution steps that actually happened, so humans can see which role claimed, implemented, tested and reviewed the change without a fabricated "pending" row. A finished job revokes its job-scoped model token.

A prUrl or issueUrl is stored only when it names this job's repository: https, the origin prOrigin derives from the repository row (its upstream_html_url, else fork_html_url, else the web host behind GITHUB_API_BASE, which drops the api. label and any path) and the path <owner>/<name>/pull/<n> or <owner>/<name>/issues/<n>. That is the same origin rule the merge path uses, so the accepted host is never a literal github.com. A present value that cannot be placed is treated as no link (pr_url/issue_url become NULL) and the callback still answers 200 {"ok":true}, because a 400 would make the bridge retry the same delivery forever; a field that is absent leaves the stored value alone. The same rule applies to followUps[].prUrl: an unplaceable entry is dropped, not queued.

Callback idempotency

A terminal callback is keyed by the state transition, not by a delivery id. Fledge normalises the report and stores a fingerprint of it on the job, so the answer distinguishes a replay from a contradiction:

  • the delivery that wins the transition (queued/running → succeeded/failed) runs the side effects exactly once: the review follow-up enqueue, the work-item move and the auto-merge;
  • replaying the same report after the job finished answers 200 {"ok":true,"duplicate":true} and writes no second event, notification or merge;
  • a contradicting report (a different prUrl, a later failed, …) answers 409 {"error":"job already finished"} and changes nothing;
  • a terminal report that arrives after JOB_TIMEOUT_MINUTES (default 90) answers 409 {"error":"job timed out"}: the reaper owns that decision, so a late success does not rewrite a timed-out run. The bridge does not retry a 409, so that result is lost by design — see bridge/README.md;
  • a heartbeat ({"status":"running"}) is accepted only for a job that is already running. For a queued job it answers 409 and does not refresh last_callback_at, because a heartbeat may not promote a row that no worker has been dispatched for;
  • a payload rejected with 400 (bad branch) does not refresh last_callback_at either.

Heartbeats extend the stall window (JOB_STALL_MINUTES, default 30) but never the hard timeout (JOB_TIMEOUT_MINUTES, default 90), and they never rewrite started_at. A credential read after the job finished still answers 409.

Model gateway

OpenAI-compatible; base path /v1.

Every gateway route answers three stable failure shapes before it does any work:

Status error.type When
401 invalid_api_key no flg_ token, an unknown hash, a revoked token, an expired expires_at, or a token whose user is not approved
403 insufficient_scope a live token whose scope is not gateway; last_used_at is not written, so a refused token leaves no trace
403 invalid_job_token a token whose name claims job:<id> but whose kind is not job, or whose job belongs to another user; decided before the upstream call, so a forged name spends no quota
429 rate_limited the per-token or per-IP limiter refused the request

Neither refusal echoes the token. A personal token always carries an expires_at (#355); only an operator token created by an admin may be minted without one, and PAT_EXPIRY_DAYS (default 90) sets the default lifetime.

A chat call records its model and its cost on a job only when the token's kind is job and the job belongs to the token's user (#481). The job: prefix in the name is a label, not the binding; a token that claims a job it does not own is refused with invalid_job_token before the provider is called.

GET /v1/models

curl -H "Authorization: Bearer flg_…" http://localhost:8080/v1/models

POST /v1/chat/completions

curl http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer flg_…" \
  -H "Content-Type: application/json" \
  -d '{"model":"your-model","messages":[{"role":"user","content":"hi"}],"max_tokens":64}'

Response headers include:

  • x-fledge-quota-remaining-cny
  • x-fledge-top-up-url

When the platform quota is exhausted:

{
  "error": {
    "type": "quota_exhausted",
    "code": "FLEDGE_QUOTA_EXHAUSTED",
    "message": "平台额度已用完,请到 Settings → Model 配置你自己的模型 API 后继续。",
    "quota": { "initialCny": 1, "usedCny": 1.0002, "remainingCny": 0 },
    "settingsUrl": "https://fledge.example/settings"
  }
}

Status code: 402.

Health

GET /healthz → { "ok": true, "service": "fledge", "version": "0.2.4", "time": "..." }.

That is the whole public answer. commit (12-character short sha) and builtAt (UTC build stamp) are served only to a caller presenting Authorization: Bearer <METRICS_TOKEN>, or to a loopback caller in a non-production build with no token configured — the same rule /metrics uses, and the same reason: the live commit is a deploy-timing oracle for anyone who can ask it. With the bearer the payload grows two fields:

{ "ok": true, "service": "fledge", "version": "0.2.4", "commit": "0123456789ab", "builtAt": "2026-11-10T00:00:00Z", "time": "..." }

commit and builtAt are always present for an authorised caller; a build that carried no stamp answers "unknown" for both. A caller without the credential gets no key at all rather than "unknown": unknown means "this build carries no stamp", so reusing it for "not authorised" would make an unstamped production build and a denied caller indistinguishable. The status stays 200 for every caller — a liveness probe is never refused. The values above are illustrative; read the real ones from the running service.

GET /readyz

Public, and answerable without a token, because a readiness probe that needs a credential fails when the credential does. The body is:

{ "ok": true, "db": true, "instances": { "ready": 2, "failed": 0, "total": 2 }, "clock": { "skewSeconds": 1680, "source": "github" } }

instances counts the per-repository instances the database knows about. clock.skewSeconds is the last measured difference between this process's clock and GitHub's Date header, in seconds, positive when the process is ahead. clock.skewSeconds is null — never 0 — when no measurement has succeeded: an unconfigured App, an unreachable GitHub, or a Date header that cannot be parsed leaves no reading, and 0 would claim a clock that was verified to be right. clock.source is always present and names where the number comes from (github), the only source there is; when skewSeconds is null it still says where the measurement would have come from, so a client never has to handle a missing key. The route reads the stored measurement and makes no GitHub call of its own, so a health probe cannot turn into traffic against the API, and an outage cannot make readiness flap. An operator watches an absent fledge_clock_skew_seconds series as "not measured", not as "aligned" (see docs/deploy.md).

The measurement itself lives on the GitHub App client (src/repos/app.ts): measureSkew() reads the Date header of one unauthenticated GET /rate_limit, records it, and leaves null behind when the request fails, the header is missing, or its value is present but unparseable (a rewriting proxy) — the last case keeps the JWT's last real correction rather than handing NaN to iat. clockSkewSeconds() returns that stored reading. The App JWT still refreshes the same figure at most every five minutes and corrects iat with it; the JWT path is unchanged by this field.

Conventions

  • JSON errors always have error.type and error.code.
  • Browser form errors redirect back with ?error=; API errors use HTTP status.
  • All timestamps are ISO-8601 UTC.
  • Money in the API is CNY unless the field says usd.

Model providers and issue commands

Method Path Auth Purpose
POST /settings/providers approved + CSRF Add a provider; HTTP/private URLs need the per-user permission or the global flag
POST /settings/providers/:id/update approved + CSRF Update a provider; existing models are re-tested before saving
POST /settings/providers/:id/delete approved + CSRF Delete a provider and its models
POST /settings/providers/:id/discover approved + CSRF Read the provider's /v1/models list from Fledge
POST /settings/providers/:id/models/bulk approved + CSRF Add selected discovered models, testing each before saving
POST /settings/providers/:id/models approved + CSRF Run a 1-token test from Fledge and save the model only on success
POST /settings/providers/:id/models/:modelId/delete approved + CSRF Delete a provider model
POST /admin/users/:id/private-models superadmin + CSRF Grant or revoke the private/HTTP model permission for a user

Issue comments from OWNER, MEMBER or COLLABORATOR accounts can run /fledge status, /fledge plan, /fledge approve or /fledge fix, plus the four job verbs /fledge retry, /fledge pause, /fledge resume and /fledge cancel. Commands are deduped by comment id and reply on the issue. /fledge approve is maintainer-only.

The job verbs act on the job attached to the issue's work item and never create a work item: retry re-queues the latest finished job (resuming its pull request when it has one), pause and cancel act on the queued or running job, and resume creates the resumed job from the paused one. A verb with no matching job refuses in the thread instead of starting one. Each one calls the same service action as the console, so it writes the existing audit action (job.retried, job.paused, job.resumed, job.cancelled) and the same job-timeline event; the pause and cancel replies report what the stop path actually did — how many live sessions were stopped, how many were asked to stop at the next safe point, that no live session was reachable, or that the stop could not be delivered — so they never claim a send that did not happen. The job is looked up by its work item, so an item whose job is older than the repository's newest twenty is still found.

Release management calls

GithubClient carries the release writes and reads used by src/releases/release.ts: commitExists (GET /commits/{sha}, false on 404, so a hand-typed SHA is a named refusal instead of a crash), listTags (paginated tags with their commit), compareCommits (status and ahead_by for GET /compare/{base}...{head}), getRepoFile (one file at a ref, null on 404), createAnnotatedTag (POST /git/tags, object = the confirmed commit) and createTagRef (POST /git/refs). createTagRef returns { ok: false, status, message } when the ref is refused instead of forcing it; a 422 becomes the tag_exists refusal, while any other status becomes the fixed the tag reference was refused by GitHub (HTTP <status>); nothing was moved or deleted, so no upstream body text reaches the page. A 422 from createAnnotatedTag is the target_missing refusal rather than an exception. No call here updates or deletes a ref.

The readiness view adds two more reads and one additive field. getCommitMeta (GET /commits/{ref}) returns one commit's SHA and committer date, and null on 404, so a tag that stopped resolving becomes a named "could not read" instead of an exception. listMergedPullRequestsSince pages GET /pulls?state=closed in updated_at order, keeps only entries merged at or after one instant and carries their labels, and reports complete: false only when the page cap was reached while every row seen was still inside the window: a pull request merged before the window can appear on an early page after a comment, so the filter runs per entry rather than stopping at the first old row. listDirectory also returns each entry's blob sha, which is how a changelog.d fragment that was edited is told from one already released.

Workflow tracking uses bounded reads and one draft-only write: listWorkflowRunsForRef (up to three pages of GET /actions/runs?branch=<tag>, so other workflows cannot push the tag's run out of the window), listWorkflowJobs (up to three pages of GET /actions/runs/{id}/jobs; the client default stays at two, so the tracker and the failure-log intake both ask for three explicitly — a job that failed on page 3 of a 300-job run is still that run's reason), getReleaseByTag (GET /releases/tags/{tag}, null on 404), createRelease (POST /releases, draft fixed true, so this client cannot publish) and updateRelease (PATCH /releases/{id}). Only a run whose head is the tag commit and whose path is the release workflow can produce a draft; a tag without such a run is run_missing. The tracker reads an existing Release before the run: the id the row recorded is draft_exists while it is a draft and draft_published once the human publishes it, and every other Release is external_release and is never duplicated.

POST /repos/:id/release (owner only, CSRF) redirects to /repos/:id?release=1&refused=<reason> on a refusal and ?release=1&tagged=<tag> on success; a re-tag of a name that already has a repo_releases row updates that row and adds &retagged=1 so the page says the tag already existed and the record was updated. The page renders whichever outcome is present above the form. The first workflow observation is recorded against the same row, and POST /repos/:id/releases/:releaseId/check (owner only, CSRF, maintainer role) repeats it, then redirects to ?release=1&checked=<message>. The check may prepare a draft Release; it never publishes, reruns, deletes or moves anything.

GET /repos/:id/release-readiness

Owner only; no CSRF, because nothing is written. It answers whether a connected repository can be released before a version is typed, using only GETs on the installation token Fledge already holds — no tag is created, no Release is prepared, and no token with release scope is requested. The server-rendered page names the latest SemVer tag reachable from the default branch and its commit time (the highest tag is compared first, bounded to eight compare probes; running out of probes is reported as no answer rather than as "no previous tag"), the commits since that tag, the pull requests merged since it with how many are features (an enhancement or feature label, else a feat conventional title prefix, with the page naming which rule counted each one), and the changelog.d fragments present at the head with a blob the tag does not have.

A release that would not go ahead is reported with the three version-independent refusals — missing_gate, missing_release_workflow, no_new_commits — printed verbatim from releaseRefusals, the same table planRelease reads, so the page and POST /repos/:id/release cannot state different reasons for the same facts. tag_exists and not_higher stay on the write path because they need a requested version. A merged count cut by the page cap says the count may be higher, and an unreadable default branch, an undetermined previous tag or a failed GitHub read renders "the repository state could not be read" instead of inventing a ready or a refused repository.

The shared table keeps the sentences identical, not the list of checks. A version-independent blocker added to planRelease later must be added to releaseReadiness too; until it is, the page would predict ready where the write path refuses. The tag route is authoritative — the page is a read-only prediction — and the test asserts the three codes that exist today.

POST /repos/:id/release-readiness/tag

Owner only, CSRF, maintainer role: the one write on the readiness page, and the only thing it creates is an annotated tag. The form carries version, the 40-hex head the page rendered, and confirm=1. The route re-reads the default branch immediately before it writes, and a head that is not the one the form carried is refused as branch_moved, naming both commits — because a tag is never moved or deleted, so a stale target costs a version rather than a retry. A version that fails planRelease is refused in that plan's own words, including the suggested next version. A 403 or 404 from the ref write is refused as tag_scope_missing, saying the installation needs Contents: write; the 422 race stays tag_exists; no upstream GitHub body text reaches the page. The outcome travels to the page as a one-shot flash — "tag created" or the refusal — and never as a query string. Nothing else is written: no Release object, no draft notes, no workflow dispatch and no token beyond the repository's installation token. Draft notes and the Release are a later slice of #546.