Skip to content

Latest commit

 

History

History
319 lines (254 loc) · 23.8 KB

File metadata and controls

319 lines (254 loc) · 23.8 KB

forq — CONTRACTS

Read this first. This is the coordination contract for the parallel build. The foundation (this doc + @forq/shared + @forq/api-client + the app bootstraps) is done and frozen-by-default. Build agents implement inside their owned directory only and depend on these contracts verbatim. If you think a contract needs to change, change @forq/shared + this doc in one PR and announce it — every other agent depends on it.

Ground rules:

  • @forq/shared is the single source of truth. All domain types, zod schemas, API request/response shapes, RunStatus, and ForqConfig live there. Never redefine a shape locally — import it.
  • Do not edit package.json files. Every dependency each workspace needs is already declared. If something is genuinely missing, flag it.
  • Stay in your directory (ownership map below). The seams between slices are the interfaces in apps/server/src/contracts.ts (backend) and the component inventory + route map (frontend).
  • Error envelope everywhere: non-2xx responses are exactly { error: { code, message, details? } } (ApiError in shared; codes in ApiErrorCode; status mapping in ERROR_STATUS). Use apiError() / the fail() route helper.
  • ESM, TypeScript strict, noUncheckedIndexedAccess on. Import sibling files with the .js extension (NodeNext) in server/shared/cli; web uses bundler resolution.
  • The web app and the CLI both consume @forq/api-client. Build it once, well.

(a) Directory-ownership map

server-boxd            -> apps/server/src/boxd/
server-github          -> apps/server/src/github/
server-runs            -> apps/server/src/runs/
server-routes          -> apps/server/src/routes/
server-config-triage   -> apps/server/src/config/, apps/server/src/triage/
web-ui                 -> apps/web/src/ui/
web-shell              -> apps/web/src/app/
web-repos-queue        -> apps/web/src/views/repos/, apps/web/src/views/queue/
web-runs               -> apps/web/src/views/runs/
web-golden-settings    -> apps/web/src/views/golden/, apps/web/src/views/settings/
web-data               -> apps/web/src/api/, apps/web/src/mocks/
cli                    -> apps/cli/src/

