Skip to content
Open
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/dev-stack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Contributor dev stack

on:
pull_request:
paths:
- "scripts/dev-stack*.mjs"
- "scripts/tests/dev-stack*.test.mjs"
- "apps/**"
- "packages/**"
- "Dockerfile"
- "package*.json"
- ".github/workflows/dev-stack.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: dev-stack-${{ github.ref }}
cancel-in-progress: true

jobs:
source-stack:
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
cache: npm
- name: Install contributor prerequisites
run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep sqlite3
- name: Install locked dependencies
run: npm ci
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
- name: Check smoke failure detection
run: node --test scripts/tests/dev-stack*.test.mjs
- name: Start from source and exercise owner and CLI flows
timeout-minutes: 25
run: |
npm run dev:stack > "$RUNNER_TEMP/dev-stack.log" 2>&1 &
node scripts/dev-stack-smoke.mjs --report "$RUNNER_TEMP/dev-stack-proof.json"
- name: Remove owned resources
if: always()
run: npm run dev:stack -- reset
- name: Prove cleanup
if: always()
run: |
if [ -f "$RUNNER_TEMP/dev-stack-proof.json" ]; then
node scripts/dev-stack-smoke.mjs --verify-cleanup "$RUNNER_TEMP/dev-stack-proof.json"
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Generated outputs and local tool reports. These are safe to regenerate; do
# not commit them, and clean them only with an allowlist.
node_modules/
.dev-stack/
dist/
dist-mock/
dist-types/
Expand Down
98 changes: 97 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,105 @@ ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci

Skipping the Electron download is suitable for core development. Desktop
packaging requires its normal toolchain and separate platform acceptance.
Dependency installation may use the network; the smoke command below does not
Dependency installation may use the network; the offline smoke command described later does not
need a provider account, API key, Docker, PostgreSQL or Temporal.

## Run the full app from source

Use Node **>=22.12 <23**, npm, Git, ripgrep, SQLite 3, and Docker with Compose v2 or newer.
Docker must be running. On macOS use Docker Desktop or Colima with Docker
Compose installed; on Windows 11 run these commands inside WSL2, with Docker
Desktop's WSL integration enabled. Native Windows is not supported by this
Unix-socket/PTY development command. Keep the checkout in the WSL Linux
filesystem for reliable file watching. Linux needs the usual Python 3, Make
and C++ build tools if `node-pty` has no prebuilt binary for your platform.

After the locked dependency installation above:

```sh
npm run dev:stack
# In another terminal, from this same checkout:
npm run dev:stack -- down
npm run dev:stack -- reset
```

The first start downloads the published pgvector and Temporal images, builds
the internal packages, applies the database migrations, and installs the exact
OpenCode package pinned in `Dockerfile` into `.dev-stack/vendor`. The database
image digests come directly from the published Compose template. It then
starts the API in watch mode, Vite, the worker and the CLI host from this
checkout. The command stays in the foreground and also works without a TTY.
It prints the URL and, until an owner exists, the one-time setup token. Open
the URL, create the owner using that token, then sign in. Getting Started's
OpenCode pane can open its terminal UI without an AI provider account. Sending
a prompt may require a provider account; the dev stack does not supply one.
Provider inference checks are not enabled; terminal availability does not
certify that a model can answer a prompt.
The normal owner flow is used; the dev-login shortcut is disabled.

Default loopback-only ports are:

| Service | Port | Override before first start |
| --- | --- | --- |
| Web UI | 15411 | `SPACE_DEV_WEB_PORT` |
| API | 15410 | `SPACE_DEV_API_PORT` |
| PostgreSQL | 15432 | `SPACE_DEV_POSTGRES_PORT` |
| Temporal | 17233 | `SPACE_DEV_TEMPORAL_PORT` |
| OpenCode server | 15412 | `SPACE_DEV_OPENCODE_PORT` |

