SPB Git

Repository Public

fake-runtime.ts 12.1 KB 251 lines typescript

1/**2 * Fake apps/runtime for tests: Fastify on a temp unix socket implementing the IPC rows the3 * server uses (CONTRACT.md). The attach WebSocket is an echo PTY; `/events` broadcasts whatever4 * the test emits. Nothing is spawned and nothing touches the real filesystem (in-memory files).5 */6import fs from 'node:fs';7import os from 'node:os';8import path from 'node:path';9import { createHash, randomBytes } from 'node:crypto';10import Fastify, { type FastifyInstance } from 'fastify';11import websocket from '@fastify/websocket';12import type WebSocket from 'ws';13import type { RtEvent, RtSessionInfo, RtSessionSpec, RtTaskInfo, RtTaskSpec } from '@cr/shared';1415export const FAKE_RT_TOKEN = 'fake-runtime-token-0123456789abcdef0123456789';16type Body = Record<string, unknown>;1718export class FakeRuntime {19  readonly dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cr-rt-'));20  readonly socketPath = path.join(this.dir, 'rt.sock');21  readonly sessions = new Map<string, RtSessionInfo>();22  readonly specs = new Map<string, RtSessionSpec>();23  readonly tasks = new Map<string, RtTaskInfo>();24  readonly taskSpecs = new Map<string, RtTaskSpec>();25  readonly taskLogs = new Map<string, Buffer>();26  /** key = `${root}/${rel}`; value = file bytes, or null for a directory. */27  readonly files = new Map<string, Buffer | null>();28  readonly controls: { sessionId: string; msg: unknown }[] = [];29  readonly calls: { method: string; url: string; body: unknown }[] = [];30  readonly attachSockets = new Map<string, Set<WebSocket>>();31  private readonly eventSockets = new Set<WebSocket>();32  private app: FastifyInstance | null = null;33  git: Body = { isRepo: true, branch: 'main', dirty: false, remote: null };34  skills: unknown[] = [];35  mcp: unknown[] = [];3637  async start(): Promise<void> {38    const app = Fastify({ bodyLimit: 300 * 1024 * 1024 });39    this.app = app;40    await app.register(websocket, { options: { maxPayload: 16 * 1024 * 1024 } });41    app.addHook('onRequest', async (req, reply) => {42      if (req.headers.authorization !== `Bearer ${FAKE_RT_TOKEN}`) {43        return reply.code(401).send({ error: { code: 'unauthorized', message: 'bad token' } });44      }45    });46    app.addHook('preHandler', async (req) => {47      this.calls.push({ method: req.method, url: req.url, body: Buffer.isBuffer(req.body) ? '<binary>' : req.body });48    });49    app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));50    const err = (reply: { code(n: number): { send(b: unknown): unknown } }, status: number, code: string): unknown =>51      reply.code(status).send({ error: { code, message: code } });5253    app.get('/health', () => ({ ok: true, version: 'fake', tmux: true, claude: { path: '/fake/claude', version: '9.9.9' }, uptimeSec: 1 }));54    app.get('/host/metrics', () => ({55      collectedAtMs: Date.now(), hostname: 'fakehost.local', cpuPct: 12.5, load: [1, 0.5, 0.25], memUsedMb: 1000,56      memTotalMb: 4000, diskFreeGb: 50, diskTotalGb: 100, uptimeSec: 3600, sessions: [],57    }));58    app.get('/claude/info', () => ({ path: '/fake/claude', version: '9.9.9', authenticated: true, method: 'claude.ai', subscription: null, configDir: '/fake/.claude' }));59    app.post('/claude/conversations', () => [{ id: 'conv-12345678', summary: 'hello', updatedAt: new Date(0).toISOString(), messageCount: 2 }]);6061    // ---- sessions ----62    app.get('/sessions', () => [...this.sessions.values()]);63    app.post('/sessions', (req, reply) => {64      const spec = req.body as RtSessionSpec;65      if (this.sessions.has(spec.id)) return err(reply, 409, 'exists');66      const info: RtSessionInfo = { id: spec.id, alive: true, pid: 4242, attached: 0, lastActivityMs: Date.now(), exitCode: null, cols: spec.cols, rows: spec.rows };67      this.sessions.set(spec.id, info);68      this.specs.set(spec.id, spec);69      return info;70    });71    app.get('/sessions/:id', (req, reply) => this.sessions.get((req.params as { id: string }).id) ?? err(reply, 404, 'not_found'));72    app.delete('/sessions/:id', (req, reply) => {73      const { id } = req.params as { id: string };74      if (!this.sessions.delete(id)) return err(reply, 404, 'not_found');75      for (const s of this.attachSockets.get(id) ?? []) s.close(4410, 'ended');76      return { ok: true };77    });78    app.get('/sessions/:id/snapshot', (req, reply) =>79      this.sessions.has((req.params as { id: string }).id) ? { text: 'snapshot-text' } : err(reply, 404, 'not_found'),80    );81    app.get('/sessions/:id/attach', { websocket: true }, (socket, req) => {82      const { id } = req.params as { id: string };83      const q = req.query as { cols?: string; rows?: string };84      const info = this.sessions.get(id);85      if (!info) return socket.close(4404, 'not found');86      const set = this.attachSockets.get(id) ?? new Set<WebSocket>();87      this.attachSockets.set(id, set);88      set.add(socket);89      info.attached = set.size;90      socket.send(JSON.stringify({ t: 'ready', sessionId: id, cols: Number(q.cols), rows: Number(q.rows) }));91      socket.on('message', (data, isBinary) => {92        if (isBinary) return socket.send(data, { binary: true }); // echo PTY93        const msg = JSON.parse(data.toString()) as { t: string; n?: number };94        this.controls.push({ sessionId: id, msg });95        if (msg.t === 'ping') socket.send(JSON.stringify({ t: 'pong', n: msg.n }));96      });97      socket.on('close', () => {98        set.delete(socket);99        info.attached = set.size;100      });101    });102103    // ---- tasks ----104    app.get('/tasks', () => [...this.tasks.values()]);105    app.post('/tasks', (req, reply) => {106      const spec = req.body as RtTaskSpec;107      if (this.tasks.has(spec.id)) return err(reply, 409, 'exists');108      const info: RtTaskInfo = { id: spec.id, state: 'RUNNING', exitCode: null, startedAtMs: Date.now(), endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null };109      this.tasks.set(spec.id, info);110      this.taskSpecs.set(spec.id, spec);111      return info;112    });113    app.get('/tasks/:id', (req, reply) => this.tasks.get((req.params as { id: string }).id) ?? err(reply, 404, 'not_found'));114    app.delete('/tasks/:id', (req, reply) => {115      const t = this.tasks.get((req.params as { id: string }).id);116      if (!t) return err(reply, 404, 'not_found');117      t.state = 'CANCELLED';118      t.endedAtMs = Date.now();119      return t;120    });121    app.get('/tasks/:id/log', (req, reply) => {122      const { id } = req.params as { id: string };123      const q = req.query as { offset?: string };124      const log = this.taskLogs.get(id) ?? Buffer.alloc(0);125      return reply.header('X-Log-Size', String(log.length)).header('Content-Type', 'application/octet-stream').send(log.subarray(Number(q.offset ?? 0)));126    });127    app.get('/tasks/:id/log/follow', { websocket: true }, (socket, req) => {128      const { id } = req.params as { id: string };129      socket.send(this.taskLogs.get(id) ?? Buffer.alloc(0), { binary: true });130      socket.close(1000, 'done');131    });132133    // ---- events ----134    app.get('/events', { websocket: true }, (socket) => {135      this.eventSockets.add(socket);136      socket.on('close', () => this.eventSockets.delete(socket));137    });138139    // ---- fs / git ----140    const key = (root: unknown, rel: unknown): string => (rel ? `${String(root)}/${String(rel)}` : String(root));141    const meta = (rel: string, buf: Buffer): Body => ({142      path: rel, size: buf.length, mtimeMs: 1, sha256: createHash('sha256').update(buf).digest('hex'), encoding: 'utf8', content: buf.toString('utf8'),143    });144    app.post('/fs/list', (req, reply) => {145      const b = req.body as Body;146      const dir = key(b.root, b.rel);147      if (this.files.get(dir) !== null) return err(reply, 404, 'not_found');148      const out: Body[] = [];149      for (const [k, v] of this.files) {150        if (!k.startsWith(`${dir}/`) || k.slice(dir.length + 1).includes('/')) continue;151        const name = k.slice(dir.length + 1);152        out.push({ name, path: b.rel ? `${String(b.rel)}/${name}` : name, type: v === null ? 'dir' : 'file', size: v?.length ?? 0, mtimeMs: 1 });153      }154      return out;155    });156    app.post('/fs/read', (req, reply) => {157      const b = req.body as Body;158      const buf = this.files.get(key(b.root, b.rel));159      return buf ? meta(String(b.rel), buf) : err(reply, 404, 'not_found');160    });161    app.post('/fs/write', (req, reply) => {162      const b = req.body as Body;163      const k = key(b.root, b.rel);164      const cur = this.files.get(k);165      // Same shapes as apps/runtime/src/fs.ts: conflict carries details, success carries no content.166      const curSha = cur ? createHash('sha256').update(cur).digest('hex') : null;167      if (b.baseSha256 && curSha !== b.baseSha256) {168        return reply.code(409).send({ error: { code: 'conflict', message: 'file changed on disk since it was loaded', details: { sha256: curSha, mtimeMs: cur ? 1 : null } } });169      }170      if (cur && b.create) return err(reply, 409, 'exists');171      const buf = Buffer.from(String(b.content), 'utf8');172      this.files.set(k, buf);173      const { content: _content, ...written } = meta(String(b.rel), buf);174      return written;175    });176    app.post('/fs/op', (req) => {177      const b = req.body as Body;178      if (b.op === 'mkdir') this.files.set(key(b.root, b.path), null);179      else if (b.op === 'delete' && b.confirm === true) this.files.delete(key(b.root, b.path));180      else if (b.op === 'rename') {181        const v = this.files.get(key(b.root, b.from));182        this.files.delete(key(b.root, b.from));183        this.files.set(key(b.root, b.to), v ?? null);184      }185      return { ok: true, path: String(b.op === 'rename' ? b.to : b.path) };186    });187    app.post('/fs/upload', (req, reply) => {188      const q = req.query as { root: string; rel: string; overwrite?: string };189      const buf = req.body as Buffer;190      if (this.files.has(key(q.root, q.rel)) && q.overwrite !== '1') return err(reply, 409, 'exists');191      this.files.set(key(q.root, q.rel), buf);192      return { path: q.rel, size: buf.length, mtimeMs: 1 };193    });194    app.get('/fs/download', (req, reply) => {195      const q = req.query as { root: string; rel: string };196      const buf = this.files.get(key(q.root, q.rel));197      return buf ? reply.header('Content-Type', 'application/octet-stream').send(buf) : err(reply, 404, 'not_found');198    });199    app.post('/fs/search', (req) => {200      const b = req.body as Body;201      const out: Body[] = [];202      for (const k of this.files.keys()) {203        if (k.startsWith(`${String(b.root)}/`) && k.includes(String(b.query))) out.push({ path: k.slice(String(b.root).length + 1), line: null, preview: null });204      }205      return out;206    });207    app.post('/git/info', () => this.git);208    app.post('/git/status', () => ({ isRepo: true, branch: 'main', ahead: 0, behind: 0, files: [] }));209    app.post('/git/diff', () => ({ diff: '', truncated: false }));210    app.post('/git/clone', (req) => {211      const b = req.body as Body;212      this.files.set(key(b.root, b.rel), null);213      return { ok: true };214    });215216    // ---- skills / mcp ----217    app.get('/skills', () => this.skills);218    app.post('/skills/op', () => ({ ok: true }));219    app.get('/mcp', () => this.mcp);220    app.post('/mcp/add', () => ({ ok: true }));221    app.post('/mcp/remove', () => ({ ok: true }));222    app.post('/mcp/test', () => ({ status: 'connected', lastError: null, checkedAt: new Date().toISOString() }));223224    await app.listen({ path: this.socketPath });225  }226227  /** Marks a directory (and its parents up to `root`) as existing. */228  mkdir(abs: string): void {229    this.files.set(abs, null);230  }231  emit(ev: RtEvent): void {232    for (const s of this.eventSockets) s.send(JSON.stringify(ev));233  }234  get eventSubscribers(): number {235    return this.eventSockets.size;236  }237  /** Simulates the runtime being down (socket gone). `start()` brings it back with its state. */238  async stop(): Promise<void> {239    for (const s of this.eventSockets) s.terminate();240    for (const set of this.attachSockets.values()) for (const s of set) s.terminate();241    await this.app?.close();242    this.app = null;243    fs.rmSync(this.socketPath, { force: true });244  }245  async destroy(): Promise<void> {246    await this.stop();247    fs.rmSync(this.dir, { recursive: true, force: true });248  }249}250export const randomId = (): string => randomBytes(6).toString('hex');251
Files main
Menu

Theme