Foundation-owned (do not edit without coordinating): root config, tsconfig.base.json, packages/shared/**, packages/api-client/**, apps/server/src/{index,env,contracts,services,openapi}.ts, apps/server/src/routes/{index,_shared,health}.ts (other route files are stubs for server-routes/owners to fill), apps/web/src/{main.tsx,App.tsx,styles/theme.css,vite-env.d.ts}, apps/web/vite.config.ts, apps/web/index.html.

Note on routes: the per-resource route files (boxd.ts, repos.ts, golden.ts, actions.ts, runs.ts, webhooks.ts, auth.ts) currently return 501 stubs. server-routes owns wiring them to services; the service implementations are owned by the respective server-* slice. Routes pull services off Hono context (c.get("services")) — never import concrete impl classes.


(b) Domain model + API

Domain types (all in @forq/shared)

Every name below is both a zod schema (value) and its inferred type (same identifier). import { Run } from "@forq/shared" gives you both.

Enums (enums.ts)

  • RunStatusqueued | triaging | provisioning | implementing | testing | pr_opened | needs_human | aborted | failed. TERMINAL_RUN_STATUSES, isTerminalRunStatus() provided. The lifecycle.
  • ActionKindpreview | implement | triage | github
  • FixStrategysingle-shot | speculative
  • CodingAgentclaude-code | opencode | aider
  • TargetTypeissue | pr
  • GithubOpclose | comment | label
  • FundingModeowner-funded | contributor-byo
  • GoldenStatusnot_configured | provisioning | ready | syncing | error
  • TreeNodeStatusexploring | passing | failing | pruned (drives ForkTree colors)
  • TriageSuggestedActionimplement | preview | comment | close | needs_human | skip
  • IssueStateopen | closed | all
  • RunEventTypestatus | log | tree | result | error | heartbeat (SSE discriminator)
  • LogLeveldebug | info | warn | error

Common (common.ts) — RepoId, RunId, InstallationId, Timestamp (ISO string), ApiErrorCode, ApiError, apiError(), ERROR_STATUS, PaginationQuery, paginated(item), Paginated<T>, HEADERS.

Repo (repo.ts) — GitHubActor {login, avatarUrl, htmlUrl}, Repo, RepoDetail (Repo + branches/topics/language).

Issue/PR (issue.ts) — Label {name,color,description}, QueueItem (unified issue/PR row; PR-only fields optional), IssueDetail.

Golden (golden.ts) — GoldenDeployConfig, GoldenConfig {install,build,start,port,branch,deploy}, Golden (status + config + boxId + previewUrl + sync info).

Config (config.ts) — TriageConfig, TestConfig, ActionsConfig, FundingConfig, ForqConfig (the forq.yml schema), ResolvedForqConfig {repoId, source, path, sha, config, raw}.

Run (run.ts) — ActionTarget {type, number, ref?}, TriageVerdict {act, confidence, reason, suggestedAction, labels, duplicateOf, model}, TreeNode, TreeEdge, ForkTree, RunPhase, RunResult, Run, RunEvent, SSE_EVENT_NAME ("run-event").

Boxd / sessionBoxdStatus, Session ({authenticated, user, installationIds, boxdConnected, appInstallUrl}appInstallUrl is the GitHub App's …/installations/new URL, derived server-side from the App's own metadata; null in mock or when unresolved. The "no repositories" empty state links here).

Endpoints (endpoints.ts) — API_BASE_PATH (/api/v1), ENDPOINTS registry (id/method/path/idempotent/sse/summary per endpoint), buildPath(path, params), EndpointId, HttpMethod.

The Run shape (load-bearing)

Run = {
  id, repoId, action: ActionKind, status: RunStatus,
  target: ActionTarget, strategy: FixStrategy|null, agent: CodingAgent|null,
  funding: FundingMode|null, title: string|null,
  timeline: RunPhase[],            // lifecycle transitions, for the timeline UI
  result: RunResult|null,          // terminal artifacts (prUrl, previewUrl, diff, triage, question…)
  previewUrl: string|null, prUrl: string|null,   // list-render mirrors of result
  hasTree: boolean,                // true when strategy === 'speculative'
  boxId: string|null, error: string|null, idempotencyKey: string|null,
  createdAt, updatedAt, startedAt: string|null, finishedAt: string|null,
}

RunEvent (SSE payload)

Discriminated by type. Wire frame: event: run-event, data: <JSON RunEvent>, id: <seq>. seq is monotonic per run (use as Last-Event-ID to resume).

RunEvent = {
  type: RunEventType, runId, seq, at,
  status?,                         // type === 'status'
  level?, message?, source?,       // type === 'log'
  node?, edge?,                    // type === 'tree'
  result?,                         // type === 'result'
  error?,                          // type === 'error'
}

API endpoint table — REST/JSON under /api/v1

Request/response columns name @forq/shared schemas. POSTs marked idem honor an Idempotency-Key header. Errors: ApiError envelope with the status from ERROR_STATUS.

Method Path Req body / query Response Description
GET /health HealthResponse Liveness (the one live endpoint in the foundation)
GET /boxd/status BoxdStatusResponse (BoxdStatus) boxd connection + account; mock:true when no BOXD_API_KEY
GET /repos ListReposResponse {items:Repo[]} Installed repos
GET /repos/:repoId GetRepoResponse (RepoDetail) Repo detail + golden status
GET /repos/:repoId/issues ListIssuesQuery (state,label,cursor,limit) ListIssuesResponse (Paginated<QueueItem>) Open issues, paginated
GET /repos/:repoId/pulls ListPullsQuery ListPullsResponse (Paginated<QueueItem>) Open PRs, paginated
GET /repos/:repoId/config GetConfigResponse (ResolvedForqConfig) Resolved forq.yml
PUT /repos/:repoId/golden PutGoldenRequest (GoldenConfig) PutGoldenResponse (Golden) Create/configure golden
GET /repos/:repoId/golden GetGoldenResponse (Golden) Golden status
POST idem /repos/:repoId/golden/sync SyncGoldenResponse (Golden) Resync golden to main
GET /golden ListGoldensResponse {items:Golden[]} Configured goldens (fleet index)
POST idem /golden/setup GoldenSetupRequest {repoId,branch} GoldenSetupResponse {jobId,golden} Agent-driven golden creation → job
GET sse /golden/setup/:jobId/events RunEvent stream Live setup logs (reuses RunEvent)
POST idem /actions/preview PreviewActionRequest RunResponse (Run) Spin a preview env → Run
POST idem /actions/implement ImplementActionRequest RunResponse (Run) Implement an issue → Run
POST idem /actions/triage TriageActionRequest RunResponse (Run) Triage; terminal result carries TriageVerdict
POST idem /actions/github GithubActionRequest GithubActionResponse close/comment/label (synchronous, no Run)
POST idem /actions/batch BatchActionRequest BatchActionResponse {runs:Run[]} Whole-queue burndown
GET /runs ListRunsQuery (repoId,status,cursor,limit) ListRunsResponse (Paginated<Run>) List runs
GET /runs/:runId GetRunResponse (Run) Run detail
GET (SSE) /runs/:runId/events header Last-Event-ID? text/event-stream of RunEvent Live log/status/tree stream
POST idem /runs/:runId/abort AbortRunResponse (Run) Abort a run
GET /runs/:runId/tree GetTreeResponse (ForkTree) Speculative tree snapshot (stub data ok)
POST /webhooks/github raw GitHub payload (+ signature headers) WebhookResponse GitHub App webhook receiver
GET /auth/session SessionResponse (Session) Current session (stub)
GET /openapi.json OpenAPI 3.0 doc Served at root (not under /api/v1)

@forq/api-client method ↔ endpoint map (one method each): health, boxdStatus, listRepos, getRepo(repoId), listIssues(repoId,query?), listPulls(repoId,query?), getConfig(repoId), getGolden(repoId), putGolden(repoId,body), syncGolden(repoId), previewAction(body), implementAction(body), triageAction(body), githubAction(body), batchAction(body), listRuns(query?), getRun(runId), abortRun(runId), getTree(runId), authSession(), and subscribeRunEvents(runId, onEvent, opts?) → { close } for SSE. Every method takes a final RequestOptions {signal?, idempotencyKey?, headers?}. Construct with createForqClient({ baseUrl, token?, mock? }); mock:true returns shared-shaped fixtures and animates SSE with no server.


(c) Backend service interfaces (apps/server/src/contracts.ts)

Routes depend on these interfaces only, via c.get("services") (Services). Implement your slice's interface in your directory and register the impl in apps/server/src/services.ts (replace the stub<T>() for your field). All return @forq/shared types. Helper types: VmHandle, ExecResult, ListOptions, IssueListOptions.

interface BoxdService {                          // server-boxd  (apps/server/src/boxd/)
  readonly mock: boolean;
  status(signal?): Promise<BoxdStatus>;
  createGolden(repoId, config: GoldenConfig, opts?): Promise<VmHandle>;
  getGolden(repoId): Promise<VmHandle | null>;
  syncGolden(repoId, branch, opts?): Promise<void>;
  fork(repoId, opts?): Promise<VmHandle>;        // live-fork the golden (~113ms CoW)
  forkFrom(boxId, opts?): Promise<VmHandle>;     // branch a speculative node from its parent
  get(boxId): Promise<VmHandle | null>;
  destroy(boxId): Promise<void>;
}

interface GitHubService {                        // server-github  (apps/server/src/github/)
  readonly enabled: boolean;
  listInstalledRepos(opts?): Promise<Repo[]>;
  getRepo(repoId, signal?): Promise<RepoDetail>;
  listIssues(repoId, opts?): Promise<Paginated<QueueItem>>;
  listPulls(repoId, opts?): Promise<Paginated<QueueItem>>;
  performOp(repoId, target, op, payload, signal?): Promise<{ htmlUrl: string | null }>;
  openPullRequest(input: OpenPullRequestInput, signal?): Promise<{ htmlUrl: string; number: number }>;
  verifyWebhook(headers, rawBody): Promise<VerifiedWebhook>;
  getSession(token?): Promise<Session>;
}

interface ConfigService {                        // server-config-triage  (apps/server/src/config/)
  resolve(repoId, opts?): Promise<ResolvedForqConfig>;   // fetch+parse+validate forq.yml, fall back to defaults
}

interface TriageService {                        // server-config-triage  (apps/server/src/triage/)
  readonly enabled: boolean;
  classify(input: TriageInput): Promise<TriageVerdict>;  // Nebius OpenAI-compatible fast model
}

interface RunsEngine {                           // server-runs  (apps/server/src/runs/)
  start(input: StartActionInput): Promise<Run>;          // create Run (status: queued), drive lifecycle
  startBatch(input): Promise<Run[]>;                     // one Run per target
  get(runId): Promise<Run | null>;
  list(filter: RunFilter): Promise<Paginated<Run>>;
  abort(runId): Promise<Run>;
  getTree(runId): Promise<ForkTree | null>;
  subscribe(runId, onEvent: (e: RunEvent)=>void, opts?: {afterSeq?}): () => void;  // SSE source
}

interface Services { boxd; github; config; triage; runs; }   // the bag on Hono context
// c.get("services") via AppBindings; key = SERVICES_KEY ("services").

Implementation notes for the slices:

  • server-boxd: wrap @boxd-sh/sdk's Compute (box.create/fork/get/list, Box.exec/writeFile/createProxy/setProxyPort/waitUntilReady/suspend/destroy, token.create). VmHandle is the forq-narrowed view. When !capabilities.boxd, run mock: status().mock === true, fabricate handles. Reuse the on-VM bash platform via exec for golden sync / preview / fix (see PLAN §10).
  • server-github: octokit App auth (@octokit/auth-app) → installation token; @octokit/rest for issues/PRs/PR-create; @octokit/webhooks for verifyWebhook. Map GitHub issue/PR → QueueItem. Acts as the App, never a user PAT. Stub cleanly when !capabilities.github.
  • server-config-triage: ConfigService.resolve fetches forq.yml from the repo default branch, parses YAML, validates with ForqConfig, normalizes yaml snake/kebab → camelCase, falls back to defaults. TriageService calls Nebius (NEBIUS_BASE_URL, OpenAI-compatible); when !capabilities.triage, return a deterministic act:true verdict so the pipeline flows.
  • server-runs: owns the RunStatus state machine (only place that mutates a Run's status), emits RunEvents, persists runs (in-memory map is fine for v1), implements the four action handlers + the speculative search (best-first over BoxdService.fork/forkFrom, tests as fitness, build ForkTree). Read the Idempotency-Key (router passes it through start({idempotencyKey})) and de-dupe.
  • server-routes: replace each stub handler with: validate input (shared zod schema) → call the service → ok(c, result) / fail(c, code, msg). For SSE, bridge runs.subscribe(runId, e => stream.writeSSE({event: SSE_EVENT_NAME, data: JSON.stringify(e), id: String(e.seq)})).

(d) UI component inventory (apps/web/src/ui/, owned by web-ui)

All components are monospace-forward, hairline-bordered, dark-default. Names + prop shapes are the contract — views import these by name. Use the design tokens (section e) via Tailwind utilities (bg-surface, text-strong, border-hairline, text-accent, rounded-md, semantic text-success etc.) and the helpers .ring-hairline, .focus-ring, .elev-1. Prefer forwardRef where it makes sense. Keep everything controlled.

// primitives
Button({ variant?: 'primary'|'secondary'|'ghost'|'danger', size?: 'sm'|'md', loading?: boolean,
         iconLeft?: ReactNode, iconRight?: ReactNode, disabled?, type?, onClick?, children })
IconButton({ icon: ReactNode, label: string /* a11y */, variant?: 'ghost'|'secondary'|'danger',
             size?: 'sm'|'md', loading?, disabled?, onClick? })