For example, `SPACE_DEV_WEB_PORT=16411 npm run dev:stack`. Ports are saved in
the checkout's state; reset before selecting different ports. Every listener
binds to `127.0.0.1`. An installed app on 4910/4911 keeps its own data and
ports. Each checkout gets a distinct Compose project and named database
volume. Passwords, session keys, wrappers, provider homes and logs live in the
git-ignored `.dev-stack/`; secret files have mode 0600. A short, private socket
directory under `/tmp/spaceapp-dev-*` avoids the macOS Unix-socket path limit.
The application and OpenCode receive isolated homes and no inherited provider
keys. Docker alone uses your existing Docker context to reach its daemon.

Changes in `apps/api/src` restart the API, and changes in `apps/web/src` use
Vite hot reload. After changing an internal `packages/*` module or worker
source, run `npm run dev:stack -- rebuild` in another terminal: this rebuilds
the packages and restarts the API, worker and CLI host. Open terminals are
interrupted by that explicit rebuild; reopen the pane afterwards.

`down` (or Ctrl-C) stops the owned processes and removes the project's
containers/network, retaining the database volume and state for next time.
`reset` also removes that project's volume, state and socket directory. These
commands never prune other Docker resources. The browser service, companion
services and root-only features stay disabled. Logs are under
`.dev-stack/logs`; unexpected process exits or prolonged failed readiness
checks stop the stack with a named error. `/readyz` requires the real worker.

If startup fails, inspect the named service log and check `docker compose
version` and `docker info`. A port-in-use error means another checkout or
process uses that port; stop that stack or reset this checkout and choose
different ports. If the owner token expires, stop and start the stack to
refresh its 15-minute setup window. A claimed owner cannot be replaced with
the token; reset deletes this development database if you need a fresh owner.
Use a checkout you own, and never point this command at an operator instance.

The CI acceptance check uses real owner authentication and a WebSocket-backed
OpenCode terminal, then verifies cleanup:

```sh
node scripts/dev-stack-smoke.mjs --report /tmp/spaceapp-dev-proof.json
npm run dev:stack -- reset
node scripts/dev-stack-smoke.mjs --verify-cleanup /tmp/spaceapp-dev-proof.json
```

Run that smoke against a fresh dev stack; it creates a disposable owner. The
report contains resource identities and process IDs, not credentials.

Runtime verification on 2026-10-23 used Node 22.23.4 on macOS arm64 and a
Linux arm64 container with Docker Desktop. Both completed owner setup,
logout/login, real Temporal worker readiness, an OpenCode terminal with output,
normal shutdown and reset cleanup. The macOS run also exercised API/CSS reload,
the rebuild command and intentional worker-exit supervision. The Linux run
verified the configured storage root through the live authenticated admin API.
This is container-based Linux evidence; bare-metal Linux and WSL have not been
verified. No provider inference was requested or tested.

## First executed task, without a provider

