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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ apps/web/public/device-chrome/
.agents/
.claude/
.context/
.megagoal/
.prompts/
.cursor/
.windsurf/
Expand Down
15 changes: 15 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ bun run dev:web

---

## Local Database Schema

Deus is still pre-launch, so the checked-in SQLite schema is the source of truth.
Breaking schema changes should update `shared/schema.ts` directly instead of
adding compatibility migrations for old local databases.

If your local database was created by an older build and the backend reports a
pre-launch schema mismatch, reset it by deleting `deus.db` from the app data
directory or by pointing `DATABASE_PATH` at a fresh file before restarting.

Use durable migrations only after launch or once real external testers depend on
preserved local data.

---

## Available Scripts

### Main Commands (Use These!)
Expand Down
21 changes: 2 additions & 19 deletions apps/backend/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ function ensureSchema(dbPath: string) {
const schemaPath = path.resolve(__dirname, "../../shared/schema.ts");
const schemaContent = fs.readFileSync(schemaPath, "utf-8");

// Extract SCHEMA_SQL and MIGRATIONS from the file
// Extract SCHEMA_SQL from the file. Pre-launch schema changes update the
// source schema directly; stale local DBs should be reset rather than migrated.
const schemaMatch = schemaContent.match(/export const SCHEMA_SQL = `([\s\S]*?)`;/);
if (schemaMatch) {
const schemaSql = schemaMatch[1];
Expand All @@ -106,24 +107,6 @@ function ensureSchema(dbPath: string) {
/* tables already exist */
}
}

// Run migrations
const migrationsMatch = schemaContent.match(
/export const MIGRATIONS: string\[\] = \[([\s\S]*?)\];/
);
if (migrationsMatch) {
const migrationBlock = migrationsMatch[1];
const migrations = [...migrationBlock.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
for (const migration of migrations) {
try {
execSync(`sqlite3 "${dbPath}" "${migration.replace(/"/g, '\\"').replace(/\n/g, " ")}"`, {
timeout: 5000,
});
} catch {
/* already applied */
}
}
}
}

// ---------------------------------------------------------------------------
Expand Down
81 changes: 65 additions & 16 deletions apps/backend/src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import path from "path";
import fs from "fs";
import os from "os";
import { resolveDefaultDatabasePath } from "../../../../shared/runtime";
import { SCHEMA_SQL, MIGRATIONS, isExpectedMigrationError } from "@shared/schema";
import {
PRELAUNCH_REQUIRED_COLUMNS,
PRELAUNCH_SCHEMA_RESET_HINT,
SCHEMA_SQL,
} from "@shared/schema";
import { openSqliteDatabase } from "./sqlite";

const DEFAULT_DB_PATH = resolveDefaultDatabasePath({
Expand All @@ -17,6 +21,50 @@ const DB_PATH = process.env.DATABASE_PATH || DEFAULT_DB_PATH;

let dbInstance: BetterSqlite3.Database | null = null;

interface TableInfoRow {
name: string;
}

function assertPrelaunchSchemaCurrent(db: BetterSqlite3.Database): void {
const missing: string[] = [];

for (const [table, requiredColumns] of Object.entries(PRELAUNCH_REQUIRED_COLUMNS)) {
const rows = db.pragma(`table_info(${table})`) as TableInfoRow[];
if (rows.length === 0) {
missing.push(`${table}.*`);
continue;
}

const actualColumns = new Set(rows.map((row) => row.name));
for (const column of requiredColumns) {
if (!actualColumns.has(column)) {
missing.push(`${table}.${column}`);
}
}
}

if (missing.length > 0) {
throw prelaunchSchemaError(`Missing columns/tables: ${missing.join(", ")}`);
}

const staleCodexHarness = db
.prepare("SELECT 1 FROM sessions WHERE agent_harness = 'codex' LIMIT 1")
.get();
if (staleCodexHarness) {
throw prelaunchSchemaError("Found stale sessions.agent_harness value: codex");
}
}

function prelaunchSchemaError(detail: string): Error {
return new Error(
[
"Database schema is out of date for this pre-launch build.",
detail,
PRELAUNCH_SCHEMA_RESET_HINT,
].join(" ")
);
}

function initDatabase(): BetterSqlite3.Database {
if (dbInstance) {
return dbInstance;
Expand All @@ -37,26 +85,27 @@ function initDatabase(): BetterSqlite3.Database {
dbInstance.pragma("busy_timeout = 5000");
dbInstance.pragma("optimize");

// Create all tables, indexes, and triggers (idempotent)
dbInstance.exec(SCHEMA_SQL);

// Post-launch migrations: tolerate only the specific no-op failures that
// happen when a database already reflects the final schema. Anything else
// should fail fast so we don't silently continue with a partial migration.
for (const sql of MIGRATIONS) {
try {
dbInstance.exec(sql);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "";
if (!isExpectedMigrationError(sql, msg)) {
throw e;
}
}
// Pre-launch policy: SCHEMA_SQL is the source of truth. We create fresh
// databases from it directly, then fail fast with a reset hint if an old
// local database still has a pre-launch schema shape.
try {
dbInstance.exec(SCHEMA_SQL);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw prelaunchSchemaError(`Schema initialization failed: ${message}`);
}
assertPrelaunchSchemaCurrent(dbInstance);

console.log("Database connected");
return dbInstance;
} catch (error) {
if (dbInstance) {
try {
dbInstance.close();
} finally {
dbInstance = null;
}
}
console.error("Failed to open database:", error);
throw error;
}
Expand Down
13 changes: 12 additions & 1 deletion apps/backend/src/services/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,16 @@ interface CommandResult {
[key: string]: unknown;
}

export interface CommandContext {
relayClient?: boolean;
}

// ---- Command Dispatch ----

export async function runCommand(
command: CommandName,
params: QueryParams
params: QueryParams,
context: CommandContext = {}
): Promise<CommandResult> {
return (
match(command)
Expand Down Expand Up @@ -166,6 +171,12 @@ export async function runCommand(
const workspaceId = requireParam(params, "workspaceId", "sim:start");
const udid = requireParam(params, "udid", "sim:start");
const skipBootCheck = params.skipBootCheck === true;
const capabilities = simulator.getSimulatorCapabilities({
relayClient: context.relayClient === true,
});
if (!capabilities.available) {
throw new Error(capabilities.unavailableReason ?? "Simulator is unavailable");
}
// Async: start returns immediately, pushes sim:streamReady event when ready
simulator.startStream(workspaceId, udid, skipBootCheck).catch((err) => {
console.error("[Simulator] startStream failed:", err);
Expand Down
100 changes: 20 additions & 80 deletions apps/backend/src/services/query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import { delegateToRoute } from "./route-delegate";
import { autoProgressStatus, setWorkspaceStatus } from "./workspace-status.service";
import { getRunningApps, listApps, stopAppsForWorkspace } from "./aap";
import { getDiscoveredServers } from "./local-servers.service";
import {
runRequest,
type RequestContext,
type RequestResourceName,
} from "./query-request-dispatcher";
import { WorkspaceStatusSchema } from "@shared/enums";
import {
QUERY_RESOURCES,
Expand Down Expand Up @@ -82,6 +87,10 @@ interface Sub {
params: QueryParams;
}

interface CommandContext {
relayClient: boolean;
}

/** Per-connection active subscriptions, keyed by client-assigned sub ID. */
const subs = new Map<string, Map<string, Sub>>();

Expand Down Expand Up @@ -246,7 +255,7 @@ async function handleRequest(connectionId: string, msg: ResourceFrameInput): Pro
const data = runQuery(resource, params);
sendFrame(connectionId, { type: "q:response", id, data });
} else if (isRequestResource(resource)) {
const data = await runRequest(resource, params);
const data = await runRequest(resource, params, getRequestContext(connectionId));
sendFrame(connectionId, { type: "q:response", id, data });
} else {
throw new Error(`Unknown resource: ${resource}`);
Expand Down Expand Up @@ -352,7 +361,11 @@ async function handleCommand(connectionId: string, msg: CommandFrameInput): Prom
const { id, command, params } = msg;

try {
const result = await runCommand(toCommandName(command), params);
const result = await runCommand(
toCommandName(command),
params,
getCommandContext(connectionId)
);
sendFrame(connectionId, {
type: "q:command_ack",
id,
Expand Down Expand Up @@ -435,89 +448,16 @@ function runQuery(resource: QueryResource, params: QueryParams): unknown {

// ---- Request Dispatch (one-shot reads via route delegation) ----

type RequestResourceName = (typeof REQUEST_RESOURCES)[number];

function isRequestResource(value: string): value is RequestResourceName {
return (REQUEST_RESOURCES as readonly string[]).includes(value);
}

/**
* Route one-shot request resources to existing Hono endpoints.
* Uses delegateToRoute() so all business logic stays in the route handlers.
*/
async function runRequest(resource: RequestResourceName, params: QueryParams): Promise<unknown> {
/** GET /api/workspaces/:id{path} — covers the 10+ workspace-scoped reads. */
const wsGet = (path = "") =>
delegateToRoute(
"GET",
`/api/workspaces/${encodeURIComponent(requireParam(params, "workspaceId", resource))}${path}`
);
/** GET /api/repos/:id{path} — covers repo-scoped reads. */
const repoGet = (path = "") =>
delegateToRoute(
"GET",
`/api/repos/${encodeURIComponent(requireParam(params, "repoId", resource))}${path}`
);
function getRequestContext(connectionId: string): RequestContext {
return { relayClient: getConnection(connectionId)?.isVirtual === true };
}

return match(resource)
.with("settings", () => delegateToRoute("GET", "/api/settings"))
.with("repos", () => delegateToRoute("GET", "/api/repos"))
.with("repoManifest", () => repoGet("/manifest"))
.with("detectManifest", () => repoGet("/detect-manifest"))
.with("agentConfig", () => {
const section = readStringParam(params, "section") ?? "agents";
const scope = readStringParam(params, "scope") ?? "global";
const repoPath = readStringParam(params, "repoPath");
const qs = new URLSearchParams({ scope });
if (repoPath) qs.set("repoPath", repoPath);
return delegateToRoute(
"GET",
`/api/agent-config/${encodeURIComponent(section)}?${qs.toString()}`
);
})
.with("ghStatus", () => delegateToRoute("GET", "/api/gh-status"))
.with("prStatus", () => wsGet("/pr-status"))
.with("workspace", () => wsGet())
.with("allWorkspaces", () => delegateToRoute("GET", "/api/workspaces"))
.with("workspaceManifest", () => wsGet("/manifest"))
.with("setupLogs", () => wsGet("/setup-logs"))
.with("diffStats", () => wsGet("/diff-stats"))
.with("diffFiles", () => wsGet("/diff-files"))
.with("diffFile", () => {
const wsId = requireParam(params, "workspaceId", "diffFile");
const file = requireParam(params, "file", "diffFile");
return delegateToRoute(
"GET",
`/api/workspaces/${encodeURIComponent(wsId)}/diff-file?file=${encodeURIComponent(file)}`
);
})
.with("penFiles", () => wsGet("/pen-files"))
.with("workspaceFiles", () => wsGet("/files"))
.with("fileContent", () => {
const wsId = requireParam(params, "workspaceId", "fileContent");
const filePath = requireParam(params, "path", "fileContent");
return delegateToRoute(
"GET",
`/api/workspaces/${encodeURIComponent(wsId)}/file-content?path=${encodeURIComponent(filePath)}`
);
})
.with("fileSearch", () => {
const wsId = requireParam(params, "workspaceId", "fileSearch");
const query = readStringParam(params, "query") ?? "";
const limit = readNumberParam(params, "limit");
return delegateToRoute("POST", `/api/workspaces/${encodeURIComponent(wsId)}/files/search`, {
query,
...(limit !== undefined ? { limit } : {}),
});
})
.with("recentProjects", () => delegateToRoute("GET", "/api/onboarding/recent-projects"))
.with("pairedDevices", () => delegateToRoute("GET", "/api/remote-auth/devices"))
.with("relayStatus", () => delegateToRoute("GET", "/api/relay/status"))
.with("allSessions", () => delegateToRoute("GET", "/api/sessions"))
.with("repoPrs", () => repoGet("/prs"))
.with("repoBranches", () => repoGet("/branches"))
.with("agentAuth", () => delegateToRoute("GET", "/api/settings/agent-auth"))
.exhaustive();
function getCommandContext(connectionId: string): CommandContext {
return { relayClient: getConnection(connectionId)?.isVirtual === true };
}

// ---- Mutation Dispatch ----
Expand Down
Loading
Loading