Card({ as?, padded?: boolean, interactive?: boolean, className?, children })          // surface + ring-hairline
Badge({ variant?: 'neutral'|'accent'|'success'|'warning'|'error'|'info', children })   // pill, tinted-muted bg
StatusPill({ status: RunStatus, size?: 'sm'|'md' })   // maps RunStatus -> color (table below); dot + label
Spinner({ size?: number, className? })
Tooltip({ content: ReactNode, side?: 'top'|'right'|'bottom'|'left', children })
EmptyState({ icon?: ReactNode, title: string, description?: string, action?: ReactNode })
CodeBlock({ code: string, language?: string, wrap?: boolean, className? })             // mono, hairline, scroll

// form
TextInput({ value: string, onChange: (v:string)=>void, placeholder?, type?, disabled?,
            invalid?: boolean, iconLeft?: ReactNode, onKeyDown?, name?, id? })
Select<T extends string>({ value: T, onChange: (v:T)=>void,
            options: { value: T; label: string }[], disabled?, placeholder? })
Checkbox({ checked: boolean, onChange: (c:boolean)=>void, indeterminate?: boolean, label?, disabled? })

// layout / overlay
Tabs({ value: string, onChange: (v:string)=>void, tabs: { value: string; label: ReactNode; count?: number }[] })
Dialog({ open: boolean, onClose: ()=>void, title?: ReactNode, children, footer?: ReactNode, size?: 'sm'|'md'|'lg' })
Toast + useToast: useToast() -> { toast: (t: { title: string; description?: string;
            variant?: 'neutral'|'success'|'error'|'info'; durationMs?: number }) => void };
            mount <ToastViewport/> once (web-shell). 