```sh
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1788,7 +1788,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Fastify
try { observability.observeWorkerReadiness(readiness); } catch { /* metrics never fail readiness */ }
return readiness;
};
const storageReadinessChecker = options.storageReadinessChecker ?? collectStorageReadiness;
const storageReadinessChecker = options.storageReadinessChecker ?? (() => collectStorageReadiness(config.installRoot));
const spaceCapabilityInventoryCollector = options.spaceCapabilityInventoryCollector ?? collectDefaultSpaceCapabilityInventory;
const spaceMemorySearcher = options.spaceMemorySearcher ?? ((q) => searchDefaultSpaceMemory(q, decisionsService));
const canonicalMemory =
Expand Down
10 changes: 5 additions & 5 deletions apps/api/src/storage-readiness.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { stat, statfs } from "node:fs/promises";
import { storageReadinessSchema, type StorageReadiness } from "@space/contracts";

// The launch storage check of /readyz (moved out of the composition root): a dedicated /opt/spaceapp volume with 20 GB free.
// Production/browser-heavy launch requires dedicated app storage with 20 GB free.
const storageMinimumRecommendedFreeBytes = 20 * 1024 * 1024 * 1024;

function usedPercent(sizeBytes: number, availableBytes: number): number {
Expand All @@ -22,16 +22,16 @@ async function collectStorageMount(targetPath: string) {
};
}

export async function collectStorageReadiness(): Promise<StorageReadiness> {
const [root, app] = await Promise.all([collectStorageMount("/"), collectStorageMount("/opt/spaceapp")]);
export async function collectStorageReadiness(installRoot = "/opt/spaceapp"): Promise<StorageReadiness> {
const [root, app] = await Promise.all([collectStorageMount("/"), collectStorageMount(installRoot)]);
const dedicatedAppVolume = root.deviceId !== app.deviceId;
const freeSpaceBlocked = app.availableBytes < storageMinimumRecommendedFreeBytes;
const rootWarn = root.usedPercent >= 80;
const status = !dedicatedAppVolume || freeSpaceBlocked ? "BLOCKED" : rootWarn ? "WARN" : "VERIFIED";
const statusReason = !dedicatedAppVolume
? "Dedicated /opt/spaceapp volume is not detected; production/browser-heavy launch requires isolated dedicated app storage with at least 20GB free."
? `Dedicated ${installRoot} volume is not detected; production/browser-heavy launch requires isolated dedicated app storage with at least 20GB free.`
: freeSpaceBlocked
? "Dedicated /opt/spaceapp volume exists but available space is below the 20GB minimum recommendation."
? `Dedicated ${installRoot} volume exists but available space is below the 20GB minimum recommendation.`
: rootWarn
? "Root filesystem is above the 80% warning threshold."
: "Storage readiness meets the current Space launch thresholds.";
Expand Down
25 changes: 25 additions & 0 deletions apps/api/tests/storage-warning.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app.js";
import { getApiConfig } from "../src/config.js";

Expand Down Expand Up @@ -47,6 +50,28 @@ async function loginCookie(app: Awaited<ReturnType<typeof createApp>>): Promise<
const APP_TEST_TIMEOUT_MS = 60_000;

describe("public profile storage warning", () => {
it("inspects the configured installation root without requiring /opt/spaceapp", async () => {
const installRoot = await mkdtemp(join(tmpdir(), "space-dev-storage-"));
expect(getApiConfig({}).installRoot).toBe("/opt/spaceapp");
const app = await createApp({
config: getApiConfig({ SPACE_INSTALL_ROOT: installRoot, SPACE_BROWSER_SESSIONS_ENABLED: "false" }),
auth: { sessionSecret: "storage-path-test-session-secret", devLogin: true, secureCookies: false }
});
try {
const headers = { cookie: await loginCookie(app) };
const storage = await app.inject({ method: "GET", url: "/api/admin/storage", headers });
expect(storage.statusCode).toBe(200);
expect(storage.json()).toMatchObject({ app: { path: installRoot }, minimumRecommendedFreeBytes: 20 * 1024 ** 3 });
expect(storage.json().dedicatedAppVolume).toBe(storage.json().root.deviceId !== storage.json().app.deviceId);
const admin = await app.inject({ method: "GET", url: "/api/admin", headers });
expect(admin.statusCode).toBe(200);
expect(admin.json().storageWarning).toBe("");
} finally {
await app.close();
await rm(installRoot, { recursive: true, force: true });
}
}, APP_TEST_TIMEOUT_MS);

it("does not show the browser-heavy storage banner when browser sessions are disabled", async () => {
const app = await createApp({
config: getApiConfig({
Expand Down
10 changes: 5 additions & 5 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ export default defineConfig({
host: process.env.SPACE_WEB_HOST ?? "0.0.0.0",
port: Number.parseInt(process.env.SPACE_WEB_PORT ?? "4911", 10),
proxy: {
"/api": "http://127.0.0.1:4910",
"/plugins": "http://127.0.0.1:4910",
"/healthz": "http://127.0.0.1:4910",
"/readyz": "http://127.0.0.1:4910",
"/version": "http://127.0.0.1:4910"
"/api": { target: process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", ws: true },
"/plugins": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910",
"/healthz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910",
"/readyz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910",
"/version": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910"
}
},
test: {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"@napi-rs/canvas": "1.0.9"
},
"scripts": {
"dev:stack": "node scripts/dev-stack.mjs",
"test:dev-stack": "node --test scripts/tests/dev-stack*.test.mjs",
"dev:api": "npm run dev -w @space/api",
"dev:web": "npm run dev -w @space/web",
"dev:worker": "npm run dev -w @space/worker",
Expand Down
Loading