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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs`
bae92ae5e1113534f84664b7884281b316d8b23b # prettier, 1062 files
6 changes: 3 additions & 3 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ name: Auto-merge

on:
workflow_run:
workflows: ['CI']
workflows: ["CI"]
types: [completed]
schedule:
- cron: '*/10 * * * *'
- cron: "*/10 * * * *"
workflow_dispatch: {}

# Declared on the CALLER as well as inside the reusable workflow: a called
Expand All @@ -54,6 +54,6 @@ jobs:
# SPACE-separated: the sweep word-splits this. A comma would become one
# bogus token, every dispatch would fail, and the only symptom would be
# that nothing deploys — while the sweep still reported success.
rearm_workflows: 'ci.yml'
rearm_workflows: "ci.yml"
# The reconciler. Without this, merges land and never ship (see above).
deploy_workflow: deploy.yml
7 changes: 4 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ name: Deploy

on:
workflow_run:
workflows: ["CI"] # the job in ci.yml
workflows: ["CI"] # the job in ci.yml
types: [completed]
branches: [main] # a PR's CI is not a ship signal — filter it out
# here rather than creating a run that only skips
branches:
[main] # a PR's CI is not a ship signal — filter it out
# here rather than creating a run that only skips
# Ships whatever is on main right now. Required, not a convenience: a CI run
# that GITHUB_TOKEN started (the auto-merge re-arm) emits NO workflow_run
# event, so the chain above never fires for an auto-merged PR — observed
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ name: Desktop release
on:
push:
tags:
- 'fleet-runner-v*'
- "fleet-runner-v*"
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -108,7 +108,7 @@ jobs:
- name: Setup Python 3.11 (node-gyp needs distutils)
uses: actions/setup-python@v7
with:
python-version: '3.11'
python-version: "3.11"

# Top-level install — the web app's package.json is needed so
# @home/* + @/* imports in the desktop main process resolve (those
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/fleet-certs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ name: Fleet certs
on:
schedule:
# 06:41 UTC. Before the working day, and off the hour to spread fleet load.
- cron: '41 6 * * *'
- cron: "41 6 * * *"
workflow_dispatch:

permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/fleet-refs-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,5 @@ jobs:
# A floor, because "0 problems" across 0 repos is not a pass. If the
# token ever stops seeing the fleet, that must fail rather than
# quietly report clean.
MIN_REPOS: '10'
MIN_REPOS: "10"
run: node scripts/ci/fleet-refs-audit.mjs
2 changes: 1 addition & 1 deletion .github/workflows/fleet-uptime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ name: Fleet Uptime

on:
schedule:
- cron: '*/15 * * * *' # GitHub's minimum is 5 and it runs late under load
- cron: "*/15 * * * *" # GitHub's minimum is 5 and it runs late under load
workflow_dispatch: {}

permissions:
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/selfhost-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,24 @@ on:
workflow_call:
inputs:
app:
description: 'Key in scripts/hetzner/apps.conf — SSOT for port, domain, app dir and database'
description: "Key in scripts/hetzner/apps.conf — SSOT for port, domain, app dir and database"
required: true
type: string
node-version:
description: 'Node version to build with'
description: "Node version to build with"
required: false
type: string
default: '20'
default: "20"
install-flags:
description: 'Extra flags for the install command (e.g. --legacy-peer-deps)'
description: "Extra flags for the install command (e.g. --legacy-peer-deps)"
required: false
type: string
default: ''
default: ""
package-manager:
description: 'Override lockfile detection: npm | pnpm | yarn'
description: "Override lockfile detection: npm | pnpm | yarn"
required: false
type: string
default: ''
default: ""
secrets:
HETZNER_SSH_PRIVATE_KEY:
required: true
Expand Down Expand Up @@ -231,8 +231,8 @@ jobs:
INSTALL_FLAGS: ${{ inputs.install-flags }}
# Deploys build from a lockfile CI already verified; a drifted lockfile
# should fail in CI, not be silently papered over here.
NPM_CONFIG_FUND: 'false'
NPM_CONFIG_AUDIT: 'false'
NPM_CONFIG_FUND: "false"
NPM_CONFIG_AUDIT: "false"
run: |
set -euo pipefail
case "$MGR" in
Expand Down
43 changes: 43 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Build output and vendored trees — formatting these is noise.
node_modules
.next
dist
build
out
coverage
.turbo
.vercel
*.min.js
*.min.css

# Generated during a build, so it is absent locally and present in CI — which
# makes a clean local --check no evidence at all. Contentlayer's output also
# uses import assertions, which prettier's parser rejects outright.
.contentlayer
.astro
.svelte-kit
storybook-static
test-results
playwright-report

# Lockfiles are generated; prettier would rewrite them wholesale.
package-lock.json
pnpm-lock.yaml
yarn.lock

# Markdown is deliberately out of scope for now. Prettier rewraps prose, which
# is where it is most opinionated and least useful, and it would bury the real
# diff. Remove this line when you want docs formatted too.
*.md

# desktop/ is deliberately out of scope for THIS pass, not forgotten.
#
# Everything under desktop/ is built into Fleet Runner, and desktop-release-drift
# requires that any change to it ship: bump desktop/package.json AND write a
# FLEET_RUNNER_RELEASES entry, whose stated audience is "person who installed
# Fleet Runner and wants to know what changed". For a whitespace-only build the
# honest answer to that question is "nothing", so the release should not exist.
#
# Format desktop/ in the same PR as the next real runner change, where the bump
# and the release notes are true anyway.
desktop
9 changes: 9 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf"
}
12 changes: 10 additions & 2 deletions bridge/src/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ async function notifyState(pool: Pool, userId: string): Promise<void> {
}

/** A runner SSE connection opened on cloud (box-runner) or local (desktop). */
export async function markConnect(pool: Pool, userId: string, channel: PresenceChannel = "local"): Promise<void> {
export async function markConnect(
pool: Pool,
userId: string,
channel: PresenceChannel = "local",
): Promise<void> {
await pool.query(
`INSERT INTO runner_presence (
user_id, connection_count, connected, connected_at, last_change_at,
Expand Down Expand Up @@ -44,7 +48,11 @@ export async function markConnect(pool: Pool, userId: string, channel: PresenceC
}

/** A runner SSE connection closed. Floors counts at 0. */
export async function markDisconnect(pool: Pool, userId: string, channel: PresenceChannel = "local"): Promise<void> {
export async function markDisconnect(
pool: Pool,
userId: string,
channel: PresenceChannel = "local",
): Promise<void> {
await pool.query(
`UPDATE runner_presence SET
connection_count = GREATEST(0, connection_count - 1),
Expand Down
44 changes: 30 additions & 14 deletions bridge/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,22 @@ async function handleSse(req: IncomingMessage, res: ServerResponse): Promise<voi
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Connection": "keep-alive",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // disable nginx buffering if proxied later
"Access-Control-Allow-Origin": "*",
});

// Initial "you're connected" frame so the client knows auth succeeded
// before any actual change event arrives.
res.write(`event: hello\ndata: ${JSON.stringify({ userId: auth.userId, serverTime: Date.now() })}\n\n`);
res.write(
`event: hello\ndata: ${JSON.stringify({ userId: auth.userId, serverTime: Date.now() })}\n\n`,
);

// Replay buffered events if the client sent Last-Event-ID.
const lastIdHeader = req.headers["last-event-id"];
const sinceId = lastIdHeader ? parseInt(Array.isArray(lastIdHeader) ? lastIdHeader[0]! : lastIdHeader, 10) : 0;
const sinceId = lastIdHeader
? parseInt(Array.isArray(lastIdHeader) ? lastIdHeader[0]! : lastIdHeader, 10)
: 0;
const lastSentId = isFinite(sinceId) ? replayFor(auth.userId, sinceId, res) : 0;

// Periodic heartbeat (SSE comment lines) so proxies and the OS don't
Expand All @@ -115,22 +119,33 @@ async function handleSse(req: IncomingMessage, res: ServerResponse): Promise<voi
// presence write never breaks the SSE stream itself.
const isRunner = url.searchParams.get("client") === "runner";
const channelParam = url.searchParams.get("channel");
const presenceChannel = channelParam === "cloud" ? "cloud" as const
: channelParam === "local" ? "local" as const
: "local" as const;
const presenceChannel =
channelParam === "cloud"
? ("cloud" as const)
: channelParam === "local"
? ("local" as const)
: ("local" as const);
if (isRunner) {
void presence.markConnect(pool, auth.userId, presenceChannel).catch((err) =>
console.warn(`[presence] markConnect failed: ${(err as Error).message}`));
void presence
.markConnect(pool, auth.userId, presenceChannel)
.catch((err) => console.warn(`[presence] markConnect failed: ${(err as Error).message}`));
}
console.log(`[sse] +${sub.userId.slice(0, 8)}${isRunner ? ` (runner:${presenceChannel})` : ""} (replay since=${sinceId}, conns=${subs.stats().connections})`);
console.log(
`[sse] +${sub.userId.slice(0, 8)}${isRunner ? ` (runner:${presenceChannel})` : ""} (replay since=${sinceId}, conns=${subs.stats().connections})`,
);

req.on("close", () => {
subs.remove(sub);
if (isRunner) {
void presence.markDisconnect(pool, auth.userId, presenceChannel).catch((err) =>
console.warn(`[presence] markDisconnect failed: ${(err as Error).message}`));
void presence
.markDisconnect(pool, auth.userId, presenceChannel)
.catch((err) =>
console.warn(`[presence] markDisconnect failed: ${(err as Error).message}`),
);
}
console.log(`[sse] -${sub.userId.slice(0, 8)}${isRunner ? ` (runner:${presenceChannel})` : ""} (conns=${subs.stats().connections})`);
console.log(
`[sse] -${sub.userId.slice(0, 8)}${isRunner ? ` (runner:${presenceChannel})` : ""} (conns=${subs.stats().connections})`,
);
});
}

Expand Down Expand Up @@ -190,8 +205,9 @@ server.listen(PORT, () => {
// This fresh process holds zero connections, so clear any presence rows a
// previous (possibly crashed) bridge left as connected before clients
// reconnect and re-register. See docs/architecture/connection-presence.md.
void presence.resetAll(pool).catch((err) =>
console.warn(`[presence] boot resetAll failed: ${(err as Error).message}`));
void presence
.resetAll(pool)
.catch((err) => console.warn(`[presence] boot resetAll failed: ${(err as Error).message}`));
});

// Fire-and-forget — start() runs forever (or until stop()).
Expand Down
Loading