Table + Row: Table({ columns: Column<T>[], children })  // Column<T> = { key, header, width?, align?, render?(row) }
             Row({ selected?: boolean, onClick?, className?, children })
             // burndown rows animate status transitions — Row exposes data-status for motion hooks.

// the two showpieces
LogStream({ runId: string, events?: RunEvent[], autoScroll?: boolean, height?: number|string })
             // SSE-fed (subscribeRunEvents) or controlled via events[]; renders log lines + status transitions.
ForkTree({ nodes: TreeNode[], edges: TreeEdge[], winningNodeId?: string|null,
           onSelectNode?: (id:string)=>void, height?: number|string })
             // node = {id,parentId,status,hypothesis,score,...}; colors by TreeNodeStatus (section e);
             // amber exploring -> red failing / green passing -> gray pruned; the explode-then-collapse viz.

// chrome (web-shell owns instances in src/app/; web-ui owns the components)
Sidebar({ children })            TopBar({ left?, center?, right? })
ThemeToggle({})                  // toggles .dark/.light on <html>, persists to localStorage 'forq-theme'

StatusPill color mapping (RunStatus → token):

status token (utility)
queued text-status-queued (weak gray)
triaging text-status-triaging (info / purple)
provisioning text-status-provisioning (warning)
implementing text-status-implementing (accent)
testing text-status-testing (warning)
pr_opened text-status-pr-opened (success)
needs_human text-status-needs-human (info / purple)
aborted text-status-aborted (gray)
failed text-status-failed (error)

ForkTree node color mapping (TreeNodeStatus → token): exploring → node-exploring (amber/peach), passing → node-passing (green), failing → node-failing (red), pruned → node-pruned (gray).


(e) Design tokens + aesthetic

Defined in apps/web/src/styles/theme.css as CSS variables (light + dark) and wired to Tailwind v4 via @theme. Dark is the default/hero. Toggle by putting .dark or .light on <html> (pre-painted in index.html; ThemeToggle flips + persists forq-theme).

  • Accent (the ONE signature color, sparingly): light #dcde8d (chartreuse), dark #fab283 (peach). Utilities: text-accent, bg-accent, border-accent; muted fill bg-accent-muted. Focus ring = accent (.focus-ring).
  • Backgrounds (dark): base #0a0a0a / alt #101010; surfaces #151515 (bg-surface) / #1a1a1a (bg-raised). Light: base #f8f8f8 / surface #ffffff. Utilities: bg-base, bg-surface, bg-raised, bg-overlay.
  • Text (dark): strong rgba(255,255,255,0.94) (text-strong) / base 0.62 (text-base-fg) / weak 0.42 (text-weak) / faint 0.28 (text-faint). Light: strong #171717 / base #6f6f6f / weak #8f8f8f.
  • Borders — HAIRLINE: dark rgba(255,255,255,0.12..0.2), light rgba(0,0,0,0.12..0.16). Utilities border-hairline / border-hairline-strong; prefer border-as-shadow .ring-hairline (box-shadow 0 0 0 1px).
  • Semantic: success #12c905, warning #fbdd46, error #fc533a, info #a753ae (+ -muted tints). Diff: diff-add / diff-add-bg, diff-del / diff-del-bg. Status + node palettes per the tables above.
  • Radii: xs 2px, sm 4px, md 6px, lg 8px, xl 10px (rounded-xs…rounded-xl). Spacing base 0.25rem.
  • Type: mono = JetBrains Mono (PRIMARY — UI + data), fallback IBM Plex Mono, ui-monospace; sans = Inter (long prose only, opt-in via .prose). Base 14px. Weights 400/500/600. font-mono is the default; font-sans for prose.

Aesthetic: refined industrial/utilitarian developer tool. Near-monochrome warm-gray, ONE accent, hairline borders, crisp small radii, dense-but-breathable monospace data. Dark is the hero. Tasteful Motion only (lib: motion). Lavish craft on the two memorable moments: (1) the issue/PR queue burning down with smooth row state transitions; (2) the speculative fork-tree viz (universes exploding then collapsing to green). NO generic AI look, NO purple gradients, NO Inter-as-everything.


(f) Web route map (path → view → owning slice)

createBrowserRouter table lives in apps/web/src/App.tsx (foundation seeds placeholder views; each slice replaces its view). AppLayout chrome (Sidebar/TopBar/ThemeToggle/ToastViewport) is owned by web-shell in src/app/. Data hooks (useQuery wrappers around @forq/api-client) + mocks are owned by web-data in src/api/ + src/mocks/.

path view component owning slice
/ ReposView (repo list) web-repos-queue
/repos/:repoId RepoOverviewView web-repos-queue
/repos/:repoId/queue QueueView (issues/PRs burndown, multi-select, batch) web-repos-queue
/repos/:repoId/golden GoldenView (golden config) web-golden-settings
/repos/:repoId/settings RepoSettingsView (forq.yml) web-golden-settings
/runs RunsView (runs list) web-runs
/runs/:runId RunDetailView (timeline + LogStream; ForkTree when speculative) web-runs
/settings SettingsView (boxd key, account, theme) web-golden-settings
* NotFoundView web-shell

Shared frontend conventions:

  • All server state via @tanstack/react-query; one ForqClient instance (web-data provides it; foundation wires a mock one in App.tsx). Toggle live vs mock with VITE_FORQ_MOCK ("false" = live; Vite proxies /api:8787).
  • Stream a run with api.subscribeRunEvents(runId, onEvent); feed LogStream/ForkTree. Resume with lastEventId.
  • Never hardcode colors — use the token utilities. Never reach past @forq/api-client to fetch the API directly.

Build / verify

pnpm install
pnpm typecheck      # all workspaces strict
pnpm build          # shared -> api-client -> server + web + cli
pnpm dev            # server :8787 + web :5173

Foundation status: pnpm typecheck and pnpm build are green; server boots (/api/v1/health 200, stubs 501 with the envelope, /openapi.json valid, SSE frames well-formed); web builds (Tailwind v4 + tokens compile); CLI runs in mock mode end-to-end.