From 7b64a937066b68ddfc6316d91400390b6a887303 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:09:56 +0000 Subject: [PATCH 01/18] Set up pnpm monorepo tooling Root workspace (pnpm 10, TypeScript 6, vitest 5 projects), package manifests for events, testkit, core, world-engine, replay, runtime and the Next.js studio, with pinned dependency versions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- .gitignore | 10 + .nvmrc | 1 + apps/studio/next.config.ts | 19 + apps/studio/package.json | 33 + apps/studio/tsconfig.json | 13 + apps/studio/vitest.config.ts | 9 + package.json | 28 + packages/core/package.json | 19 + packages/core/tsconfig.json | 4 + packages/core/vitest.config.ts | 9 + packages/events/package.json | 16 + packages/events/tsconfig.json | 4 + packages/events/vitest.config.ts | 9 + packages/replay/package.json | 21 + packages/replay/tsconfig.json | 4 + packages/replay/vitest.config.ts | 9 + packages/runtime/package.json | 26 + packages/runtime/tsconfig.json | 4 + packages/runtime/vitest.config.ts | 9 + packages/testkit/package.json | 16 + packages/testkit/tsconfig.json | 4 + packages/testkit/vitest.config.ts | 9 + packages/world-engine/package.json | 21 + packages/world-engine/tsconfig.json | 4 + packages/world-engine/vitest.config.ts | 9 + pnpm-lock.yaml | 2206 ++++++++++++++++++++++++ pnpm-workspace.yaml | 7 + tsconfig.base.json | 19 + vitest.config.ts | 7 + 29 files changed, 2549 insertions(+) create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 apps/studio/next.config.ts create mode 100644 apps/studio/package.json create mode 100644 apps/studio/tsconfig.json create mode 100644 apps/studio/vitest.config.ts create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/events/package.json create mode 100644 packages/events/tsconfig.json create mode 100644 packages/events/vitest.config.ts create mode 100644 packages/replay/package.json create mode 100644 packages/replay/tsconfig.json create mode 100644 packages/replay/vitest.config.ts create mode 100644 packages/runtime/package.json create mode 100644 packages/runtime/tsconfig.json create mode 100644 packages/runtime/vitest.config.ts create mode 100644 packages/testkit/package.json create mode 100644 packages/testkit/tsconfig.json create mode 100644 packages/testkit/vitest.config.ts create mode 100644 packages/world-engine/package.json create mode 100644 packages/world-engine/tsconfig.json create mode 100644 packages/world-engine/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3c86f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.runloop/ +.next/ +out/ +coverage/ +*.tsbuildinfo +next-env.d.ts +.DS_Store +.env +.env.* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts new file mode 100644 index 0000000..9cefa18 --- /dev/null +++ b/apps/studio/next.config.ts @@ -0,0 +1,19 @@ +import path from 'node:path'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'export', + reactStrictMode: true, + transpilePackages: [ + '@runloop-world/core', + '@runloop-world/events', + '@runloop-world/replay', + '@runloop-world/world-engine', + ], + turbopack: { + root: path.join(import.meta.dirname, '../..'), + }, + images: { unoptimized: true }, +}; + +export default nextConfig; diff --git a/apps/studio/package.json b/apps/studio/package.json new file mode 100644 index 0000000..f50356d --- /dev/null +++ b/apps/studio/package.json @@ -0,0 +1,33 @@ +{ + "name": "@runloop-world/studio", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@react-three/drei": "10.7.9", + "@react-three/fiber": "9.8.2", + "@react-three/postprocessing": "3.2.0", + "@runloop-world/core": "workspace:*", + "@runloop-world/events": "workspace:*", + "@runloop-world/replay": "workspace:*", + "@runloop-world/world-engine": "workspace:*", + "next": "16.4.2", + "postprocessing": "6.39.5", + "react": "19.3.1", + "react-dom": "19.3.1", + "three": "0.186.1", + "zustand": "5.0.15" + }, + "devDependencies": { + "@runloop-world/testkit": "workspace:*", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@types/three": "0.186.0" + } +} diff --git a/apps/studio/tsconfig.json b/apps/studio/tsconfig.json new file mode 100644 index 0000000..b38acb1 --- /dev/null +++ b/apps/studio/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node", "react", "react-dom"], + "jsx": "react-jsx", + "allowJs": false, + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", ".next", "out"] +} diff --git a/apps/studio/vitest.config.ts b/apps/studio/vitest.config.ts new file mode 100644 index 0000000..cec2419 --- /dev/null +++ b/apps/studio/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'studio', + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..00a5ce5 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "runloop", + "private": true, + "version": "0.1.0", + "description": "A 3D living world for AI agents: watch real agent work, inspect the trace, replay what happened.", + "license": "Apache-2.0", + "type": "module", + "packageManager": "pnpm@10.28.0", + "engines": { + "node": ">=22.12" + }, + "scripts": { + "dev": "pnpm -r --parallel --filter @runloop-world/runtime --filter @runloop-world/studio dev", + "demo": "pnpm --filter @runloop-world/runtime demo", + "serve": "pnpm --filter @runloop-world/runtime serve", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "pnpm -r typecheck", + "build": "pnpm --filter @runloop-world/studio build" + }, + "devDependencies": { + "@types/node": "22.20.5", + "tsx": "4.23.15", + "typescript": "6.0.3", + "vite": "8.3.4", + "vitest": "5.0.5" + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..060869d --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,19 @@ +{ + "name": "@runloop-world/core", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@runloop-world/events": "workspace:*" + }, + "devDependencies": { + "@runloop-world/testkit": "workspace:*" + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..d526fba --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'core', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/packages/events/package.json b/packages/events/package.json new file mode 100644 index 0000000..d442c31 --- /dev/null +++ b/packages/events/package.json @@ -0,0 +1,16 @@ +{ + "name": "@runloop-world/events", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "zod": "4.6.7" + } +} diff --git a/packages/events/tsconfig.json b/packages/events/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/events/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/events/vitest.config.ts b/packages/events/vitest.config.ts new file mode 100644 index 0000000..5b03927 --- /dev/null +++ b/packages/events/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'events', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/packages/replay/package.json b/packages/replay/package.json new file mode 100644 index 0000000..7f9aecc --- /dev/null +++ b/packages/replay/package.json @@ -0,0 +1,21 @@ +{ + "name": "@runloop-world/replay", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@runloop-world/core": "workspace:*", + "@runloop-world/events": "workspace:*", + "@runloop-world/world-engine": "workspace:*" + }, + "devDependencies": { + "@runloop-world/testkit": "workspace:*" + } +} diff --git a/packages/replay/tsconfig.json b/packages/replay/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/replay/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/replay/vitest.config.ts b/packages/replay/vitest.config.ts new file mode 100644 index 0000000..3525cc9 --- /dev/null +++ b/packages/replay/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'replay', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 0000000..10dfec3 --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,26 @@ +{ + "name": "@runloop-world/runtime", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "serve": "node --disable-warning=ExperimentalWarning --import tsx src/bin.ts serve", + "dev": "node --disable-warning=ExperimentalWarning --import tsx src/bin.ts serve", + "demo": "node --disable-warning=ExperimentalWarning --import tsx src/bin.ts demo" + }, + "dependencies": { + "@hono/node-server": "2.1.5", + "@runloop-world/core": "workspace:*", + "@runloop-world/events": "workspace:*", + "@runloop-world/world-engine": "workspace:*", + "hono": "4.13.13" + }, + "devDependencies": { + "@runloop-world/testkit": "workspace:*" + } +} diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts new file mode 100644 index 0000000..5802eaa --- /dev/null +++ b/packages/runtime/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'runtime', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/packages/testkit/package.json b/packages/testkit/package.json new file mode 100644 index 0000000..12cb1fc --- /dev/null +++ b/packages/testkit/package.json @@ -0,0 +1,16 @@ +{ + "name": "@runloop-world/testkit", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@runloop-world/events": "workspace:*" + } +} diff --git a/packages/testkit/tsconfig.json b/packages/testkit/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/testkit/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/testkit/vitest.config.ts b/packages/testkit/vitest.config.ts new file mode 100644 index 0000000..f8edbb9 --- /dev/null +++ b/packages/testkit/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'testkit', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/packages/world-engine/package.json b/packages/world-engine/package.json new file mode 100644 index 0000000..588408d --- /dev/null +++ b/packages/world-engine/package.json @@ -0,0 +1,21 @@ +{ + "name": "@runloop-world/world-engine", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@runloop-world/core": "workspace:*", + "@runloop-world/events": "workspace:*", + "zod": "4.6.7" + }, + "devDependencies": { + "@runloop-world/testkit": "workspace:*" + } +} diff --git a/packages/world-engine/tsconfig.json b/packages/world-engine/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/world-engine/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/world-engine/vitest.config.ts b/packages/world-engine/vitest.config.ts new file mode 100644 index 0000000..ae5764b --- /dev/null +++ b/packages/world-engine/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'world-engine', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f0078b3 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2206 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: 22.20.5 + version: 22.20.5 + tsx: + specifier: 4.23.15 + version: 4.23.15 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.4 + version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + vitest: + specifier: 5.0.5 + version: 5.0.5(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + + apps/studio: + dependencies: + '@react-three/drei': + specifier: 10.7.9 + version: 10.7.9(@react-three/fiber@9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1))(@types/react@19.3.0)(@types/three@0.186.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1) + '@react-three/fiber': + specifier: 9.8.2 + version: 9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1) + '@react-three/postprocessing': + specifier: 3.2.0 + version: 3.2.0(@react-three/fiber@9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1))(@types/three@0.186.0)(postprocessing@6.39.5(three@0.186.1))(react@19.3.1)(three@0.186.1) + '@runloop-world/core': + specifier: workspace:* + version: link:../../packages/core + '@runloop-world/events': + specifier: workspace:* + version: link:../../packages/events + '@runloop-world/replay': + specifier: workspace:* + version: link:../../packages/replay + '@runloop-world/world-engine': + specifier: workspace:* + version: link:../../packages/world-engine + next: + specifier: 16.4.2 + version: 16.4.2(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + postprocessing: + specifier: 6.39.5 + version: 6.39.5(three@0.186.1) + react: + specifier: 19.3.1 + version: 19.3.1 + react-dom: + specifier: 19.3.1 + version: 19.3.1(react@19.3.1) + three: + specifier: 0.186.1 + version: 0.186.1 + zustand: + specifier: 5.0.15 + version: 5.0.15(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)) + devDependencies: + '@runloop-world/testkit': + specifier: workspace:* + version: link:../../packages/testkit + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@types/three': + specifier: 0.186.0 + version: 0.186.0 + + packages/core: + dependencies: + '@runloop-world/events': + specifier: workspace:* + version: link:../events + devDependencies: + '@runloop-world/testkit': + specifier: workspace:* + version: link:../testkit + + packages/events: + dependencies: + zod: + specifier: 4.6.7 + version: 4.6.7 + + packages/replay: + dependencies: + '@runloop-world/core': + specifier: workspace:* + version: link:../core + '@runloop-world/events': + specifier: workspace:* + version: link:../events + '@runloop-world/world-engine': + specifier: workspace:* + version: link:../world-engine + devDependencies: + '@runloop-world/testkit': + specifier: workspace:* + version: link:../testkit + + packages/runtime: + dependencies: + '@hono/node-server': + specifier: 2.1.5 + version: 2.1.5(hono@4.13.13) + '@runloop-world/core': + specifier: workspace:* + version: link:../core + '@runloop-world/events': + specifier: workspace:* + version: link:../events + '@runloop-world/world-engine': + specifier: workspace:* + version: link:../world-engine + hono: + specifier: 4.13.13 + version: 4.13.13 + devDependencies: + '@runloop-world/testkit': + specifier: workspace:* + version: link:../testkit + + packages/testkit: + dependencies: + '@runloop-world/events': + specifier: workspace:* + version: link:../events + + packages/world-engine: + dependencies: + '@runloop-world/core': + specifier: workspace:* + version: link:../core + '@runloop-world/events': + specifier: workspace:* + version: link:../events + zod: + specifier: 4.6.7 + version: 4.6.7 + devDependencies: + '@runloop-world/testkit': + specifier: workspace:* + version: link:../testkit + +packages: + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mediapipe/tasks-vision@0.10.17': + resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} + + '@monogrid/gainmap-js@3.4.0': + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + + '@next/env@16.4.2': + resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==} + + '@next/swc-darwin-arm64@16.4.2': + resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.4.2': + resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.4.2': + resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.4.2': + resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.4.2': + resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.4.2': + resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.4.2': + resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.4.2': + resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + + '@react-three/drei@10.7.9': + resolution: {integrity: sha512-wP4Y6o58KFi+hT1zCfjJarRtPCT6WW/lo53WMiA/aCORuUvBzW916XsCtadnIB1KB+3IQNcDU5JeVh/gYb8MYw==} + peerDependencies: + '@react-three/fiber': ^9.0.0 + react: ^19 + react-dom: ^19 + three: '>=0.159' + peerDependenciesMeta: + react-dom: + optional: true + + '@react-three/fiber@9.8.2': + resolution: {integrity: sha512-6s0ejw0bT1myQ/aLLugcil8/zBmNnnHw/AISWF1yfDLOxfX3yYaTS+0IkpJITEnm/SSMLtxje6CJDt0i5Wu1Sw==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.4' + react-dom: '>=19 <19.4' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + + '@react-three/postprocessing@3.2.0': + resolution: {integrity: sha512-VSwaG1cimKOF3d4LWtR5BumcI4k1B+CJ8HfbeJ7msuEgtZiOnkNBGyLfRQtai3evrup+9QNRbNIIZ4UsuyEh9A==} + peerDependencies: + '@react-three/fiber': '>=9.7.0' + postprocessing: ^6.36.0 + react: ^19.0.0 + three: '>= 0.156.0' + + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.186.0': + resolution: {integrity: sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.29: + resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + camera-controls@3.1.2: + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} + engines: {node: '>=22.0.0', npm: '>=10.5.1'} + peerDependencies: + three: '>=0.126.1' + + caniuse-lite@1.0.30001816: + resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-gpu@5.0.70: + resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + draco3d@1.5.9: + resolution: {integrity: sha512-MqC0eAfW5gPATGY3GABIyFWpHwdDGAyewTdek5R/e+mhI9ZDUMKb2mTDkTMUW0+Vm7an+ThEMUop3+svS72Uug==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.6.11: + resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==} + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glsl-noise@0.0.0: + resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} + + hls.js@1.7.3: + resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==} + + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + its-fine@2.1.1: + resolution: {integrity: sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw==} + peerDependencies: + react: ^19.0.0 + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + maath@0.10.8: + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + meshline@3.3.1: + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==} + peerDependencies: + three: '>=0.137' + + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + + n8ao@2.0.1: + resolution: {integrity: sha512-MoP1QKUdj4/3cKEnhFl917t0eQwWGER1l43ckz5mNOPyYWGRDGQbVoTOQz/VBvy01in6QVNG+AL6Xxx5AjZBYA==} + peerDependencies: + postprocessing: '>=6.30.0' + three: '>=0.137' + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.4.2: + resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + postprocessing@6.39.5: + resolution: {integrity: sha512-YmH+t74J2X1lwYjKzK/tgs9C9kqWWPB9JwZiDF5mEf4BdCEAmEFbO8lXBA7NM0/j4g+x6FgD/Tt5/kT7ogbEDA==} + peerDependencies: + three: '>= 0.168.0 < 0.187.0' + + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + + promise-worker-transferable@1.0.4: + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + + three-mesh-bvh@0.8.3: + resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==} + peerDependencies: + three: '>= 0.159.0' + + three-stdlib@2.36.1: + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} + peerDependencies: + three: '>=0.128.0' + + three@0.186.1: + resolution: {integrity: sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + troika-three-text@0.52.5: + resolution: {integrity: sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==} + peerDependencies: + three: '>=0.125.0' + + troika-three-utils@0.52.5: + resolution: {integrity: sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==} + peerDependencies: + three: '>=0.125.0' + + troika-worker-utils@0.52.0: + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webgl-constants@1.1.1: + resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} + + webgl-sdf-generator@1.1.1: + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/runtime@7.29.10': {} + + '@dimforge/rapier3d-compat@0.12.0': {} + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hono/node-server@2.1.5(hono@4.13.13)': + dependencies: + hono: 4.13.13 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@mediapipe/tasks-vision@0.10.17': {} + + '@monogrid/gainmap-js@3.4.0(three@0.186.1)': + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.186.1 + + '@next/env@16.4.2': {} + + '@next/swc-darwin-arm64@16.4.2': + optional: true + + '@next/swc-darwin-x64@16.4.2': + optional: true + + '@next/swc-linux-arm64-gnu@16.4.2': + optional: true + + '@next/swc-linux-arm64-musl@16.4.2': + optional: true + + '@next/swc-linux-x64-gnu@16.4.2': + optional: true + + '@next/swc-linux-x64-musl@16.4.2': + optional: true + + '@next/swc-win32-arm64-msvc@16.4.2': + optional: true + + '@next/swc-win32-x64-msvc@16.4.2': + optional: true + + '@oxc-project/types@0.154.0': {} + + '@react-three/drei@10.7.9(@react-three/fiber@9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1))(@types/react@19.3.0)(@types/three@0.186.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.186.1) + '@react-three/fiber': 9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1) + '@use-gesture/react': 10.3.1(react@19.3.1) + camera-controls: 3.1.2(three@0.186.1) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.7.3 + maath: 0.10.8(@types/three@0.186.0)(three@0.186.1) + meshline: 3.3.1(three@0.186.1) + react: 19.3.1 + stats-gl: 2.4.2(@types/three@0.186.0)(three@0.186.1) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@19.3.1) + three: 0.186.1 + three-mesh-bvh: 0.8.3(three@0.186.1) + three-stdlib: 2.36.1(three@0.186.1) + troika-three-text: 0.52.5(three@0.186.1) + tunnel-rat: 0.1.2(@types/react@19.3.0)(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + utility-types: 3.11.0 + zustand: 5.0.15(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)) + optionalDependencies: + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer + + '@react-three/fiber@9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.1.1(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-use-measure: 2.1.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + scheduler: 0.28.0 + suspend-react: 0.1.3(react@19.3.1) + three: 0.186.1 + use-sync-external-store: 1.7.0(react@19.3.1) + zustand: 5.0.15(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)) + optionalDependencies: + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + + '@react-three/postprocessing@3.2.0(@react-three/fiber@9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1))(@types/three@0.186.0)(postprocessing@6.39.5(three@0.186.1))(react@19.3.1)(three@0.186.1)': + dependencies: + '@react-three/fiber': 9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1) + maath: 0.10.8(@types/three@0.186.0)(three@0.186.1) + n8ao: 2.0.1(postprocessing@6.39.5(three@0.186.1))(three@0.186.1) + postprocessing: 6.39.5(three@0.186.1) + react: 19.3.1 + three: 0.186.1 + transitivePeerDependencies: + - '@types/three' + + '@rolldown/binding-android-arm-eabi@1.2.14': + optional: true + + '@rolldown/binding-android-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-x64@1.2.14': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.14': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.14': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.14': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.14': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.14': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tweenjs/tween.js@23.1.3': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/draco3d@1.4.10': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@types/offscreencanvas@2019.7.3': {} + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react-reconciler@0.28.9(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.186.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/webxr@0.5.24': {} + + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.3.1)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.3.1 + + '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + + '@vitest/spy@5.0.5': {} + + assertion-error@2.0.1: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.29: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + camera-controls@3.1.2(three@0.186.1): + dependencies: + three: 0.186.1 + + caniuse-lite@1.0.30001816: {} + + chai@6.3.0: {} + + client-only@0.0.1: {} + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + detect-gpu@5.0.70: + dependencies: + webgl-constants: 1.1.1 + + detect-libc@2.1.2: {} + + draco3d@1.5.9: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.6.11: {} + + fflate@0.8.3: {} + + fsevents@2.3.3: + optional: true + + glsl-noise@0.0.0: {} + + hls.js@1.7.3: {} + + hono@4.13.13: {} + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + is-promise@2.2.2: {} + + isexe@2.0.0: {} + + its-fine@2.1.1(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.3.0) + react: 19.3.1 + transitivePeerDependencies: + - '@types/react' + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + maath@0.10.8(@types/three@0.186.0)(three@0.186.1): + dependencies: + '@types/three': 0.186.0 + three: 0.186.1 + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + meshline@3.3.1(three@0.186.1): + dependencies: + three: 0.186.1 + + meshoptimizer@1.1.1: {} + + n8ao@2.0.1(postprocessing@6.39.5(three@0.186.1))(three@0.186.1): + dependencies: + postprocessing: 6.39.5(three@0.186.1) + three: 0.186.1 + + nanoid@3.3.20: {} + + next@16.4.2(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@next/env': 16.4.2 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.29 + caniuse-lite: 1.0.30001816 + postcss: 8.5.23 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + styled-jsx: 5.1.6(react@19.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 16.4.2 + '@next/swc-darwin-x64': 16.4.2 + '@next/swc-linux-arm64-gnu': 16.4.2 + '@next/swc-linux-arm64-musl': 16.4.2 + '@next/swc-linux-x64-gnu': 16.4.2 + '@next/swc-linux-x64-musl': 16.4.2 + '@next/swc-win32-arm64-msvc': 16.4.2 + '@next/swc-win32-x64-msvc': 16.4.2 + sharp: 0.35.5(@types/node@22.20.5) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + obug@3.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.23: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postprocessing@6.39.5(three@0.186.1): + dependencies: + three: 0.186.1 + + potpack@1.0.2: {} + + promise-worker-transferable@1.0.4: + dependencies: + is-promise: 2.2.2 + lie: 3.3.0 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-use-measure@2.1.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + react-dom: 19.3.1(react@19.3.1) + + react@19.3.1: {} + + require-from-string@2.0.2: {} + + rolldown@1.2.14: + dependencies: + '@oxc-project/types': 0.154.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 + + scheduler@0.28.0: {} + + semver@7.8.5: + optional: true + + sharp@0.35.5(@types/node@22.20.5): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.20.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.2: {} + + stats-gl@2.4.2(@types/three@0.186.0)(three@0.186.1): + dependencies: + '@types/three': 0.186.0 + three: 0.186.1 + + stats.js@0.17.0: {} + + std-env@4.3.0: {} + + styled-jsx@5.1.6(react@19.3.1): + dependencies: + client-only: 0.0.1 + react: 19.3.1 + + suspend-react@0.1.3(react@19.3.1): + dependencies: + react: 19.3.1 + + three-mesh-bvh@0.8.3(three@0.186.1): + dependencies: + three: 0.186.1 + + three-stdlib@2.36.1(three@0.186.1): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.24 + draco3d: 1.5.9 + fflate: 0.6.11 + potpack: 1.0.2 + three: 0.186.1 + + three@0.186.1: {} + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + troika-three-text@0.52.5(three@0.186.1): + dependencies: + bidi-js: 1.1.0 + three: 0.186.1 + troika-three-utils: 0.52.5(three@0.186.1) + troika-worker-utils: 0.52.0 + webgl-sdf-generator: 1.1.1 + + troika-three-utils@0.52.5(three@0.186.1): + dependencies: + three: 0.186.1 + + troika-worker-utils@0.52.0: {} + + tslib@2.8.1: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-rat@0.1.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + - react + + typescript@6.0.3: {} + + undici-types@6.21.0: {} + + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + + utility-types@3.11.0: {} + + vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.5 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@5.0.5(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 22.20.5 + transitivePeerDependencies: + - msw + + webgl-constants@1.1.1: {} + + webgl-sdf-generator@1.1.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + zod@4.6.7: {} + + zustand@4.5.7(@types/react@19.3.0)(react@19.3.1): + dependencies: + use-sync-external-store: 1.7.0(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.1 + + zustand@5.0.15(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)): + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ee38e68 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - apps/* + - packages/* + +# esbuild ships its binary through optional platform packages, so its postinstall is not needed. +ignoredBuiltDependencies: + - esbuild diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..a6579ea --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b31fcec --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + projects: ['packages/*', 'apps/studio'], + }, +}); From 8de30dc9a39a57f86e20db1cf843a5e54e666268 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:09:56 +0000 Subject: [PATCH 02/18] Add event contract and testkit events: versioned envelope, zod payload schemas for run/agent/task/tool/ artifact events, lenient and strict parsing, secret redaction, a sequence buffer for out-of-order delivery, and the runtime/studio HTTP and SSE protocol types. testkit: deterministic ids and clock, a run builder, an unreliable delivery simulator, and the canonical 58-event coding-mission fixture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/events/src/envelope.ts | 30 ++ packages/events/src/event.ts | 72 +++++ packages/events/src/index.ts | 7 + packages/events/src/parse.ts | 44 +++ packages/events/src/payloads.ts | 178 +++++++++++ packages/events/src/protocol.ts | 87 +++++ packages/events/src/redact.ts | 72 +++++ packages/events/src/sequence-buffer.ts | 88 +++++ packages/testkit/src/builder.ts | 77 +++++ packages/testkit/src/clock.ts | 21 ++ packages/testkit/src/coding-mission.test.ts | 37 +++ packages/testkit/src/coding-mission.ts | 338 ++++++++++++++++++++ packages/testkit/src/deliver.ts | 33 ++ packages/testkit/src/ids.ts | 18 ++ packages/testkit/src/index.ts | 6 + packages/testkit/src/rng.ts | 11 + 16 files changed, 1119 insertions(+) create mode 100644 packages/events/src/envelope.ts create mode 100644 packages/events/src/event.ts create mode 100644 packages/events/src/index.ts create mode 100644 packages/events/src/parse.ts create mode 100644 packages/events/src/payloads.ts create mode 100644 packages/events/src/protocol.ts create mode 100644 packages/events/src/redact.ts create mode 100644 packages/events/src/sequence-buffer.ts create mode 100644 packages/testkit/src/builder.ts create mode 100644 packages/testkit/src/clock.ts create mode 100644 packages/testkit/src/coding-mission.test.ts create mode 100644 packages/testkit/src/coding-mission.ts create mode 100644 packages/testkit/src/deliver.ts create mode 100644 packages/testkit/src/ids.ts create mode 100644 packages/testkit/src/index.ts create mode 100644 packages/testkit/src/rng.ts diff --git a/packages/events/src/envelope.ts b/packages/events/src/envelope.ts new file mode 100644 index 0000000..47e38a1 --- /dev/null +++ b/packages/events/src/envelope.ts @@ -0,0 +1,30 @@ +import * as z from 'zod'; + +/** Current schema version for every event type in this package. */ +export const SCHEMA_VERSION = 1 as const; + +export const VisibilitySchema = z.enum(['public', 'redacted', 'sensitive']); +export type Visibility = z.infer; + +/** + * Fields shared by every recorded event. `sequence` is the only ordering key: + * timestamps are informational and may be non-monotonic. + */ +export const EnvelopeSchema = z.object({ + eventId: z.uuid(), + runId: z.uuid(), + sequence: z.int().min(1), + timestamp: z.iso.datetime({ offset: true }), + type: z.string().min(1), + schemaVersion: z.int().min(1), + /** Index copy of the agent (or 'system') responsible. Derived from the payload when possible. */ + actorId: z.string().min(1).optional(), + /** Index copy of the related task. Derived from the payload when possible. */ + taskId: z.string().min(1).optional(), + /** Causal link, e.g. tool.call_completed -> its tool.call_started. */ + parentEventId: z.uuid().optional(), + visibility: VisibilitySchema, + payload: z.unknown(), +}); + +export type Envelope = z.infer; diff --git a/packages/events/src/event.ts b/packages/events/src/event.ts new file mode 100644 index 0000000..fb05cae --- /dev/null +++ b/packages/events/src/event.ts @@ -0,0 +1,72 @@ +import type { Envelope } from './envelope'; +import type { EventType, PayloadOf } from './payloads'; + +type EnvelopeFields = Omit; + +/** A validated event of a specific known type. */ +export type EventOf = EnvelopeFields & { + type: T; + payload: PayloadOf; +}; + +/** Discriminated union of every known event (narrow with `switch (e.type)`). */ +export type RunEvent = { [T in EventType]: EventOf }[EventType]; + +/** + * An event with a valid envelope that this version cannot interpret: + * an unknown type, an unsupported schemaVersion, or an invalid payload. + * Reducers skip it (recording an anomaly) but the text trace still shows it. + */ +export type UnknownEvent = EnvelopeFields & { + type: string; + payload: unknown; + unknown: true; + reason: 'unknown_type' | 'unsupported_version' | 'invalid_payload'; + error?: string; +}; + +export type AnyEvent = RunEvent | UnknownEvent; + +export function isUnknownEvent(e: AnyEvent): e is UnknownEvent { + return (e as { unknown?: unknown }).unknown === true; +} + +/** What an emitter supplies; the recorder fills in ids, sequence, timestamp and version. */ +export type EventDraft = { + [K in T]: { + type: K; + payload: PayloadOf; + actorId?: string; + taskId?: string; + parentEventId?: string; + visibility?: Envelope['visibility']; + }; +}[T]; + +/** + * Index fields implied by a payload. Emitters may override them (e.g. the + * coordinator as actor of task.created). + */ +export function deriveRefs(draft: EventDraft): { actorId?: string; taskId?: string } { + switch (draft.type) { + case 'agent.registered': + case 'agent.state_changed': + return { actorId: draft.payload.agentId }; + case 'agent.message_sent': + return { actorId: draft.payload.fromAgentId }; + case 'task.assigned': + case 'task.started': + return { actorId: draft.payload.agentId, taskId: draft.payload.taskId }; + case 'task.created': + case 'task.blocked': + case 'task.completed': + case 'task.failed': + return { taskId: draft.payload.taskId }; + case 'tool.call_started': + return { actorId: draft.payload.agentId, taskId: draft.payload.taskId }; + case 'tool.permission_requested': + return { actorId: draft.payload.agentId }; + default: + return {}; + } +} diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts new file mode 100644 index 0000000..1dd937e --- /dev/null +++ b/packages/events/src/index.ts @@ -0,0 +1,7 @@ +export * from './envelope'; +export * from './payloads'; +export * from './event'; +export * from './parse'; +export * from './redact'; +export * from './sequence-buffer'; +export * from './protocol'; diff --git a/packages/events/src/parse.ts b/packages/events/src/parse.ts new file mode 100644 index 0000000..441e24c --- /dev/null +++ b/packages/events/src/parse.ts @@ -0,0 +1,44 @@ +import { EnvelopeSchema, SCHEMA_VERSION } from './envelope'; +import type { AnyEvent, RunEvent, UnknownEvent } from './event'; +import { isEventType, payloadSchemas } from './payloads'; + +export type ParseResult = { ok: true; event: E } | { ok: false; error: string }; + +function describe(issues: readonly { path: readonly PropertyKey[]; message: string }[]): string { + return issues + .slice(0, 5) + .map((i) => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`) + .join('; '); +} + +/** + * Lenient parse for readers (studio, replay). Never throws. + * - invalid envelope -> { ok: false } + * - unknown type / version / bad payload -> { ok: true, event: UnknownEvent } + * - otherwise -> { ok: true, event: RunEvent } + */ +export function parseEvent(raw: unknown): ParseResult { + const env = EnvelopeSchema.safeParse(raw); + if (!env.success) return { ok: false, error: describe(env.error.issues) }; + const e = env.data; + const unknownEvent = (reason: UnknownEvent['reason'], error?: string): ParseResult => ({ + ok: true, + event: { ...e, unknown: true, reason, ...(error ? { error } : {}) }, + }); + if (!isEventType(e.type)) return unknownEvent('unknown_type'); + if (e.schemaVersion !== SCHEMA_VERSION) return unknownEvent('unsupported_version'); + const payload = payloadSchemas[e.type].safeParse(e.payload); + if (!payload.success) return unknownEvent('invalid_payload', describe(payload.error.issues)); + return { ok: true, event: { ...e, payload: payload.data } as RunEvent }; +} + +/** Strict parse for writers (the recorder): only valid, known, current-version events pass. */ +export function parseKnownEvent(raw: unknown): ParseResult { + const r = parseEvent(raw); + if (!r.ok) return r; + if ('unknown' in r.event) { + const u = r.event as UnknownEvent; + return { ok: false, error: `${u.reason}${u.error ? `: ${u.error}` : ''} (type ${u.type})` }; + } + return { ok: true, event: r.event }; +} diff --git a/packages/events/src/payloads.ts b/packages/events/src/payloads.ts new file mode 100644 index 0000000..28d25fa --- /dev/null +++ b/packages/events/src/payloads.ts @@ -0,0 +1,178 @@ +import * as z from 'zod'; + +const id = z.string().min(1); + +export const AgentRuntimeStateSchema = z.enum(['idle', 'working', 'waiting', 'blocked', 'failed', 'done']); +export type AgentRuntimeState = z.infer; + +export const TaskKindSchema = z.enum(['research', 'code', 'test', 'fix', 'approval', 'deploy']); +export type TaskKind = z.infer; + +export const RunOutcomeSchema = z.enum(['succeeded', 'failed', 'stopped', 'interrupted']); +export type RunOutcome = z.infer; + +export const ToolFailureTypeSchema = z.enum([ + 'timeout', + 'denied', + 'aborted', + 'spawn_error', + 'exception', + 'runtime_restarted', +]); +export type ToolFailureType = z.infer; + +export const ArtifactKindSchema = z.enum(['file', 'diff', 'log', 'test_report']); +export type ArtifactKind = z.infer; + +export const AdapterInfoSchema = z.object({ + id, + kind: z.enum(['scripted', 'llm']), + /** Human-readable label shown in the UI, e.g. "Scripted adapter — deterministic decisions, real tools". */ + label: z.string(), +}); +export type AdapterInfo = z.infer; + +export const TestMetricsSchema = z.object({ + tests: z.int().min(0), + passed: z.int().min(0), + failed: z.int().min(0), +}); +export type TestMetrics = z.infer; + +/** Payload schemas keyed by event type. */ +export const payloadSchemas = { + // ── Run lifecycle ──────────────────────────────────────────────── + 'run.started': z.object({ + title: z.string(), + missionId: id, + worldId: id, + seed: z.int(), + adapter: AdapterInfoSchema, + /** Workspace path relative to the runtime data dir (never an absolute host path). */ + workspace: z.string(), + }), + 'run.paused': z.object({ reason: z.string().optional() }), + 'run.resumed': z.object({}), + 'run.completed': z.object({ + outcome: RunOutcomeSchema, + summary: z.string().optional(), + }), + + // ── Agents ─────────────────────────────────────────────────────── + 'agent.registered': z.object({ + agentId: id, + name: z.string(), + role: z.string(), + model: z.string().nullable(), + tools: z.array(z.string()), + /** false = no character in the world (e.g. the coordinator). */ + embodied: z.boolean(), + characterPreset: id.optional(), + }), + 'agent.state_changed': z.object({ + agentId: id, + state: AgentRuntimeStateSchema, + reason: z.string().optional(), + }), + 'agent.message_sent': z.object({ + messageId: id, + fromAgentId: id, + /** An agentId, or 'all'. */ + to: id, + text: z.string(), + }), + + // ── Tasks ──────────────────────────────────────────────────────── + 'task.created': z.object({ + taskId: id, + title: z.string(), + kind: TaskKindSchema, + dependsOn: z.array(id), + acceptance: z.string().optional(), + /** Number of tool calls the task is expected to take; enables a progress bar. */ + plannedSteps: z.int().min(1).optional(), + }), + 'task.assigned': z.object({ taskId: id, agentId: id }), + /** Each task.started counts as one attempt. */ + 'task.started': z.object({ taskId: id, agentId: id }), + 'task.blocked': z.object({ + taskId: id, + reason: z.string(), + blockedBy: z.array(id).optional(), + }), + 'task.completed': z.object({ + taskId: id, + summary: z.string(), + artifactIds: z.array(id), + }), + 'task.failed': z.object({ + taskId: id, + reason: z.string(), + errorType: z.string().optional(), + }), + + // ── Tools ──────────────────────────────────────────────────────── + 'tool.call_started': z.object({ + callId: id, + agentId: id, + taskId: id.optional(), + tool: id, + inputSummary: z.string(), + }), + /** The tool ran. A failing test run is status "error" with real metrics. */ + 'tool.call_completed': z.object({ + callId: id, + status: z.enum(['success', 'error']), + durationMs: z.number().min(0), + exitCode: z.int().optional(), + outputSummary: z.string(), + metrics: TestMetricsSchema.optional(), + failingTests: z.array(z.string()).optional(), + outputArtifactIds: z.array(id).optional(), + errorType: z.string().optional(), + }), + /** The tool could not run at all (denied, timed out, aborted, ...). */ + 'tool.call_failed': z.object({ + callId: id, + errorType: ToolFailureTypeSchema, + message: z.string(), + durationMs: z.number().min(0), + }), + 'tool.permission_requested': z.object({ + permissionId: id, + agentId: id, + callId: id.optional(), + action: z.string(), + scope: z.string(), + }), + 'tool.permission_resolved': z.object({ + permissionId: id, + decision: z.enum(['approved', 'denied']), + by: z.string(), + }), + + // ── Artifacts ──────────────────────────────────────────────────── + 'artifact.created': z.object({ + artifactId: id, + kind: ArtifactKindSchema, + name: z.string(), + mediaType: z.string(), + bytes: z.int().min(0), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + /** Path inside the run workspace, when the artifact mirrors a workspace file. */ + path: z.string().optional(), + producedByCallId: id.optional(), + summary: z.string(), + }), +} as const; + +export type PayloadSchemas = typeof payloadSchemas; +export type EventType = keyof PayloadSchemas; +export type PayloadOf = z.infer; + +export const EVENT_TYPES = Object.keys(payloadSchemas) as EventType[]; +const EVENT_TYPE_SET: ReadonlySet = new Set(EVENT_TYPES); + +export function isEventType(type: string): type is EventType { + return EVENT_TYPE_SET.has(type); +} diff --git a/packages/events/src/protocol.ts b/packages/events/src/protocol.ts new file mode 100644 index 0000000..980c626 --- /dev/null +++ b/packages/events/src/protocol.ts @@ -0,0 +1,87 @@ +/** + * HTTP + SSE contract between the local runtime and Studio. Studio imports + * these types and never imports the runtime package itself. + */ +import type { RunOutcome } from './payloads'; + +export const DEFAULT_RUNTIME_PORT = 4317; +export const DEFAULT_RUNTIME_HOST = '127.0.0.1'; + +export type RunStatus = 'running' | RunOutcome; + +export interface RunSummary { + runId: string; + title: string; + missionId: string; + worldId: string; + status: RunStatus; + createdAt: string; + endedAt: string | null; + lastSequence: number; + adapterLabel: string; + paused: boolean; +} + +export interface HealthResponse { + ok: true; + version: string; + activeRunId: string | null; +} + +export interface ListRunsResponse { + runs: RunSummary[]; +} + +/** `world` is a WorldDefinition JSON object; validate it with @runloop-world/world-engine. */ +export interface RunDetailResponse { + run: RunSummary; + world: unknown; +} + +/** `events` are RunEvent JSON objects; parse them with parseEvent(). */ +export interface EventsPageResponse { + events: unknown[]; + nextAfter: number; + hasMore: boolean; +} + +export interface StartRunRequest { + missionId: string; + /** Playback pacing multiplier for the scripted adapter; 0 = no delays. */ + speed?: number; +} + +export interface StartRunResponse { + runId: string; +} + +export interface ErrorResponse { + error: string; +} + +/** + * SSE stream (`GET /api/runs/:id/stream`): + * - default `message` events carry one RunEvent JSON each, with `id: `; + * - resume with the `Last-Event-ID` header (or `?after=`); + * - an `end` event carries {@link StreamEndData} once the run is terminal, then the server closes. + * Clients must call `EventSource.close()` on `end`, or the browser reconnects forever. + */ +export interface StreamEndData { + status: RunStatus; + lastSequence: number; +} + +export const SSE_END_EVENT = 'end'; + +export const apiPaths = { + health: '/api/health', + runs: '/api/runs', + run: (runId: string) => `/api/runs/${encodeURIComponent(runId)}`, + events: (runId: string) => `/api/runs/${encodeURIComponent(runId)}/events`, + stream: (runId: string) => `/api/runs/${encodeURIComponent(runId)}/stream`, + stop: (runId: string) => `/api/runs/${encodeURIComponent(runId)}/stop`, + pause: (runId: string) => `/api/runs/${encodeURIComponent(runId)}/pause`, + resume: (runId: string) => `/api/runs/${encodeURIComponent(runId)}/resume`, + artifact: (runId: string, artifactId: string) => + `/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(artifactId)}`, +} as const; diff --git a/packages/events/src/redact.ts b/packages/events/src/redact.ts new file mode 100644 index 0000000..4a90cac --- /dev/null +++ b/packages/events/src/redact.ts @@ -0,0 +1,72 @@ +import type { Visibility } from './envelope'; + +export const REDACTED = '[REDACTED]'; + +/** Well-known credential shapes. Replacements never re-match, so redaction is idempotent. */ +const PATTERNS: RegExp[] = [ + /\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI / Anthropic style keys + /\bghp_[A-Za-z0-9]{20,}/g, // GitHub classic tokens + /\bgithub_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained tokens + /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key ids + /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens + /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g, // HTTP bearer credentials +]; + +export interface RedactOptions { + /** Literal secret values (e.g. from the environment). Values shorter than 8 chars are ignored. */ + secrets?: readonly string[]; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Redact a single string. Returns the input unchanged (same value) when nothing matched. */ +export function redactText(text: string, opts: RedactOptions = {}): string { + let out = text; + for (const secret of opts.secrets ?? []) { + if (secret.length < 8) continue; + out = out.replace(new RegExp(escapeRegExp(secret), 'g'), REDACTED); + } + for (const re of PATTERNS) out = out.replace(re, (m) => (m.startsWith('Bearer') ? `Bearer ${REDACTED}` : REDACTED)); + return out; +} + +/** Walk any JSON-like value and redact every string. Returns the same reference when unchanged. */ +export function redactValue(value: T, opts: RedactOptions = {}): T { + if (typeof value === 'string') return redactText(value, opts) as T; + if (Array.isArray(value)) { + let changed = false; + const next = value.map((v) => { + const r = redactValue(v, opts); + if (r !== v) changed = true; + return r; + }); + return (changed ? next : value) as T; + } + if (value !== null && typeof value === 'object') { + let changed = false; + const next: Record = {}; + for (const [k, v] of Object.entries(value)) { + const r = redactValue(v, opts); + if (r !== v) changed = true; + next[k] = r; + } + return (changed ? next : value) as T; + } + return value; +} + +/** + * Redact an event-like object's payload. When anything was replaced, a + * "public" event becomes "redacted". Idempotent. + */ +export function redactEvent( + event: E, + opts: RedactOptions = {}, +): { event: E; changed: boolean } { + const payload = redactValue(event.payload, opts); + if (payload === event.payload) return { event, changed: false }; + const visibility: Visibility = event.visibility === 'sensitive' ? 'sensitive' : 'redacted'; + return { event: { ...event, payload, visibility }, changed: true }; +} diff --git a/packages/events/src/sequence-buffer.ts b/packages/events/src/sequence-buffer.ts new file mode 100644 index 0000000..e681bb1 --- /dev/null +++ b/packages/events/src/sequence-buffer.ts @@ -0,0 +1,88 @@ +/** Minimal shape the buffer needs; works for parsed and unknown events alike. */ +export interface Sequenced { + sequence: number; + eventId: string; +} + +export interface SequenceConflict { + sequence: number; + keptEventId: string; + droppedEventId: string; +} + +/** + * Reorders one run's events by `sequence`. Accepts out-of-order and duplicate + * deliveries and only releases contiguous runs, so consumers never see a gap. + * It has no timers: callers poll `missing()` and backfill. + */ +export class SequenceBuffer { + private next: number; + private readonly pending = new Map(); + private readonly releasedIds = new Map(); + readonly conflicts: SequenceConflict[] = []; + + /** @param after the last sequence already consumed (0 = nothing yet). */ + constructor(opts: { after?: number } = {}) { + this.next = (opts.after ?? 0) + 1; + } + + /** Next sequence the buffer is waiting for. */ + get nextExpected(): number { + return this.next; + } + + /** Last sequence released to the consumer. */ + get lastReleased(): number { + return this.next - 1; + } + + get pendingCount(): number { + return this.pending.size; + } + + /** Add one event; returns the events that became releasable, in order. */ + push(event: E): E[] { + const seq = event.sequence; + if (seq < this.next) { + const kept = this.releasedIds.get(seq); + if (kept !== undefined && kept !== event.eventId) { + this.conflicts.push({ sequence: seq, keptEventId: kept, droppedEventId: event.eventId }); + } + return []; + } + const held = this.pending.get(seq); + if (held) { + if (held.eventId !== event.eventId) { + this.conflicts.push({ sequence: seq, keptEventId: held.eventId, droppedEventId: event.eventId }); + } + return []; + } + this.pending.set(seq, event); + return this.drain(); + } + + pushMany(events: Iterable): E[] { + const out: E[] = []; + for (const e of events) out.push(...this.push(e)); + return out; + } + + /** The gap blocking release, if any: sequences [from, to] are missing. */ + missing(): { from: number; to: number } | null { + if (this.pending.size === 0) return null; + let min = Infinity; + for (const seq of this.pending.keys()) if (seq < min) min = seq; + return { from: this.next, to: min - 1 }; + } + + private drain(): E[] { + const out: E[] = []; + for (let e = this.pending.get(this.next); e; e = this.pending.get(this.next)) { + this.pending.delete(this.next); + this.releasedIds.set(this.next, e.eventId); + out.push(e); + this.next += 1; + } + return out; + } +} diff --git a/packages/testkit/src/builder.ts b/packages/testkit/src/builder.ts new file mode 100644 index 0000000..8bd960f --- /dev/null +++ b/packages/testkit/src/builder.ts @@ -0,0 +1,77 @@ +import { + SCHEMA_VERSION, + deriveRefs, + type EventDraft, + type EventOf, + type EventType, + type PayloadOf, + type RunEvent, +} from '@runloop-world/events'; +import { FakeClock } from './clock'; +import { idGen, uuidFor } from './ids'; + +export interface RunBuilderOptions { + runId?: string; + startIso?: string; + /** Namespace for generated event ids (keeps fixtures from colliding). */ + namespace?: number; +} + +export interface EventMeta { + actorId?: string; + taskId?: string; + parentEventId?: string; + visibility?: RunEvent['visibility']; +} + +/** + * Fluent builder for hand-written event streams. Sequences start at 1 and + * are contiguous; timestamps come from a FakeClock you advance explicitly. + */ +export class RunBuilder { + readonly runId: string; + readonly clock: FakeClock; + readonly events: RunEvent[] = []; + private readonly nextId: () => string; + + constructor(opts: RunBuilderOptions = {}) { + const ns = opts.namespace ?? 0xf1; + this.runId = opts.runId ?? uuidFor(ns, 0xffff); + this.clock = new FakeClock(opts.startIso); + this.nextId = idGen(ns); + } + + /** Advance the clock by `ms` before the next event. */ + wait(ms: number): this { + this.clock.advance(ms); + return this; + } + + add(type: T, payload: PayloadOf, meta: EventMeta = {}): EventOf { + const refs = deriveRefs({ type, payload } as EventDraft); + const event = { + eventId: this.nextId(), + runId: this.runId, + sequence: this.events.length + 1, + timestamp: this.clock.iso(), + type, + schemaVersion: SCHEMA_VERSION, + ...refs, + ...(meta.actorId !== undefined ? { actorId: meta.actorId } : {}), + ...(meta.taskId !== undefined ? { taskId: meta.taskId } : {}), + ...(meta.parentEventId !== undefined ? { parentEventId: meta.parentEventId } : {}), + visibility: meta.visibility ?? 'public', + payload, + } as EventOf; + this.events.push(event as RunEvent); + return event; + } + + get last(): RunEvent | undefined { + return this.events[this.events.length - 1]; + } +} + +export function runBuilder(opts: RunBuilderOptions = {}): RunBuilder { + return new RunBuilder(opts); +} diff --git a/packages/testkit/src/clock.ts b/packages/testkit/src/clock.ts new file mode 100644 index 0000000..d7ec64f --- /dev/null +++ b/packages/testkit/src/clock.ts @@ -0,0 +1,21 @@ +/** Manually advanced clock producing ISO timestamps. */ +export class FakeClock { + private ms: number; + + constructor(startIso = '2026-01-01T00:00:00.000Z') { + this.ms = Date.parse(startIso); + } + + now(): number { + return this.ms; + } + + iso(): string { + return new Date(this.ms).toISOString(); + } + + advance(ms: number): this { + this.ms += ms; + return this; + } +} diff --git a/packages/testkit/src/coding-mission.test.ts b/packages/testkit/src/coding-mission.test.ts new file mode 100644 index 0000000..06022b8 --- /dev/null +++ b/packages/testkit/src/coding-mission.test.ts @@ -0,0 +1,37 @@ +import { parseKnownEvent } from '@runloop-world/events'; +import { describe, expect, it } from 'vitest'; +import { codingMissionFixture } from './coding-mission'; +import { deliver } from './deliver'; + +describe('codingMissionFixture', () => { + it('is a valid, contiguous event stream', () => { + const events = codingMissionFixture(); + events.forEach((e, i) => { + expect(e.sequence).toBe(i + 1); + const r = parseKnownEvent(e); + if (!r.ok) throw new Error(`#${e.sequence} ${e.type}: ${r.error}`); + }); + expect(new Set(events.map((e) => e.eventId)).size).toBe(events.length); + expect(events[0]!.type).toBe('run.started'); + expect(events.at(-1)!.type).toBe('run.completed'); + }); + + it('contains a genuine-shaped failing test run followed by a passing one', () => { + const completions = codingMissionFixture().filter( + (e) => e.type === 'tool.call_completed' && e.payload.metrics, + ); + expect(completions.map((e) => (e.type === 'tool.call_completed' ? e.payload.status : null))).toEqual([ + 'error', + 'success', + ]); + }); +}); + +describe('deliver', () => { + it('delivers every event at least once', () => { + const events = codingMissionFixture(); + const out = deliver(events, 7, { dupRate: 0.3, shuffleWindow: 6 }); + expect(new Set(out.map((e) => e.eventId)).size).toBe(events.length); + expect(out.length).toBeGreaterThanOrEqual(events.length); + }); +}); diff --git a/packages/testkit/src/coding-mission.ts b/packages/testkit/src/coding-mission.ts new file mode 100644 index 0000000..05b38a2 --- /dev/null +++ b/packages/testkit/src/coding-mission.ts @@ -0,0 +1,338 @@ +import { createHash } from 'node:crypto'; +import type { RunEvent } from '@runloop-world/events'; +import { RunBuilder, type RunBuilderOptions } from './builder'; + +/** + * Canonical identifiers for the "coding-mission" demo. The runtime's scripted + * adapter uses the same ids, and its integration test asserts that a real run + * produces exactly the event-type sequence of {@link codingMissionFixture}. + */ +export const CODING_MISSION = { + missionId: 'coding-mission', + worldId: 'research-campus', + seed: 42, + title: 'Build a task store and make its test suite pass', + adapter: { + id: 'scripted-coding-mission', + kind: 'scripted' as const, + label: 'Scripted adapter — deterministic decisions, real tools', + }, + agents: { + coordinator: { agentId: 'coordinator', name: 'Coordinator', role: 'coordinator', preset: undefined }, + researcher: { agentId: 'researcher', name: 'Researcher', role: 'research', preset: 'researcher' }, + builder: { agentId: 'builder', name: 'Builder', role: 'build', preset: 'builder' }, + tester: { agentId: 'tester', name: 'Tester', role: 'test', preset: 'tester' }, + }, + tools: { + researcher: ['list_files', 'read_file'], + builder: ['read_file', 'write_file'], + tester: ['run_command', 'read_file'], + }, + tasks: { + research: { taskId: 'T1', title: 'Survey the repository and the test suite', kind: 'research' as const, plannedSteps: 3 }, + code: { taskId: 'T2', title: 'Implement the task store', kind: 'code' as const, plannedSteps: 2 }, + test: { taskId: 'T3', title: 'Run the test suite', kind: 'test' as const, plannedSteps: 1 }, + fix: { taskId: 'T4', title: 'Fix the failing tests', kind: 'fix' as const, plannedSteps: 2 }, + }, + testCommand: 'node --test --test-reporter=tap', + /** Test names in examples/coding-mission/repo that the planted bug breaks. */ + failingTests: ['listOpen returns only tasks that are not done', 'listOpen reflects complete()'], + totalTests: 12, +} as const; + +/** Rough pacing used by the scripted adapter at speed 1 (ms). */ +export const CODING_MISSION_PACE = { working: 1500, step: 700 } as const; + +function sha(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +/** + * The canonical successful coding-mission run (58 events): research, a real + * failing test run (10 passed, 2 failed), a fix, a passing rerun. + */ +export function codingMissionFixture(opts: RunBuilderOptions = {}): RunEvent[] { + const M = CODING_MISSION; + const b = new RunBuilder({ startIso: '2026-10-17T09:00:00.000Z', ...opts }); + const { coordinator, researcher, builder, tester } = M.agents; + const T = M.tasks; + let callN = 0; + let artN = 0; + let msgN = 0; + + const working = (agentId: string, reason?: string) => { + b.wait(200).add('agent.state_changed', { agentId, state: 'working', ...(reason ? { reason } : {}) }); + b.wait(CODING_MISSION_PACE.working); + }; + + const tool = ( + agentId: string, + taskId: string, + toolName: string, + inputSummary: string, + result: { + status: 'success' | 'error'; + outputSummary: string; + durationMs: number; + exitCode?: number; + metrics?: { tests: number; passed: number; failed: number }; + failingTests?: string[]; + errorType?: string; + artifact?: { kind: 'file' | 'test_report'; name: string; mediaType: string; content: string; path?: string; summary: string }; + }, + ) => { + callN += 1; + const callId = `call-${callN}`; + const started = b.wait(CODING_MISSION_PACE.step).add('tool.call_started', { + callId, + agentId, + taskId, + tool: toolName, + inputSummary, + }); + b.wait(result.durationMs); + const artifactIds: string[] = []; + if (result.artifact) { + artN += 1; + const artifactId = `art-${artN}`; + artifactIds.push(artifactId); + const a = result.artifact; + b.add( + 'artifact.created', + { + artifactId, + kind: a.kind, + name: a.name, + mediaType: a.mediaType, + bytes: Buffer.byteLength(a.content), + sha256: sha(a.content), + ...(a.path ? { path: a.path } : {}), + producedByCallId: callId, + summary: a.summary, + }, + { actorId: agentId, taskId, parentEventId: started.eventId }, + ); + } + b.add( + 'tool.call_completed', + { + callId, + status: result.status, + durationMs: result.durationMs, + ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}), + outputSummary: result.outputSummary, + ...(result.metrics ? { metrics: result.metrics } : {}), + ...(result.failingTests ? { failingTests: result.failingTests } : {}), + ...(artifactIds.length ? { outputArtifactIds: artifactIds } : {}), + ...(result.errorType ? { errorType: result.errorType } : {}), + }, + { actorId: agentId, taskId, parentEventId: started.eventId }, + ); + return artifactIds; + }; + + const message = (fromAgentId: string, to: string, text: string) => { + msgN += 1; + b.wait(CODING_MISSION_PACE.step).add('agent.message_sent', { messageId: `msg-${msgN}`, fromAgentId, to, text }); + }; + + // ── Setup ───────────────────────────────────────────────────────── + b.add( + 'run.started', + { + title: M.title, + missionId: M.missionId, + worldId: M.worldId, + seed: M.seed, + adapter: { ...M.adapter }, + workspace: `workspaces/${b.runId}`, + }, + { actorId: 'system' }, + ); + b.wait(100).add('agent.registered', { + agentId: coordinator.agentId, + name: coordinator.name, + role: coordinator.role, + model: null, + tools: [], + embodied: false, + }); + for (const a of [researcher, builder, tester]) { + b.wait(100).add('agent.registered', { + agentId: a.agentId, + name: a.name, + role: a.role, + model: null, + tools: [...M.tools[a.agentId]], + embodied: true, + characterPreset: a.preset, + }); + } + const created = (t: { taskId: string; title: string; kind: (typeof T)[keyof typeof T]['kind']; plannedSteps: number }, dependsOn: string[]) => + b.wait(300).add( + 'task.created', + { taskId: t.taskId, title: t.title, kind: t.kind, dependsOn, plannedSteps: t.plannedSteps }, + { actorId: coordinator.agentId }, + ); + created(T.research, []); + created(T.code, [T.research.taskId]); + created(T.test, [T.code.taskId]); + + const assignAndStart = (taskId: string, agentId: string) => { + b.wait(CODING_MISSION_PACE.step).add('task.assigned', { taskId, agentId }, { actorId: coordinator.agentId }); + b.wait(100).add('task.started', { taskId, agentId }); + }; + + // ── T1 research ─────────────────────────────────────────────────── + assignAndStart(T.research.taskId, researcher.agentId); + working(researcher.agentId); + tool(researcher.agentId, T.research.taskId, 'list_files', '.', { + status: 'success', + outputSummary: '6 entries', + durationMs: 4, + }); + tool(researcher.agentId, T.research.taskId, 'read_file', 'README.md', { + status: 'success', + outputSummary: 'read 640 bytes', + durationMs: 2, + }); + tool(researcher.agentId, T.research.taskId, 'read_file', 'test/task-store.test.js', { + status: 'success', + outputSummary: 'read 3120 bytes', + durationMs: 2, + }); + message( + researcher.agentId, + builder.agentId, + 'task-store needs add/get/all/complete/listOpen; 12 tests in test/task-store.test.js', + ); + b.wait(CODING_MISSION_PACE.step).add('task.completed', { + taskId: T.research.taskId, + summary: 'Mapped the API surface and the 12 tests', + artifactIds: [], + }); + b.wait(100).add('agent.state_changed', { agentId: researcher.agentId, state: 'idle' }); + + // ── T2 code ─────────────────────────────────────────────────────── + assignAndStart(T.code.taskId, builder.agentId); + working(builder.agentId); + tool(builder.agentId, T.code.taskId, 'read_file', 'src/task-store.js', { + status: 'success', + outputSummary: 'read 210 bytes', + durationMs: 2, + }); + const buggy = tool(builder.agentId, T.code.taskId, 'write_file', 'src/task-store.js', { + status: 'success', + outputSummary: 'wrote 900 bytes', + durationMs: 3, + artifact: { + kind: 'file', + name: 'task-store.js', + mediaType: 'text/javascript', + content: '// buggy implementation (fixture)\n', + path: 'src/task-store.js', + summary: 'Implementation of createStore()', + }, + }); + b.wait(CODING_MISSION_PACE.step).add('task.completed', { + taskId: T.code.taskId, + summary: 'Implemented the task store', + artifactIds: buggy, + }); + b.wait(100).add('agent.state_changed', { agentId: builder.agentId, state: 'idle' }); + + // ── T3 test: real failure ───────────────────────────────────────── + assignAndStart(T.test.taskId, tester.agentId); + working(tester.agentId); + tool(tester.agentId, T.test.taskId, 'run_command', M.testCommand, { + status: 'error', + exitCode: 1, + outputSummary: '12 tests: 10 passed, 2 failed', + durationMs: 380, + metrics: { tests: M.totalTests, passed: 10, failed: 2 }, + failingTests: [...M.failingTests], + errorType: 'tests_failed', + artifact: { + kind: 'test_report', + name: 'test-output.tap', + mediaType: 'text/plain', + content: 'TAP version 13\n# tests 12\n# pass 10\n# fail 2\n', + summary: 'node --test output (exit 1)', + }, + }); + created(T.fix, []); + b.wait(100).add('task.blocked', { + taskId: T.test.taskId, + reason: '2 of 12 tests failing', + blockedBy: [T.fix.taskId], + }); + b.wait(100).add('agent.state_changed', { + agentId: tester.agentId, + state: 'waiting', + reason: 'waiting for a fix', + }); + message(tester.agentId, builder.agentId, `Failing: ${M.failingTests.join('; ')}`); + + // ── T4 fix ──────────────────────────────────────────────────────── + assignAndStart(T.fix.taskId, builder.agentId); + working(builder.agentId); + tool(builder.agentId, T.fix.taskId, 'read_file', 'src/task-store.js', { + status: 'success', + outputSummary: 'read 900 bytes', + durationMs: 2, + }); + const fixed = tool(builder.agentId, T.fix.taskId, 'write_file', 'src/task-store.js', { + status: 'success', + outputSummary: 'wrote 901 bytes', + durationMs: 3, + artifact: { + kind: 'file', + name: 'task-store.js', + mediaType: 'text/javascript', + content: '// fixed implementation (fixture)\n', + path: 'src/task-store.js', + summary: 'listOpen() now keeps tasks that are not done', + }, + }); + b.wait(CODING_MISSION_PACE.step).add('task.completed', { + taskId: T.fix.taskId, + summary: 'Fixed listOpen() filter', + artifactIds: fixed, + }); + b.wait(100).add('agent.state_changed', { agentId: builder.agentId, state: 'idle' }); + + // ── T3 retry: pass ──────────────────────────────────────────────── + b.wait(CODING_MISSION_PACE.step).add('task.started', { taskId: T.test.taskId, agentId: tester.agentId }); + working(tester.agentId, 'retrying after fix'); + const report = tool(tester.agentId, T.test.taskId, 'run_command', M.testCommand, { + status: 'success', + exitCode: 0, + outputSummary: '12 tests: 12 passed, 0 failed', + durationMs: 360, + metrics: { tests: M.totalTests, passed: 12, failed: 0 }, + artifact: { + kind: 'test_report', + name: 'test-output.tap', + mediaType: 'text/plain', + content: 'TAP version 13\n# tests 12\n# pass 12\n# fail 0\n', + summary: 'node --test output (exit 0)', + }, + }); + b.wait(CODING_MISSION_PACE.step).add('task.completed', { + taskId: T.test.taskId, + summary: 'All 12 tests pass', + artifactIds: report, + }); + b.wait(100).add('agent.state_changed', { agentId: tester.agentId, state: 'idle' }); + b.wait(CODING_MISSION_PACE.step).add( + 'run.completed', + { outcome: 'succeeded', summary: 'All tasks completed; 12/12 tests pass' }, + { actorId: 'system' }, + ); + return b.events; +} + +/** Just the ordered event types of the canonical run (what the runtime must reproduce). */ +export function codingMissionTypeSequence(): string[] { + return codingMissionFixture().map((e) => e.type); +} diff --git a/packages/testkit/src/deliver.ts b/packages/testkit/src/deliver.ts new file mode 100644 index 0000000..ffbc955 --- /dev/null +++ b/packages/testkit/src/deliver.ts @@ -0,0 +1,33 @@ +import { mulberry32 } from './rng'; + +export interface DeliverOptions { + /** Probability that an event is delivered twice. */ + dupRate?: number; + /** Events are shuffled within windows of this size (simulates network reordering). */ + shuffleWindow?: number; +} + +/** + * Simulate an unreliable transport: seeded local shuffling plus duplicates. + * Every input event is delivered at least once. + */ +export function deliver(events: readonly E[], seed: number, opts: DeliverOptions = {}): E[] { + const rand = mulberry32(seed); + const dupRate = opts.dupRate ?? 0.2; + const window = Math.max(1, opts.shuffleWindow ?? 8); + const withDups: E[] = []; + for (const e of events) { + withDups.push(e); + if (rand() < dupRate) withDups.push(e); + } + const out: E[] = []; + for (let i = 0; i < withDups.length; i += window) { + const chunk = withDups.slice(i, i + window); + for (let j = chunk.length - 1; j > 0; j--) { + const k = Math.floor(rand() * (j + 1)); + [chunk[j], chunk[k]] = [chunk[k]!, chunk[j]!]; + } + out.push(...chunk); + } + return out; +} diff --git a/packages/testkit/src/ids.ts b/packages/testkit/src/ids.ts new file mode 100644 index 0000000..5515ce9 --- /dev/null +++ b/packages/testkit/src/ids.ts @@ -0,0 +1,18 @@ +/** + * Deterministic, valid RFC 4122 v4-shaped UUIDs (zod 4's z.uuid() checks the + * version and variant bits, so all-zero fakes are rejected). + */ +export function idGen(namespace = 0): () => string { + let n = 0; + const ns = (namespace >>> 0).toString(16).padStart(8, '0'); + return () => { + n += 1; + return `${ns}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; + }; +} + +/** A fixed valid UUID for a given small number, e.g. uuidFor(1, 7). */ +export function uuidFor(namespace: number, n: number): string { + const ns = (namespace >>> 0).toString(16).padStart(8, '0'); + return `${ns}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; +} diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts new file mode 100644 index 0000000..f4be734 --- /dev/null +++ b/packages/testkit/src/index.ts @@ -0,0 +1,6 @@ +export * from './ids'; +export * from './clock'; +export * from './rng'; +export * from './builder'; +export * from './deliver'; +export * from './coding-mission'; diff --git a/packages/testkit/src/rng.ts b/packages/testkit/src/rng.ts new file mode 100644 index 0000000..20765c8 --- /dev/null +++ b/packages/testkit/src/rng.ts @@ -0,0 +1,11 @@ +/** Small seeded PRNG (mulberry32). Returns floats in [0, 1). */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} From 1760e8e7e078c7f361e35b37733c18f8a243690d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:13:55 +0000 Subject: [PATCH 03/18] Add layer contracts and the Research Campus world Type contracts for Layer 1 (execution state), Layer 2 (world state, motion, presentation) and replay, the world definition schema, the Research Campus map, and the studio scene boundary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- .gitignore | 1 + apps/studio/next.config.ts | 2 + apps/studio/src/scene/types.ts | 47 +++++ packages/core/src/types.ts | 201 +++++++++++++++++++ packages/replay/src/types.ts | 8 + packages/world-engine/src/definition.ts | 109 ++++++++++ packages/world-engine/src/types.ts | 89 +++++++++ worlds/research-campus/world.json | 251 ++++++++++++++++++++++++ 8 files changed, 708 insertions(+) create mode 100644 apps/studio/src/scene/types.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/replay/src/types.ts create mode 100644 packages/world-engine/src/definition.ts create mode 100644 packages/world-engine/src/types.ts create mode 100644 worlds/research-campus/world.json diff --git a/.gitignore b/.gitignore index a3c86f7..6a2d645 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ .runloop/ .next/ +.next-*/ out/ coverage/ *.tsbuildinfo diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index 9cefa18..e972194 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -3,6 +3,8 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'export', + // Lets a second dev server (e.g. a scene preview) run from the same folder. + distDir: process.env.NEXT_DIST_DIR ?? '.next', reactStrictMode: true, transpilePackages: [ '@runloop-world/core', diff --git a/apps/studio/src/scene/types.ts b/apps/studio/src/scene/types.ts new file mode 100644 index 0000000..074071f --- /dev/null +++ b/apps/studio/src/scene/types.ts @@ -0,0 +1,47 @@ +import type { AnyEvent } from '@runloop-world/events'; +import type { RunState } from '@runloop-world/replay'; +import type { CompiledWorld } from '@runloop-world/world-engine'; + +/** What the 3D scene needs each frame. Read it inside useFrame; never put it in React state. */ +export interface SceneFrame { + /** Layer 1 + 2 state at the playhead (live: latest; replay: stateAt(seqAtTime(ms))). */ + state: RunState; + /** Fractional world tick at the playhead (msToTick(playheadMs, tickHz)). */ + tick: number; + /** Playhead in ms since run start. */ + playheadMs: number; + /** True while the playhead is advancing (live, or replay playing). */ + advancing: boolean; +} + +export type CameraMode = 'iso' | 'top'; +export type Quality = 'high' | 'low'; + +/** + * Contract between the Studio shell (owns data + UI) and the scene + * (owns rendering). The shell renders `` via + * next/dynamic with ssr:false; `scene/WorldCanvas.tsx` default-exports it. + */ +export interface SceneProps { + world: CompiledWorld; + /** Called every rendered frame. Must be cheap (memoized by the shell). */ + getFrame: () => SceneFrame; + /** + * Events that newly became visible because the playhead moved forward by a + * small step (live arrival or replay playback). Fires effects (tool pulses, + * message packets). NOT called on scrubs/jumps, so effects never replay in bulk. + * Returns an unsubscribe function. + */ + subscribeEffects: (fn: (events: readonly AnyEvent[]) => void) => () => void; + selectedAgentId: string | null; + selectedStationId: string | null; + onSelectAgent: (agentId: string | null) => void; + onSelectStation: (stationId: string | null) => void; + cameraMode: CameraMode; + /** Keep the camera centred on the selected character. */ + follow: boolean; + quality: Quality; + reducedMotion: boolean; + /** Set data-world-ready="true" on the canvas wrapper once the first frame rendered. */ + onReady?: () => void; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..7c12446 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,201 @@ +import type { + AdapterInfo, + AgentRuntimeState, + ArtifactKind, + PayloadOf, + TaskKind, + TestMetrics, + ToolFailureType, +} from '@runloop-world/events'; + +/** Layer 1 — execution truth, folded from recorded events only. */ + +export type RunExecStatus = 'pending' | 'running' | 'paused' | 'succeeded' | 'failed' | 'stopped' | 'interrupted'; + +export interface RunExec { + status: RunExecStatus; + title: string; + missionId: string; + worldId: string; + seed: number; + adapter: AdapterInfo | null; + workspace: string; + startedAt: string | null; + endedAt: string | null; + summary: string | null; +} + +export interface AgentExec { + id: string; + name: string; + role: string; + model: string | null; + tools: string[]; + embodied: boolean; + characterPreset: string | null; + state: AgentRuntimeState; + stateReason: string | null; + /** Task the agent currently owns and has not finished (assigned / in progress / blocked). */ + currentTaskId: string | null; + /** Tool call in flight, if any. */ + activeCallId: string | null; + pendingPermissionId: string | null; + /** Most recent task outcome for this agent (drives brief success/failure reactions). */ + lastTaskOutcome: { taskId: string; status: 'completed' | 'failed'; seq: number } | null; + registeredSeq: number; +} + +export type TaskStatus = 'pending' | 'assigned' | 'in_progress' | 'blocked' | 'completed' | 'failed'; + +export interface TaskExec { + id: string; + title: string; + kind: TaskKind; + dependsOn: string[]; + acceptance: string | null; + plannedSteps: number | null; + status: TaskStatus; + ownerId: string | null; + /** Number of task.started events seen. */ + attempts: number; + createdSeq: number; + /** Agent (or 'system') that created the task, from the envelope actorId. */ + createdBy: string | null; + startedSeq: number | null; + endedSeq: number | null; + blockedReason: string | null; + blockedBy: string[]; + failure: { reason: string; errorType: string | null } | null; + summary: string | null; + artifactIds: string[]; + /** Tool calls made for this task, in order (across attempts). */ + callIds: string[]; + /** Tool calls finished (any terminal status) during the current attempt. */ + stepsDoneThisAttempt: number; +} + +export type ToolCallStatus = 'running' | 'success' | 'error' | 'failed'; + +export interface ToolCallExec { + id: string; + agentId: string; + taskId: string | null; + tool: string; + inputSummary: string; + status: ToolCallStatus; + startedSeq: number; + startedAt: string; + startedEventId: string; + endedSeq: number | null; + endedAt: string | null; + endedEventId: string | null; + durationMs: number | null; + exitCode: number | null; + outputSummary: string | null; + metrics: TestMetrics | null; + failingTests: string[]; + outputArtifactIds: string[]; + /** tool.call_completed errorType, or tool.call_failed errorType. */ + errorType: string | ToolFailureType | null; + /** tool.call_failed message. */ + message: string | null; +} + +export interface PermissionExec { + id: string; + agentId: string; + callId: string | null; + action: string; + scope: string; + status: 'pending' | 'approved' | 'denied'; + requestedSeq: number; + resolvedSeq: number | null; + resolvedBy: string | null; +} + +export type ArtifactExec = PayloadOf<'artifact.created'> & { + kind: ArtifactKind; + seq: number; + eventId: string; + agentId: string | null; + taskId: string | null; + createdAt: string; +}; + +export type MessageExec = PayloadOf<'agent.message_sent'> & { + seq: number; + eventId: string; + timestamp: string; +}; + +export type IncidentKind = 'tool_error' | 'tool_failed' | 'task_failed' | 'run_failed'; + +/** A failure worth marking in the world and the trace. */ +export interface Incident { + seq: number; + eventId: string; + kind: IncidentKind; + /** callId, taskId or runId depending on kind. */ + refId: string; + agentId: string | null; + taskId: string | null; + message: string; + /** Sequence at which the related task later completed (marker greys out), if it did. */ + resolvedSeq: number | null; +} + +/** Something in the log this version could not interpret or that contradicts earlier events. */ +export interface Anomaly { + seq: number; + code: + | 'unknown_event' + | 'unknown_agent' + | 'unknown_task' + | 'unknown_call' + | 'unknown_permission' + | 'duplicate_id' + | 'unknown_dependency' + | 'dependency_cycle' + | 'invalid_transition'; + detail: string; +} + +export interface ExecState { + /** Last folded sequence (0 = nothing folded). */ + lastSequence: number; + runId: string | null; + /** Timestamp (ms since epoch) of run.started, or of the first event folded. */ + runStartMs: number | null; + run: RunExec; + agents: Record; + agentOrder: string[]; + tasks: Record; + taskOrder: string[]; + toolCalls: Record; + callOrder: string[]; + permissions: Record; + artifacts: Record; + artifactOrder: string[]; + messages: MessageExec[]; + incidents: Incident[]; + anomalies: Anomaly[]; +} + +/** A chip in the Agent Flow row, one per task kind in first-seen order. */ +export interface FlowChip { + kind: TaskKind; + status: 'pending' | 'current' | 'done' | 'failed'; + taskIds: string[]; +} + +/** One line of the text trace / CLI output for an event. */ +export interface FormattedEvent { + seq: number; + /** "mm:ss.s" since run start. */ + clock: string; + actor: string; + type: string; + label: string; + detail: string | null; + severity: 'info' | 'ok' | 'warn' | 'error'; +} diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts new file mode 100644 index 0000000..2a63128 --- /dev/null +++ b/packages/replay/src/types.ts @@ -0,0 +1,8 @@ +import type { ExecState } from '@runloop-world/core'; +import type { WorldState } from '@runloop-world/world-engine'; + +/** Combined Layer-1 + Layer-2 state at a sequence. */ +export interface RunState { + exec: ExecState; + world: WorldState; +} diff --git a/packages/world-engine/src/definition.ts b/packages/world-engine/src/definition.ts new file mode 100644 index 0000000..ce52de1 --- /dev/null +++ b/packages/world-engine/src/definition.ts @@ -0,0 +1,109 @@ +import { TaskKindSchema } from '@runloop-world/events'; +import * as z from 'zod'; + +/** A grid cell as [x, y]: x = column (left→right), y = row (top→bottom). */ +export const CellSchema = z.tuple([z.int().min(0), z.int().min(0)]); +export type Cell = z.infer; + +const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/); +const id = z.string().min(1); + +export const ZoneKindSchema = z.enum(['research', 'build', 'test', 'deploy', 'hub']); +export type ZoneKind = z.infer; + +export const StationKindSchema = z.enum(['library', 'build', 'test', 'gate', 'ops']); +export type StationKind = z.infer; + +export const PropKindSchema = z.enum(['rack', 'console', 'crate', 'plant', 'lamp']); +export type PropKind = z.infer; + +export const ZoneSchema = z.object({ + id, + label: z.string(), + kind: ZoneKindSchema, + /** [x, y, width, height] in cells. Walls of a room are the '#' cells on its border. */ + rect: z.tuple([z.int().min(0), z.int().min(0), z.int().min(1), z.int().min(1)]), + color: hex, + /** A doorway cell, used for decorative path strips between the hub and the room. */ + door: CellSchema.optional(), +}); +export type Zone = z.infer; + +export const StationSchema = z.object({ + id, + zoneId: id, + kind: StationKindSchema, + label: z.string(), + /** Footprint cell (blocked for walking). */ + cell: CellSchema, + /** Direction the working character faces the station from. */ + facing: z.enum(['n', 'e', 's', 'w']), + /** Walkable cells where characters stand while working; capacity = slots.length. */ + slots: z.array(CellSchema).min(1), + /** Walkable cell where extra characters wait when every slot is taken. */ + overflow: CellSchema, +}); +export type Station = z.infer; + +export const PropSchema = z.object({ + kind: PropKindSchema, + /** Footprint cell (blocked for walking). */ + cell: CellSchema, +}); +export type Prop = z.infer; + +export const ActivityRuleSchema = z.object({ + when: z.object({ + taskKind: z.array(TaskKindSchema).optional(), + /** Matches when the agent has a pending permission request. */ + permission: z.literal(true).optional(), + }), + stationKind: StationKindSchema, +}); +export type ActivityRule = z.infer; + +export const CharacterPresetSchema = z.object({ + id, + label: z.string(), + color: hex, + icon: z.string(), + home: CellSchema, +}); +export type CharacterPreset = z.infer; + +/** + * A world definition: the map plus the rules that map execution activity to + * places. Swapping this file changes where characters go without touching + * the execution engine or the renderer. + */ +export const WorldDefinitionSchema = z.object({ + id, + version: z.literal(1), + name: z.string(), + description: z.string().optional(), + /** Fixed simulation rate (ticks per second). */ + tickHz: z.number().positive(), + /** Ticks needed to walk one cell. */ + ticksPerCell: z.int().min(1), + /** Rows of the map: '#' = wall (blocked), '.' = floor. All rows have equal length. */ + grid: z.array(z.string().regex(/^[#.]+$/)).min(1), + zones: z.array(ZoneSchema).min(1), + hub: z.object({ + /** Visual centre of the hub (may be fractional). */ + center: z.tuple([z.number(), z.number()]), + /** Blocked cells occupied by the hub core. */ + core: z.array(CellSchema), + }), + stations: z.array(StationSchema).min(1), + props: z.array(PropSchema).default([]), + /** First matching rule wins; no match → the character goes home. */ + activityRules: z.array(ActivityRuleSchema), + characterPresets: z.array(CharacterPresetSchema).min(1), + /** Preset for embodied agents that name none (or an unknown one). */ + defaultPreset: id, +}); +export type WorldDefinition = z.infer; + +export function parseWorldDefinition(raw: unknown): WorldDefinition { + return WorldDefinitionSchema.parse(raw); +} diff --git a/packages/world-engine/src/types.ts b/packages/world-engine/src/types.ts new file mode 100644 index 0000000..201e4eb --- /dev/null +++ b/packages/world-engine/src/types.ts @@ -0,0 +1,89 @@ +import type { Cell, CharacterPreset, Station, WorldDefinition, Zone, StationKind } from './definition'; + +/** A world definition with derived lookup tables (build once with compileWorld()). */ +export interface CompiledWorld { + def: WorldDefinition; + width: number; + height: number; + /** Walkable = floor and not a station / prop / hub-core footprint. */ + isWalkable(x: number, y: number): boolean; + zonesById: Record; + stationsById: Record; + /** Stations of each kind, sorted by id (deterministic allocation order). */ + stationsByKind: Partial>; + presetsById: Record; +} + +/** Closed-form movement: walk `path` one cell per ticksPerCell, starting at startTick. */ +export interface Motion { + /** Cells from the start cell to the destination (inclusive). Length ≥ 1. */ + path: Cell[]; + startTick: number; +} + +/** What the world wants a character to be doing (derived from Layer-1 state). */ +export type Activity = 'idle' | 'working' | 'waiting' | 'awaiting_approval' | 'succeeded' | 'failed'; + +export type Target = + | { kind: 'home' } + | { kind: 'stay' } + | { kind: 'slot'; stationId: string; index: number } + | { kind: 'overflow'; stationId: string }; + +export interface CharacterWorld { + agentId: string; + presetId: string; + home: Cell; + activity: Activity; + /** Task driving the current activity, if any. */ + taskId: string | null; + /** Station involved in the current activity (also kept for 'waiting' in overflow). */ + stationId: string | null; + target: Target; + motion: Motion; + /** Tick at which the activity last changed. */ + sinceTick: number; +} + +export interface StationWorld { + /** Occupant agentId per slot (null = free). */ + slots: (string | null)[]; + /** Agents queued at the overflow cell, in arrival order. */ + overflow: string[]; +} + +/** Layer 2 — world simulation state, a pure function of events + world definition. */ +export interface WorldState { + lastSequence: number; + /** Monotonic tick of the last folded event (ticks since run start). */ + tick: number; + /** Run start in ms since epoch, from the first folded event. */ + runStartMs: number | null; + paused: boolean; + characters: Record; + characterOrder: string[]; + stations: Record; +} + +/** Presentation state for the renderer (Layer 3 input). */ +export type PresentationState = + | 'idle' + | 'moving' + | 'working' + | 'waiting' + | 'awaiting_approval' + | 'succeeded' + | 'failed' + | 'paused'; + +export interface CharacterPose { + state: PresentationState; + /** Continuous grid-space position (cell centres are integers). */ + x: number; + y: number; + /** Heading in radians in grid space (0 = +x, π/2 = +y). */ + heading: number; + moving: boolean; + /** Cell the character is in or entering. */ + cell: Cell; +} diff --git a/worlds/research-campus/world.json b/worlds/research-campus/world.json new file mode 100644 index 0000000..58c6df8 --- /dev/null +++ b/worlds/research-campus/world.json @@ -0,0 +1,251 @@ +{ + "id": "research-campus", + "version": 1, + "name": "Research Campus", + "description": "Four cutaway rooms around a central hub: Research, Build, Test and Deploy.", + "tickHz": 10, + "ticksPerCell": 3, + "grid": [ + "........................", + ".#########....#########.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".######..#....#..######.", + "........................", + "........................", + "........................", + "........................", + ".######..#....#..######.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".#.......#....#.......#.", + ".#########....#########.", + "........................" + ], + "zones": [ + { + "id": "research", + "label": "Research", + "kind": "research", + "rect": [1, 1, 9, 7], + "color": "#38bdf8", + "door": [7, 7] + }, + { + "id": "build", + "label": "Build", + "kind": "build", + "rect": [14, 1, 9, 7], + "color": "#818cf8", + "door": [15, 7] + }, + { + "id": "test", + "label": "Test", + "kind": "test", + "rect": [1, 12, 9, 7], + "color": "#34d399", + "door": [7, 12] + }, + { + "id": "deploy", + "label": "Deploy", + "kind": "deploy", + "rect": [14, 12, 9, 7], + "color": "#c084fc", + "door": [15, 12] + }, + { + "id": "hub", + "label": "Hub", + "kind": "hub", + "rect": [10, 8, 4, 4], + "color": "#f59e0b" + } + ], + "hub": { + "center": [11.5, 9.5], + "core": [[11, 9], [12, 9], [11, 10], [12, 10]] + }, + "stations": [ + { + "id": "archive", + "zoneId": "research", + "kind": "library", + "label": "Archive Terminal", + "cell": [3, 3], + "facing": "s", + "slots": [[3, 4]], + "overflow": [4, 5] + }, + { + "id": "datawall", + "zoneId": "research", + "kind": "library", + "label": "Data Wall", + "cell": [6, 3], + "facing": "s", + "slots": [[6, 4]], + "overflow": [5, 5] + }, + { + "id": "forge", + "zoneId": "build", + "kind": "build", + "label": "Build Bench", + "cell": [17, 3], + "facing": "s", + "slots": [[17, 4]], + "overflow": [18, 5] + }, + { + "id": "assembler", + "zoneId": "build", + "kind": "build", + "label": "Assembler", + "cell": [20, 3], + "facing": "s", + "slots": [[20, 4]], + "overflow": [19, 5] + }, + { + "id": "testrig", + "zoneId": "test", + "kind": "test", + "label": "Test Rig", + "cell": [3, 16], + "facing": "n", + "slots": [[3, 15]], + "overflow": [4, 14] + }, + { + "id": "probe", + "zoneId": "test", + "kind": "test", + "label": "Probe Bench", + "cell": [6, 16], + "facing": "n", + "slots": [[6, 15]], + "overflow": [5, 14] + }, + { + "id": "launchpad", + "zoneId": "deploy", + "kind": "ops", + "label": "Launch Pad", + "cell": [18, 16], + "facing": "n", + "slots": [[18, 15]], + "overflow": [17, 14] + }, + { + "id": "gate", + "zoneId": "hub", + "kind": "gate", + "label": "Approval Gate", + "cell": [11, 16], + "facing": "n", + "slots": [[11, 15]], + "overflow": [12, 14] + } + ], + "props": [ + {"kind": "rack", "cell": [2, 2]}, + {"kind": "rack", "cell": [8, 2]}, + {"kind": "console", "cell": [15, 2]}, + {"kind": "rack", "cell": [21, 2]}, + {"kind": "crate", "cell": [2, 17]}, + {"kind": "plant", "cell": [8, 17]}, + {"kind": "rack", "cell": [21, 17]}, + {"kind": "plant", "cell": [15, 17]}, + {"kind": "plant", "cell": [0, 9]}, + {"kind": "crate", "cell": [23, 10]}, + {"kind": "lamp", "cell": [10, 13]}, + {"kind": "lamp", "cell": [13, 6]} + ], + "activityRules": [ + { + "when": { + "permission": true + }, + "stationKind": "gate" + }, + { + "when": { + "taskKind": [ + "research" + ] + }, + "stationKind": "library" + }, + { + "when": { + "taskKind": [ + "code", + "fix" + ] + }, + "stationKind": "build" + }, + { + "when": { + "taskKind": [ + "test" + ] + }, + "stationKind": "test" + }, + { + "when": { + "taskKind": [ + "approval" + ] + }, + "stationKind": "gate" + }, + { + "when": { + "taskKind": [ + "deploy" + ] + }, + "stationKind": "ops" + } + ], + "characterPresets": [ + { + "id": "researcher", + "label": "Researcher", + "color": "#38bdf8", + "icon": "search", + "home": [10, 8] + }, + { + "id": "builder", + "label": "Builder", + "color": "#f59e0b", + "icon": "hammer", + "home": [13, 8] + }, + { + "id": "tester", + "label": "Tester", + "color": "#34d399", + "icon": "flask", + "home": [10, 11] + }, + { + "id": "operator", + "label": "Operator", + "color": "#c084fc", + "icon": "rocket", + "home": [13, 11] + } + ], + "defaultPreset": "operator" +} From 3d1bb731f585fa9bbdd614b2b0e9a73c0f90528e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:16:31 +0000 Subject: [PATCH 04/18] Export the layer contracts from package entry points Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/core/src/index.ts | 1 + packages/replay/src/index.ts | 1 + packages/runtime/src/index.ts | 1 + packages/world-engine/src/index.ts | 2 ++ 4 files changed, 5 insertions(+) create mode 100644 packages/core/src/index.ts create mode 100644 packages/replay/src/index.ts create mode 100644 packages/runtime/src/index.ts create mode 100644 packages/world-engine/src/index.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..fcb073f --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts new file mode 100644 index 0000000..fcb073f --- /dev/null +++ b/packages/replay/src/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts new file mode 100644 index 0000000..43ce043 --- /dev/null +++ b/packages/world-engine/src/index.ts @@ -0,0 +1,2 @@ +export * from './definition'; +export * from './types'; From eeef673b93b0f5bfc206fefdad8e5842dac9147c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:33:52 +0000 Subject: [PATCH 05/18] Add events tests Parsing, redaction and sequence-buffer tests, including 200 seeded shuffles with duplicates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/events/src/parse.test.ts | 94 +++++++++++++++++++++ packages/events/src/redact.test.ts | 74 ++++++++++++++++ packages/events/src/sequence-buffer.test.ts | 77 +++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 packages/events/src/parse.test.ts create mode 100644 packages/events/src/redact.test.ts create mode 100644 packages/events/src/sequence-buffer.test.ts diff --git a/packages/events/src/parse.test.ts b/packages/events/src/parse.test.ts new file mode 100644 index 0000000..5a00742 --- /dev/null +++ b/packages/events/src/parse.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +// events has no dependency on testkit (testkit depends on events), so tests import its source directly. +import { codingMissionFixture } from '../../testkit/src/index'; +import { deriveRefs, isUnknownEvent, type EventDraft } from './event'; +import { parseEvent, parseKnownEvent } from './parse'; +import { EVENT_TYPES, isEventType } from './payloads'; + +const fixture = codingMissionFixture(); +const sample = () => JSON.parse(JSON.stringify(fixture[0]!)) as Record; + +describe('parseEvent', () => { + it('accepts every fixture event (after a JSON round trip) as a known event', () => { + for (const e of fixture) { + const r = parseEvent(JSON.parse(JSON.stringify(e))); + expect(r.ok, `#${e.sequence}`).toBe(true); + if (!r.ok) continue; + expect(isUnknownEvent(r.event)).toBe(false); + expect(r.event).toEqual(e); + expect(parseKnownEvent(e).ok).toBe(true); + } + }); + + it('covers most event types with the fixture', () => { + const seen = new Set(fixture.map((e) => e.type)); + const missing = EVENT_TYPES.filter((t) => !seen.has(t)); + expect(missing.sort()).toEqual(['run.paused', 'run.resumed', 'task.failed', 'tool.call_failed', 'tool.permission_requested', 'tool.permission_resolved'].sort()); + }); + + it.each([ + ['bad eventId', { eventId: 'not-a-uuid' }], + ['uuid with an invalid version', { runId: '00000001-0000-0000-8000-000000000001' }], + ['sequence 0', { sequence: 0 }], + ['fractional sequence', { sequence: 1.5 }], + ['bad visibility', { visibility: 'secret' }], + ['bad timestamp', { timestamp: 'yesterday' }], + ['missing type', { type: undefined }], + ])('rejects an invalid envelope (%s) without throwing', (_name, patch) => { + const r = parseEvent({ ...sample(), ...patch }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.length).toBeGreaterThan(0); + }); + + it.each([null, undefined, 42, 'event', [], {}])('rejects non-events (%s) without throwing', (raw) => { + expect(parseEvent(raw).ok).toBe(false); + }); + + it('returns an UnknownEvent for an unknown type', () => { + const r = parseEvent({ ...sample(), type: 'world.weather_changed', payload: { rain: true } }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(isUnknownEvent(r.event)).toBe(true); + expect(r.event).toMatchObject({ unknown: true, reason: 'unknown_type', type: 'world.weather_changed', payload: { rain: true } }); + expect(parseKnownEvent({ ...sample(), type: 'world.weather_changed' })).toMatchObject({ ok: false }); + }); + + it('returns an UnknownEvent for an unsupported schema version', () => { + const r = parseEvent({ ...sample(), schemaVersion: 2 }); + expect(r.ok && isUnknownEvent(r.event) && r.event.reason).toBe('unsupported_version'); + }); + + it('returns an UnknownEvent with the error for an invalid payload', () => { + const r = parseEvent({ ...sample(), payload: { title: 7 } }); + expect(r.ok).toBe(true); + if (!r.ok || !isUnknownEvent(r.event)) throw new Error('expected an UnknownEvent'); + expect(r.event.reason).toBe('invalid_payload'); + expect(r.event.error).toMatch(/title/); + const strict = parseKnownEvent({ ...sample(), payload: { title: 7 } }); + expect(strict.ok).toBe(false); + if (!strict.ok) expect(strict.error).toMatch(/^invalid_payload/); + }); + + it('rejects a failing test run reported with negative counts', () => { + const completed = fixture.find((e) => e.type === 'tool.call_completed' && e.payload.metrics)!; + const bad = { ...completed, payload: { ...completed.payload, metrics: { tests: 12, passed: -1, failed: 2 } } }; + expect(parseKnownEvent(bad).ok).toBe(false); + }); +}); + +describe('event helpers', () => { + it('isEventType knows exactly the payload schemas', () => { + for (const t of EVENT_TYPES) expect(isEventType(t)).toBe(true); + expect(isEventType('world.anything')).toBe(false); + }); + + it('deriveRefs copies subject ids from the payload', () => { + const d = (draft: EventDraft) => deriveRefs(draft); + expect(d({ type: 'task.started', payload: { taskId: 'T1', agentId: 'a' } })).toEqual({ actorId: 'a', taskId: 'T1' }); + expect(d({ type: 'task.completed', payload: { taskId: 'T1', summary: '', artifactIds: [] } })).toEqual({ taskId: 'T1' }); + expect(d({ type: 'agent.message_sent', payload: { messageId: 'm', fromAgentId: 'a', to: 'b', text: '' } })).toEqual({ + actorId: 'a', + }); + expect(d({ type: 'run.completed', payload: { outcome: 'succeeded' } })).toEqual({}); + }); +}); diff --git a/packages/events/src/redact.test.ts b/packages/events/src/redact.test.ts new file mode 100644 index 0000000..ac2d945 --- /dev/null +++ b/packages/events/src/redact.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { REDACTED, redactEvent, redactText, redactValue } from './redact'; + +const secrets = { + openai: 'sk-abcdefghijklmnopqrstuvwxyz012345', + github: 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', + githubPat: 'github_pat_11ABCDEFG0123456789_abcdefghijklmnop', + aws: 'AKIAIOSFODNN7EXAMPLE', + slack: 'xoxb-1234567890-abcdefghij', +}; + +describe('redactText', () => { + it.each(Object.entries(secrets))('masks %s tokens', (_name, token) => { + const out = redactText(`token=${token} end`); + expect(out).toBe(`token=${REDACTED} end`); + expect(out).not.toContain(token); + }); + + it('masks bearer credentials but keeps the scheme', () => { + expect(redactText('Authorization: Bearer abc.def-ghi_123')).toBe(`Authorization: Bearer ${REDACTED}`); + }); + + it('masks literal secret values of 8+ characters only', () => { + expect(redactText('pw=hunter2hunter2!', { secrets: ['hunter2hunter2'] })).toBe(`pw=${REDACTED}!`); + expect(redactText('short=abc', { secrets: ['abc'] })).toBe('short=abc'); + expect(redactText('a.b*c+d(e)f a.b*c+d(e)f', { secrets: ['a.b*c+d(e)f'] })).toBe(`${REDACTED} ${REDACTED}`); + }); + + it('leaves ordinary text untouched and is idempotent', () => { + expect(redactText('12 tests: 10 passed, 2 failed')).toBe('12 tests: 10 passed, 2 failed'); + const once = redactText(`a ${secrets.openai} b Bearer xyzxyzxyz ${secrets.aws}`, { secrets: ['supersecretvalue'] }); + expect(redactText(once, { secrets: ['supersecretvalue'] })).toBe(once); + }); +}); + +describe('redactValue', () => { + it('walks nested arrays and objects, keeping untouched references', () => { + const clean = { n: 1, list: ['a'] }; + const value = { clean, deep: { args: ['x', `key ${secrets.github}`] }, flag: true, nil: null }; + const out = redactValue(value); + expect(out.deep.args[1]).toBe(`key ${REDACTED}`); + expect(out.clean).toBe(clean); + expect(out.flag).toBe(true); + expect(out.nil).toBeNull(); + expect(value.deep.args[1]).toContain(secrets.github); // input not mutated + expect(redactValue(clean)).toBe(clean); + }); +}); + +describe('redactEvent', () => { + const event = { type: 'tool.call_started', visibility: 'public' as const, payload: { inputSummary: `curl -H "Bearer ${'t'.repeat(20)}"` } }; + + it('redacts the payload and marks the event redacted', () => { + const { event: out, changed } = redactEvent(event); + expect(changed).toBe(true); + expect(out.visibility).toBe('redacted'); + expect(out.payload.inputSummary).toBe(`curl -H "Bearer ${REDACTED}"`); + expect(event.visibility).toBe('public'); + }); + + it('is idempotent', () => { + const once = redactEvent(event).event; + const twice = redactEvent(once); + expect(twice.changed).toBe(false); + expect(twice.event).toBe(once); + }); + + it('keeps sensitive events sensitive and returns clean events as-is', () => { + expect(redactEvent({ ...event, visibility: 'sensitive' as const }).event.visibility).toBe('sensitive'); + const clean = { visibility: 'public' as const, payload: { text: 'hello' } }; + expect(redactEvent(clean)).toEqual({ event: clean, changed: false }); + expect(redactEvent(clean).event).toBe(clean); + }); +}); diff --git a/packages/events/src/sequence-buffer.test.ts b/packages/events/src/sequence-buffer.test.ts new file mode 100644 index 0000000..3e13d7f --- /dev/null +++ b/packages/events/src/sequence-buffer.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +// events has no dependency on testkit (testkit depends on events), so tests import its source directly. +import { codingMissionFixture, deliver, mulberry32 } from '../../testkit/src/index'; +import { SequenceBuffer, type Sequenced } from './sequence-buffer'; + +const ev = (sequence: number, eventId = `e${sequence}`): Sequenced => ({ sequence, eventId }); +const seqs = (list: Sequenced[]) => list.map((e) => e.sequence); + +describe('SequenceBuffer', () => { + it('releases contiguous runs only, in order', () => { + const b = new SequenceBuffer(); + expect(b.push(ev(2))).toEqual([]); + expect(b.push(ev(3))).toEqual([]); + expect(b.pendingCount).toBe(2); + expect(seqs(b.push(ev(1)))).toEqual([1, 2, 3]); + expect(b.nextExpected).toBe(4); + expect(b.lastReleased).toBe(3); + expect(b.pendingCount).toBe(0); + }); + + it('drops duplicates with the same eventId silently', () => { + const b = new SequenceBuffer(); + b.push(ev(1)); + expect(b.push(ev(1))).toEqual([]); // already released + b.push(ev(3)); + expect(b.push(ev(3))).toEqual([]); // already pending + expect(b.conflicts).toEqual([]); + expect(seqs(b.push(ev(2)))).toEqual([2, 3]); + }); + + it('records conflicts for the same sequence with a different eventId and keeps the first', () => { + const b = new SequenceBuffer(); + b.push(ev(1, 'a')); + b.push(ev(1, 'b')); + b.push(ev(3, 'c')); + b.push(ev(3, 'd')); + expect(b.conflicts).toEqual([ + { sequence: 1, keptEventId: 'a', droppedEventId: 'b' }, + { sequence: 3, keptEventId: 'c', droppedEventId: 'd' }, + ]); + expect(b.push(ev(2))[1]!.eventId).toBe('c'); + }); + + it('reports the gap blocking release', () => { + const b = new SequenceBuffer(); + expect(b.missing()).toBeNull(); + b.push(ev(1)); + b.push(ev(4)); + b.push(ev(7)); + expect(b.missing()).toEqual({ from: 2, to: 3 }); + b.pushMany([ev(2), ev(3)]); + expect(b.missing()).toEqual({ from: 5, to: 6 }); + expect(seqs(b.pushMany([ev(6), ev(5)]))).toEqual([5, 6, 7]); + expect(b.missing()).toBeNull(); + }); + + it('can start after an already-consumed sequence', () => { + const b = new SequenceBuffer({ after: 10 }); + expect(b.push(ev(9))).toEqual([]); + expect(seqs(b.push(ev(11)))).toEqual([11]); + }); + + it('restores the original order across 200 seeded shuffles with 20% duplicates', () => { + const events = codingMissionFixture(); + const ids = events.map((e) => e.eventId); + for (let seed = 1; seed <= 200; seed++) { + const window = 2 + Math.floor(mulberry32(seed)() * 15); + const delivered = deliver(events, seed, { dupRate: 0.2, shuffleWindow: window }); + expect(delivered.length).toBeGreaterThan(events.length); + const b = new SequenceBuffer<(typeof events)[number]>(); + const out = b.pushMany(delivered); + expect(out.map((e) => e.eventId)).toEqual(ids); + expect(b.conflicts).toEqual([]); + expect(b.missing()).toBeNull(); + } + }); +}); From f9f6cd5f2c35eeddf94ca103d2ee8cec9a9cd603 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:33:52 +0000 Subject: [PATCH 06/18] Add core execution reducer (Layer 1) Pure, idempotent fold of recorded events into run, agent, task, tool call, artifact, message, incident and anomaly state, with selectors and a text-trace formatter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/core/src/errors.ts | 12 + packages/core/src/format.test.ts | 71 +++ packages/core/src/format.ts | 132 ++++++ packages/core/src/index.ts | 11 + packages/core/src/reduce-execution.test.ts | 256 ++++++++++ packages/core/src/reduce-execution.ts | 517 +++++++++++++++++++++ packages/core/src/selectors.test.ts | 80 ++++ packages/core/src/selectors.ts | 63 +++ packages/core/src/types.ts | 2 +- 9 files changed, 1143 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/format.test.ts create mode 100644 packages/core/src/format.ts create mode 100644 packages/core/src/reduce-execution.test.ts create mode 100644 packages/core/src/reduce-execution.ts create mode 100644 packages/core/src/selectors.test.ts create mode 100644 packages/core/src/selectors.ts diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..1ff8c02 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,12 @@ +/** Thrown by reducers when an event does not directly follow the last folded sequence. */ +export class SequenceGapError extends Error { + readonly expected: number; + readonly got: number; + + constructor(expected: number, got: number) { + super(`Sequence gap: expected ${expected}, got ${got}`); + this.name = 'SequenceGapError'; + this.expected = expected; + this.got = got; + } +} diff --git a/packages/core/src/format.test.ts b/packages/core/src/format.test.ts new file mode 100644 index 0000000..88e0226 --- /dev/null +++ b/packages/core/src/format.test.ts @@ -0,0 +1,71 @@ +import type { UnknownEvent } from '@runloop-world/events'; +import { codingMissionFixture } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { formatClock, formatEvent } from './format'; +import { foldExecution, initialExecState } from './reduce-execution'; + +describe('formatClock', () => { + it('formats mm:ss', () => { + expect(formatClock(92000)).toBe('01:32'); + expect(formatClock(0)).toBe('00:00'); + expect(formatClock(999)).toBe('00:00'); + expect(formatClock(-5000)).toBe('00:00'); + expect(formatClock(195_000)).toBe('03:15'); + expect(formatClock(100 * 60_000)).toBe('100:00'); + }); +}); + +describe('formatEvent', () => { + const events = codingMissionFixture(); + const s = foldExecution(events); + + it('labels the failing test run from its payload metrics', () => { + const e = events.find((x) => x.type === 'tool.call_completed' && x.payload.status === 'error')!; + const f = formatEvent(e, s); + expect(f.label).toBe('run_command error · 10 passed, 2 failed'); + expect(f.detail).toBe('12 tests: 10 passed, 2 failed (380 ms)'); + expect(f.severity).toBe('error'); + expect(f.actor).toBe('tester'); + expect(f.seq).toBe(e.sequence); + expect(f.type).toBe('tool.call_completed'); + expect(f.clock).toMatch(/^\d\d:\d\d\.\d$/); + }); + + it('computes the clock from the run start', () => { + const last = events.at(-1)!; + const ms = Date.parse(last.timestamp) - Date.parse(events[0]!.timestamp); + const f = formatEvent(last, s); + expect(f.clock).toBe(`${formatClock(ms)}.${Math.floor(ms / 100) % 10}`); + expect(formatEvent(events[0]!, s).clock).toBe('00:00.0'); + expect(formatEvent(events[0]!).clock).toBe('--:--.-'); + expect(formatEvent(events[0]!, initialExecState()).clock).toBe('00:00.0'); + }); + + it('formats every fixture event without state, using only the payload', () => { + for (const e of events) { + const f = formatEvent(e); + expect(f.label.length).toBeGreaterThan(0); + expect(f.label).not.toContain('undefined'); + } + const completed = events.find((x) => x.type === 'tool.call_completed' && x.payload.status === 'error')!; + expect(formatEvent(completed).label).toBe('call-6 error · 10 passed, 2 failed'); + }); + + it('attributes task events to the task owner and system events to "system"', () => { + const done = events.find((x) => x.type === 'task.completed')!; + expect(formatEvent(done, s).actor).toBe('researcher'); + expect(formatEvent(events[0]!, s).actor).toBe('system'); + expect(formatEvent(events.at(-1)!, s)).toMatchObject({ label: 'Run succeeded', severity: 'ok' }); + }); + + it('shows unknown events as warnings', () => { + const u: UnknownEvent = { + ...events[1]!, + type: 'world.custom', + payload: { x: 1 }, + unknown: true, + reason: 'unknown_type', + }; + expect(formatEvent(u, s)).toMatchObject({ type: 'world.custom', severity: 'warn', label: 'Unrecognised event (unknown type)' }); + }); +}); diff --git a/packages/core/src/format.ts b/packages/core/src/format.ts new file mode 100644 index 0000000..c8606ce --- /dev/null +++ b/packages/core/src/format.ts @@ -0,0 +1,132 @@ +import { isUnknownEvent, type AnyEvent, type RunEvent } from '@runloop-world/events'; +import type { ExecState, FormattedEvent } from './types'; + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** Milliseconds → "mm:ss" (92000 → "01:32"). Negative values clamp to 0; minutes may exceed 99. */ +export function formatClock(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + return `${pad2(Math.floor(total / 60))}:${pad2(total % 60)}`; +} + +/** Milliseconds → "mm:ss.s" (21400 → "00:21.4"). */ +function formatClockTenths(ms: number): string { + const tenths = Math.max(0, Math.floor(ms / 100)); + return `${formatClock(Math.floor(tenths / 10) * 1000)}.${tenths % 10}`; +} + +/** + * One text-trace line for an event (timeline rows, Logs, CLI output). + * Text comes from payload fields only (never parsed tool output). `s` (the + * state before or after folding `e`), when given, supplies the run start for + * the clock, tool names for call results and task owners as actors. + */ +export function formatEvent(e: AnyEvent, s?: ExecState): FormattedEvent { + // With a state, an event folded before anything else marks the run start itself. + const startMs = s ? (s.runStartMs ?? Date.parse(e.timestamp)) : null; + const clock = startMs === null ? '--:--.-' : formatClockTenths(Date.parse(e.timestamp) - startMs); + const base = { seq: e.sequence, clock, type: e.type }; + + if (isUnknownEvent(e)) { + return { + ...base, + actor: e.actorId ?? 'system', + label: `Unrecognised event (${e.reason.replace('_', ' ')})`, + detail: e.error ?? null, + severity: 'warn', + }; + } + const { label, detail = null, severity = 'info', actor } = describe(e, s); + // Task lifecycle events often carry only a taskId: attribute them to the task's owner. + const owner = e.taskId ? (s?.tasks[e.taskId]?.ownerId ?? undefined) : undefined; + return { ...base, actor: e.actorId ?? actor ?? owner ?? 'system', label, detail, severity }; +} + +interface Described { + label: string; + detail?: string | null; + severity?: FormattedEvent['severity']; + actor?: string; +} + +function describe(e: RunEvent, s?: ExecState): Described { + switch (e.type) { + case 'run.started': + return { label: `Run started: ${e.payload.title}`, detail: e.payload.adapter.label }; + case 'run.paused': + return { label: 'Run paused', detail: e.payload.reason ?? null, severity: 'warn' }; + case 'run.resumed': + return { label: 'Run resumed' }; + case 'run.completed': { + const o = e.payload.outcome; + return { + label: `Run ${o}`, + detail: e.payload.summary ?? null, + severity: o === 'succeeded' ? 'ok' : o === 'failed' ? 'error' : 'warn', + }; + } + case 'agent.registered': { + const p = e.payload; + const tools = p.tools.length ? `tools: ${p.tools.join(', ')}` : 'no tools'; + return { label: `Registered ${p.name} (${p.role})`, detail: p.embodied ? tools : `${tools}; no character` }; + } + case 'agent.state_changed': { + const p = e.payload; + return { + label: `State → ${p.state}`, + detail: p.reason ?? null, + severity: p.state === 'failed' ? 'error' : p.state === 'blocked' ? 'warn' : 'info', + }; + } + case 'agent.message_sent': + return { label: `Message to ${e.payload.to}`, detail: e.payload.text }; + case 'task.created': { + const p = e.payload; + const deps = p.dependsOn.length ? `after ${p.dependsOn.join(', ')}` : null; + return { label: `Created ${p.taskId} [${p.kind}]: ${p.title}`, detail: deps }; + } + case 'task.assigned': + return { label: `Assigned ${e.payload.taskId} to ${e.payload.agentId}` }; + case 'task.started': + return { label: `Started ${e.payload.taskId}` }; + case 'task.blocked': + return { label: `Blocked ${e.payload.taskId}`, detail: e.payload.reason, severity: 'warn' }; + case 'task.completed': + return { label: `Completed ${e.payload.taskId}`, detail: e.payload.summary, severity: 'ok' }; + case 'task.failed': + return { label: `Failed ${e.payload.taskId}`, detail: e.payload.reason, severity: 'error' }; + case 'tool.call_started': + return { label: `${e.payload.tool} ${e.payload.inputSummary}`.trim() }; + case 'tool.call_completed': { + const p = e.payload; + const call = s?.toolCalls[p.callId]; + const counts = p.metrics ? ` · ${p.metrics.passed} passed, ${p.metrics.failed} failed` : ''; + return { + label: `${call?.tool ?? p.callId} ${p.status}${counts}`, + detail: `${p.outputSummary} (${Math.round(p.durationMs)} ms)`, + severity: p.status === 'success' ? 'ok' : 'error', + ...(call ? { actor: call.agentId } : {}), + }; + } + case 'tool.call_failed': { + const p = e.payload; + const call = s?.toolCalls[p.callId]; + return { + label: `${call?.tool ?? p.callId} could not run (${p.errorType})`, + detail: p.message, + severity: 'error', + ...(call ? { actor: call.agentId } : {}), + }; + } + case 'tool.permission_requested': + return { label: `Permission requested: ${e.payload.action}`, detail: e.payload.scope, severity: 'warn' }; + case 'tool.permission_resolved': { + const p = e.payload; + return { label: `Permission ${p.decision} by ${p.by}`, severity: p.decision === 'approved' ? 'ok' : 'error' }; + } + case 'artifact.created': { + const p = e.payload; + return { label: `Artifact ${p.name} (${p.kind}, ${p.bytes} B)`, detail: p.summary }; + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fcb073f..6ff0980 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,12 @@ export * from './types'; +export { SequenceGapError } from './errors'; +export { initialExecState, reduceExecution, foldExecution } from './reduce-execution'; +export { + selectReadyTasks, + selectRecentActions, + selectTaskProgress, + selectAgentFlow, + selectEmbodiedAgents, + selectOpenIncidents, +} from './selectors'; +export { formatClock, formatEvent } from './format'; diff --git a/packages/core/src/reduce-execution.test.ts b/packages/core/src/reduce-execution.test.ts new file mode 100644 index 0000000..35494be --- /dev/null +++ b/packages/core/src/reduce-execution.test.ts @@ -0,0 +1,256 @@ +import type { AnyEvent, RunEvent, UnknownEvent } from '@runloop-world/events'; +import { CODING_MISSION, codingMissionFixture, runBuilder, type RunBuilder } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { SequenceGapError } from './errors'; +import { foldExecution, initialExecState, reduceExecution } from './reduce-execution'; + +/** run.started + one embodied agent "a1" + task "T1" (code). */ +function base(): RunBuilder { + const b = runBuilder(); + b.add('run.started', { + title: 't', + missionId: 'm', + worldId: 'research-campus', + seed: 1, + adapter: { id: 'x', kind: 'scripted', label: 'X' }, + workspace: 'workspaces/x', + }); + b.add('agent.registered', { agentId: 'a1', name: 'A1', role: 'build', model: null, tools: [], embodied: true }); + b.add('task.created', { taskId: 'T1', title: 'do', kind: 'code', dependsOn: [] }); + return b; +} + +function unknownEvent(seq: number, runId: string): UnknownEvent { + return { + eventId: `00000001-0000-4000-8000-${seq.toString(16).padStart(12, '0')}`, + runId, + sequence: seq, + timestamp: '2026-01-01T00:00:01.000Z', + type: 'world.teleported', + schemaVersion: 1, + visibility: 'public', + payload: {}, + unknown: true, + reason: 'unknown_type', + }; +} + +describe('reduceExecution — coding mission fixture', () => { + const events = codingMissionFixture(); + const s = foldExecution(events); + const M = CODING_MISSION; + + it('ends with the run succeeded and every task completed', () => { + expect(s.lastSequence).toBe(events.length); + expect(s.run.status).toBe('succeeded'); + expect(s.run.title).toBe(M.title); + expect(s.run.adapter?.label).toBe(M.adapter.label); + expect(s.runStartMs).toBe(Date.parse(events[0]!.timestamp)); + expect(s.taskOrder).toEqual(['T1', 'T2', 'T3', 'T4']); + for (const id of s.taskOrder) expect(s.tasks[id]!.status).toBe('completed'); + expect(s.anomalies).toEqual([]); + }); + + it('counts the test task retry as a second attempt', () => { + expect(s.tasks.T3!.attempts).toBe(2); + expect(s.tasks.T1!.attempts).toBe(1); + expect(s.tasks.T3!.callIds).toEqual(['call-6', 'call-9']); + }); + + it('records exactly one tool_error incident (the real failing test run), resolved when T3 completed', () => { + expect(s.incidents).toHaveLength(1); + const inc = s.incidents[0]!; + expect(inc).toMatchObject({ kind: 'tool_error', refId: 'call-6', agentId: 'tester', taskId: 'T3' }); + expect(inc.message).toBe('12 tests: 10 passed, 2 failed'); + const t3Done = events.find((e) => e.type === 'task.completed' && e.payload.taskId === 'T3')!; + expect(inc.resolvedSeq).toBe(t3Done.sequence); + expect(s.toolCalls['call-6']!.metrics).toEqual({ tests: 12, passed: 10, failed: 2 }); + expect(s.toolCalls['call-6']!.status).toBe('error'); + expect(s.toolCalls['call-9']!.status).toBe('success'); + }); + + it('leaves agents without current work and remembers their last outcome', () => { + expect(s.agentOrder).toEqual(['coordinator', 'researcher', 'builder', 'tester']); + expect(s.agents.coordinator!.embodied).toBe(false); + for (const id of s.agentOrder) { + expect(s.agents[id]!.currentTaskId).toBeNull(); + expect(s.agents[id]!.activeCallId).toBeNull(); + } + expect(s.agents.tester!.lastTaskOutcome).toMatchObject({ taskId: 'T3', status: 'completed' }); + expect(s.agents.builder!.lastTaskOutcome).toMatchObject({ taskId: 'T4', status: 'completed' }); + }); + + it('keeps artifacts, messages and creators', () => { + expect(s.artifactOrder).toEqual(['art-1', 'art-2', 'art-3', 'art-4']); + expect(s.artifacts['art-2']).toMatchObject({ agentId: 'tester', taskId: 'T3', producedByCallId: 'call-6' }); + expect(s.messages.map((m) => m.messageId)).toEqual(['msg-1', 'msg-2']); + expect(s.tasks.T4!.createdBy).toBe('coordinator'); + }); + + it('marks the test task blocked (owner keeps it) while the fix is pending', () => { + const blocked = events.find((e) => e.type === 'task.blocked')!; + const mid = foldExecution(events.slice(0, blocked.sequence)); + expect(mid.tasks.T3!.status).toBe('blocked'); + expect(mid.tasks.T3!.blockedBy).toEqual(['T4']); + expect(mid.agents.tester!.currentTaskId).toBe('T3'); + const waiting = events.find((e) => e.type === 'agent.state_changed' && e.payload.state === 'waiting')!; + expect(foldExecution(events.slice(0, waiting.sequence)).agents.tester).toMatchObject({ + state: 'waiting', + stateReason: 'waiting for a fix', + currentTaskId: 'T3', + }); + }); +}); + +describe('reduceExecution — ordering rules', () => { + const events = codingMissionFixture(); + + it('returns the same reference for an already-folded sequence', () => { + const s = foldExecution(events.slice(0, 10)); + expect(reduceExecution(s, events[9]!)).toBe(s); + expect(reduceExecution(s, events[0]!)).toBe(s); + }); + + it('throws SequenceGapError on a gap', () => { + const s = foldExecution(events.slice(0, 3)); + let err: unknown; + try { + reduceExecution(s, events[4]!); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(SequenceGapError); + expect(err).toMatchObject({ expected: 4, got: 5 }); + }); + + it('is pure and structurally shared', () => { + const s = foldExecution(events.slice(0, 20)); + const snapshot = JSON.stringify(s); + const next = reduceExecution(s, events[20]!); // task.assigned T2 → builder + expect(JSON.stringify(s)).toBe(snapshot); + expect(next).not.toBe(s); + expect(next.agents.researcher).toBe(s.agents.researcher); + expect(next.toolCalls).toBe(s.toolCalls); + expect(next.tasks.T1).toBe(s.tasks.T1); + expect(next.tasks.T2).not.toBe(s.tasks.T2); + }); + + it('records unknown events as anomalies and still advances', () => { + const s = foldExecution(events.slice(0, 2)); + const next = reduceExecution(s, unknownEvent(3, s.runId!)); + expect(next.lastSequence).toBe(3); + expect(next.anomalies).toEqual([{ seq: 3, code: 'unknown_event', detail: 'world.teleported: unknown_type' }]); + // …and the next real event folds normally after it. + const shifted = { ...events[2]!, sequence: 4 } as AnyEvent; + expect(reduceExecution(next, shifted).lastSequence).toBe(4); + }); +}); + +describe('reduceExecution — semantics', () => { + it('turns contradictions into anomalies instead of throwing', () => { + const b = base(); + b.add('tool.call_completed', { callId: 'nope', status: 'success', durationMs: 1, outputSummary: '' }); + b.add('task.assigned', { taskId: 'T9', agentId: 'a1' }); + b.add('agent.registered', { agentId: 'a1', name: 'A1', role: 'build', model: null, tools: [], embodied: true }); + b.add('task.created', { taskId: 'T2', title: 'x', kind: 'test', dependsOn: ['T7'] }); + b.add('task.created', { taskId: 'T3', title: 'loop', kind: 'test', dependsOn: ['T3'] }); + b.add('agent.state_changed', { agentId: 'ghost', state: 'working' }); + b.add('tool.permission_resolved', { permissionId: 'p0', decision: 'approved', by: 'user' }); + const s = foldExecution(b.events); + expect(s.anomalies.map((a) => a.code)).toEqual([ + 'unknown_call', + 'unknown_task', + 'duplicate_id', + 'unknown_dependency', + 'dependency_cycle', + 'unknown_agent', + 'unknown_permission', + ]); + expect(s.lastSequence).toBe(b.events.length); + }); + + it('increments attempts on every task.started and resets per-attempt steps', () => { + const b = base(); + b.add('task.assigned', { taskId: 'T1', agentId: 'a1' }); + b.add('task.started', { taskId: 'T1', agentId: 'a1' }); + b.add('tool.call_started', { callId: 'c1', agentId: 'a1', taskId: 'T1', tool: 'write_file', inputSummary: 'x' }); + b.add('tool.call_failed', { callId: 'c1', errorType: 'timeout', message: 'took too long', durationMs: 30000 }); + b.add('task.failed', { taskId: 'T1', reason: 'tool timed out', errorType: 'timeout' }); + let s = foldExecution(b.events); + expect(s.tasks.T1).toMatchObject({ status: 'failed', attempts: 1, stepsDoneThisAttempt: 1 }); + expect(s.tasks.T1!.failure).toEqual({ reason: 'tool timed out', errorType: 'timeout' }); + expect(s.agents.a1!.currentTaskId).toBeNull(); + expect(s.agents.a1!.lastTaskOutcome).toMatchObject({ taskId: 'T1', status: 'failed' }); + expect(s.incidents.map((i) => i.kind)).toEqual(['tool_failed', 'task_failed']); + expect(s.incidents[0]!.message).toBe('timeout: took too long'); + + b.add('task.started', { taskId: 'T1', agentId: 'a1' }); + s = foldExecution(b.events); + expect(s.tasks.T1).toMatchObject({ status: 'in_progress', attempts: 2, stepsDoneThisAttempt: 0 }); + expect(s.agents.a1!.currentTaskId).toBe('T1'); + + b.add('task.completed', { taskId: 'T1', summary: 'ok', artifactIds: [] }); + s = foldExecution(b.events); + expect(s.incidents.every((i) => i.resolvedSeq === b.events.length)).toBe(true); + }); + + it('tracks tool calls, the active call and per-attempt steps', () => { + const b = base(); + b.add('task.started', { taskId: 'T1', agentId: 'a1' }); + b.add('tool.call_started', { callId: 'c1', agentId: 'a1', taskId: 'T1', tool: 'read_file', inputSummary: 'a.js' }); + let s = foldExecution(b.events); + expect(s.agents.a1!.activeCallId).toBe('c1'); + expect(s.toolCalls.c1!.status).toBe('running'); + b.add('tool.call_completed', { callId: 'c1', status: 'success', durationMs: 3, outputSummary: 'read 3 bytes' }); + b.add('tool.call_completed', { callId: 'c1', status: 'success', durationMs: 3, outputSummary: 'again' }); + s = foldExecution(b.events); + expect(s.agents.a1!.activeCallId).toBeNull(); + expect(s.toolCalls.c1).toMatchObject({ status: 'success', durationMs: 3, outputSummary: 'read 3 bytes' }); + expect(s.tasks.T1!.stepsDoneThisAttempt).toBe(1); + expect(s.anomalies.map((a) => a.code)).toEqual(['invalid_transition']); + }); + + it('handles pause / resume and a failed run', () => { + const b = base(); + b.add('run.paused', { reason: 'user' }); + expect(foldExecution(b.events).run.status).toBe('paused'); + b.add('run.resumed', {}); + expect(foldExecution(b.events).run.status).toBe('running'); + b.add('run.completed', { outcome: 'failed', summary: 'tests still failing' }); + const s = foldExecution(b.events); + expect(s.run).toMatchObject({ status: 'failed', summary: 'tests still failing' }); + expect(s.incidents).toMatchObject([{ kind: 'run_failed', refId: b.runId, message: 'tests still failing' }]); + b.add('run.resumed', {}); + expect(foldExecution(b.events).anomalies.map((a) => a.code)).toEqual(['invalid_transition']); + }); + + it('tracks permission requests on the agent', () => { + const b = base(); + b.add('tool.permission_requested', { permissionId: 'p1', agentId: 'a1', action: 'deploy', scope: 'prod' }); + let s = foldExecution(b.events); + expect(s.agents.a1!.pendingPermissionId).toBe('p1'); + expect(s.permissions.p1!.status).toBe('pending'); + b.add('tool.permission_resolved', { permissionId: 'p1', decision: 'denied', by: 'user' }); + s = foldExecution(b.events); + expect(s.agents.a1!.pendingPermissionId).toBeNull(); + expect(s.permissions.p1).toMatchObject({ status: 'denied', resolvedBy: 'user', resolvedSeq: b.events.length }); + }); + + it('moves a task to its new owner on reassignment', () => { + const b = base(); + b.add('agent.registered', { agentId: 'a2', name: 'A2', role: 'build', model: null, tools: [], embodied: true }); + b.add('task.assigned', { taskId: 'T1', agentId: 'a1' }); + b.add('task.assigned', { taskId: 'T1', agentId: 'a2' }); + const s = foldExecution(b.events); + expect(s.tasks.T1).toMatchObject({ ownerId: 'a2', status: 'assigned' }); + expect(s.agents.a1!.currentTaskId).toBeNull(); + expect(s.agents.a2!.currentTaskId).toBe('T1'); + }); + + it('folds from a given state', () => { + const events: RunEvent[] = codingMissionFixture(); + const half = foldExecution(events.slice(0, 30)); + expect(foldExecution(events.slice(30), half)).toEqual(foldExecution(events)); + expect(foldExecution([], initialExecState())).toEqual(initialExecState()); + }); +}); diff --git a/packages/core/src/reduce-execution.ts b/packages/core/src/reduce-execution.ts new file mode 100644 index 0000000..d320292 --- /dev/null +++ b/packages/core/src/reduce-execution.ts @@ -0,0 +1,517 @@ +import { isUnknownEvent, type AnyEvent, type EventOf, type EventType } from '@runloop-world/events'; +import { SequenceGapError } from './errors'; +import type { AgentExec, Anomaly, ExecState, Incident, TaskExec, ToolCallExec } from './types'; + +export function initialExecState(): ExecState { + return { + lastSequence: 0, + runId: null, + runStartMs: null, + run: { + status: 'pending', + title: '', + missionId: '', + worldId: '', + seed: 0, + adapter: null, + workspace: '', + startedAt: null, + endedAt: null, + summary: null, + }, + agents: {}, + agentOrder: [], + tasks: {}, + taskOrder: [], + toolCalls: {}, + callOrder: [], + permissions: {}, + artifacts: {}, + artifactOrder: [], + messages: [], + incidents: [], + anomalies: [], + }; +} + +/** + * Fold one event into the execution state. Pure, with structural sharing: + * untouched records keep their references. + * + * - `seq ≤ lastSequence` returns `s` itself (idempotent redelivery). + * - `seq ≠ lastSequence + 1` throws {@link SequenceGapError} (order upstream with SequenceBuffer). + * - Unknown/invalid events and contradictions become `anomalies`; the log is the truth, so this never throws for them. + */ +export function reduceExecution(s: ExecState, e: AnyEvent): ExecState { + if (e.sequence <= s.lastSequence) return s; + if (e.sequence !== s.lastSequence + 1) throw new SequenceGapError(s.lastSequence + 1, e.sequence); + + const base: ExecState = { + ...s, + lastSequence: e.sequence, + runId: s.runId ?? e.runId, + runStartMs: s.runStartMs ?? Date.parse(e.timestamp), + }; + if (isUnknownEvent(e)) { + return anomaly(base, e.sequence, 'unknown_event', `${e.type}: ${e.reason}${e.error ? ` (${e.error})` : ''}`); + } + const handler = handlers[e.type] as Handler; + return handler(base, e); +} + +export function foldExecution(events: readonly AnyEvent[], from: ExecState = initialExecState()): ExecState { + let s = from; + for (const e of events) s = reduceExecution(s, e); + return s; +} + +// ── helpers ──────────────────────────────────────────────────────────── + +type Handler = (s: ExecState, e: EventOf) => ExecState; + +function anomaly(s: ExecState, seq: number, code: Anomaly['code'], detail: string): ExecState { + return { ...s, anomalies: [...s.anomalies, { seq, code, detail }] }; +} + +function patchAgent(s: ExecState, id: string, patch: Partial): ExecState { + const a = s.agents[id]; + if (!a) return s; + return { ...s, agents: { ...s.agents, [id]: { ...a, ...patch } } }; +} + +function patchTask(s: ExecState, id: string, patch: Partial): ExecState { + const t = s.tasks[id]; + if (!t) return s; + return { ...s, tasks: { ...s.tasks, [id]: { ...t, ...patch } } }; +} + +function patchCall(s: ExecState, id: string, patch: Partial): ExecState { + const c = s.toolCalls[id]; + if (!c) return s; + return { ...s, toolCalls: { ...s.toolCalls, [id]: { ...c, ...patch } } }; +} + +function addIncident(s: ExecState, incident: Omit): ExecState { + return { ...s, incidents: [...s.incidents, { ...incident, resolvedSeq: null }] }; +} + +/** Clear `currentTaskId` on an agent if it points at `taskId`. */ +function releaseTask(s: ExecState, agentId: string | null, taskId: string): ExecState { + if (!agentId) return s; + const a = s.agents[agentId]; + if (!a || a.currentTaskId !== taskId) return s; + return patchAgent(s, agentId, { currentTaskId: null }); +} + +/** Make `agentId` the owner of `taskId`, detaching a previous owner. */ +function takeTask(s: ExecState, taskId: string, agentId: string): ExecState { + const t = s.tasks[taskId]!; + let next = s; + if (t.ownerId && t.ownerId !== agentId) next = releaseTask(next, t.ownerId, taskId); + if (next.agents[agentId]) next = patchAgent(next, agentId, { currentTaskId: taskId }); + return next; +} + +/** True when a dependency path leads from `taskId` back to itself. */ +function hasCycle(tasks: Record, taskId: string): boolean { + const seen = new Set(); + const stack = [...(tasks[taskId]?.dependsOn ?? [])]; + while (stack.length) { + const id = stack.pop()!; + if (id === taskId) return true; + if (seen.has(id)) continue; + seen.add(id); + stack.push(...(tasks[id]?.dependsOn ?? [])); + } + return false; +} + +const TERMINAL_RUN = new Set(['succeeded', 'failed', 'stopped', 'interrupted']); +const TERMINAL_TASK = new Set(['completed', 'failed']); + +// ── handlers (one per known event type) ──────────────────────────────── + +const handlers: { [T in EventType]: Handler } = { + 'run.started': (s, e) => { + if (s.run.status !== 'pending') return anomaly(s, e.sequence, 'invalid_transition', `run.started while ${s.run.status}`); + const p = e.payload; + return { + ...s, + run: { + ...s.run, + status: 'running', + title: p.title, + missionId: p.missionId, + worldId: p.worldId, + seed: p.seed, + adapter: p.adapter, + workspace: p.workspace, + startedAt: e.timestamp, + }, + }; + }, + + 'run.paused': (s, e) => { + if (s.run.status !== 'running') return anomaly(s, e.sequence, 'invalid_transition', `run.paused while ${s.run.status}`); + return { ...s, run: { ...s.run, status: 'paused' } }; + }, + + 'run.resumed': (s, e) => { + if (s.run.status !== 'paused') return anomaly(s, e.sequence, 'invalid_transition', `run.resumed while ${s.run.status}`); + return { ...s, run: { ...s.run, status: 'running' } }; + }, + + 'run.completed': (s, e) => { + if (TERMINAL_RUN.has(s.run.status)) { + return anomaly(s, e.sequence, 'invalid_transition', `run.completed while ${s.run.status}`); + } + const p = e.payload; + const next: ExecState = { + ...s, + run: { ...s.run, status: p.outcome, endedAt: e.timestamp, summary: p.summary ?? null }, + }; + if (p.outcome !== 'failed') return next; + return addIncident(next, { + seq: e.sequence, + eventId: e.eventId, + kind: 'run_failed', + refId: e.runId, + agentId: null, + taskId: null, + message: p.summary ?? 'Run failed', + }); + }, + + 'agent.registered': (s, e) => { + const p = e.payload; + if (s.agents[p.agentId]) return anomaly(s, e.sequence, 'duplicate_id', `agent ${p.agentId} registered twice`); + const agent: AgentExec = { + id: p.agentId, + name: p.name, + role: p.role, + model: p.model, + tools: p.tools, + embodied: p.embodied, + characterPreset: p.characterPreset ?? null, + state: 'idle', + stateReason: null, + currentTaskId: null, + activeCallId: null, + pendingPermissionId: null, + lastTaskOutcome: null, + registeredSeq: e.sequence, + }; + return { ...s, agents: { ...s.agents, [p.agentId]: agent }, agentOrder: [...s.agentOrder, p.agentId] }; + }, + + 'agent.state_changed': (s, e) => { + const p = e.payload; + if (!s.agents[p.agentId]) return anomaly(s, e.sequence, 'unknown_agent', p.agentId); + return patchAgent(s, p.agentId, { state: p.state, stateReason: p.reason ?? null }); + }, + + 'agent.message_sent': (s, e) => { + const p = e.payload; + const next: ExecState = { + ...s, + messages: [...s.messages, { ...p, seq: e.sequence, eventId: e.eventId, timestamp: e.timestamp }], + }; + return s.agents[p.fromAgentId] ? next : anomaly(next, e.sequence, 'unknown_agent', p.fromAgentId); + }, + + 'task.created': (s, e) => { + const p = e.payload; + if (s.tasks[p.taskId]) return anomaly(s, e.sequence, 'duplicate_id', `task ${p.taskId} created twice`); + const task: TaskExec = { + id: p.taskId, + title: p.title, + kind: p.kind, + dependsOn: p.dependsOn, + acceptance: p.acceptance ?? null, + plannedSteps: p.plannedSteps ?? null, + status: 'pending', + ownerId: null, + attempts: 0, + createdSeq: e.sequence, + createdBy: e.actorId ?? null, + startedSeq: null, + endedSeq: null, + blockedReason: null, + blockedBy: [], + failure: null, + summary: null, + artifactIds: [], + callIds: [], + stepsDoneThisAttempt: 0, + }; + let next: ExecState = { ...s, tasks: { ...s.tasks, [p.taskId]: task }, taskOrder: [...s.taskOrder, p.taskId] }; + for (const dep of p.dependsOn) { + if (!s.tasks[dep] && dep !== p.taskId) next = anomaly(next, e.sequence, 'unknown_dependency', `${p.taskId} → ${dep}`); + } + if (hasCycle(next.tasks, p.taskId)) next = anomaly(next, e.sequence, 'dependency_cycle', p.taskId); + return next; + }, + + 'task.assigned': (s, e) => { + const p = e.payload; + const t = s.tasks[p.taskId]; + if (!t) return anomaly(s, e.sequence, 'unknown_task', p.taskId); + if (t.status === 'completed') return anomaly(s, e.sequence, 'invalid_transition', `assign completed task ${p.taskId}`); + let next = takeTask(s, p.taskId, p.agentId); + next = patchTask(next, p.taskId, { status: 'assigned', ownerId: p.agentId, blockedReason: null, blockedBy: [] }); + return next.agents[p.agentId] ? next : anomaly(next, e.sequence, 'unknown_agent', p.agentId); + }, + + 'task.started': (s, e) => { + const p = e.payload; + const t = s.tasks[p.taskId]; + if (!t) return anomaly(s, e.sequence, 'unknown_task', p.taskId); + if (t.status === 'completed') return anomaly(s, e.sequence, 'invalid_transition', `start completed task ${p.taskId}`); + let next = takeTask(s, p.taskId, p.agentId); + next = patchTask(next, p.taskId, { + status: 'in_progress', + ownerId: p.agentId, + attempts: t.attempts + 1, + startedSeq: e.sequence, + endedSeq: null, + blockedReason: null, + blockedBy: [], + stepsDoneThisAttempt: 0, + }); + return next.agents[p.agentId] ? next : anomaly(next, e.sequence, 'unknown_agent', p.agentId); + }, + + 'task.blocked': (s, e) => { + const p = e.payload; + const t = s.tasks[p.taskId]; + if (!t) return anomaly(s, e.sequence, 'unknown_task', p.taskId); + if (TERMINAL_TASK.has(t.status)) return anomaly(s, e.sequence, 'invalid_transition', `block ${t.status} task ${p.taskId}`); + // The owner keeps currentTaskId: a blocked task is still theirs (the character waits). + return patchTask(s, p.taskId, { status: 'blocked', blockedReason: p.reason, blockedBy: p.blockedBy ?? [] }); + }, + + 'task.completed': (s, e) => { + const p = e.payload; + const t = s.tasks[p.taskId]; + if (!t) return anomaly(s, e.sequence, 'unknown_task', p.taskId); + if (t.status === 'completed') return anomaly(s, e.sequence, 'invalid_transition', `complete completed task ${p.taskId}`); + let next = patchTask(s, p.taskId, { + status: 'completed', + endedSeq: e.sequence, + summary: p.summary, + artifactIds: p.artifactIds, + }); + next = releaseTask(next, t.ownerId, p.taskId); + if (t.ownerId && next.agents[t.ownerId]) { + next = patchAgent(next, t.ownerId, { lastTaskOutcome: { taskId: p.taskId, status: 'completed', seq: e.sequence } }); + } + if (next.incidents.some((i) => i.taskId === p.taskId && i.resolvedSeq === null)) { + next = { + ...next, + incidents: next.incidents.map((i) => + i.taskId === p.taskId && i.resolvedSeq === null ? { ...i, resolvedSeq: e.sequence } : i, + ), + }; + } + return next; + }, + + 'task.failed': (s, e) => { + const p = e.payload; + const t = s.tasks[p.taskId]; + if (!t) return anomaly(s, e.sequence, 'unknown_task', p.taskId); + if (TERMINAL_TASK.has(t.status)) return anomaly(s, e.sequence, 'invalid_transition', `fail ${t.status} task ${p.taskId}`); + let next = patchTask(s, p.taskId, { + status: 'failed', + endedSeq: e.sequence, + failure: { reason: p.reason, errorType: p.errorType ?? null }, + }); + next = releaseTask(next, t.ownerId, p.taskId); + if (t.ownerId && next.agents[t.ownerId]) { + next = patchAgent(next, t.ownerId, { lastTaskOutcome: { taskId: p.taskId, status: 'failed', seq: e.sequence } }); + } + return addIncident(next, { + seq: e.sequence, + eventId: e.eventId, + kind: 'task_failed', + refId: p.taskId, + agentId: t.ownerId, + taskId: p.taskId, + message: p.reason, + }); + }, + + 'tool.call_started': (s, e) => { + const p = e.payload; + if (s.toolCalls[p.callId]) return anomaly(s, e.sequence, 'duplicate_id', `call ${p.callId} started twice`); + const call: ToolCallExec = { + id: p.callId, + agentId: p.agentId, + taskId: p.taskId ?? null, + tool: p.tool, + inputSummary: p.inputSummary, + status: 'running', + startedSeq: e.sequence, + startedAt: e.timestamp, + startedEventId: e.eventId, + endedSeq: null, + endedAt: null, + endedEventId: null, + durationMs: null, + exitCode: null, + outputSummary: null, + metrics: null, + failingTests: [], + outputArtifactIds: [], + errorType: null, + message: null, + }; + let next: ExecState = { ...s, toolCalls: { ...s.toolCalls, [p.callId]: call }, callOrder: [...s.callOrder, p.callId] }; + if (next.agents[p.agentId]) next = patchAgent(next, p.agentId, { activeCallId: p.callId }); + else next = anomaly(next, e.sequence, 'unknown_agent', p.agentId); + if (p.taskId) { + const t = next.tasks[p.taskId]; + if (t) next = patchTask(next, p.taskId, { callIds: [...t.callIds, p.callId] }); + else next = anomaly(next, e.sequence, 'unknown_task', p.taskId); + } + return next; + }, + + 'tool.call_completed': (s, e) => { + const p = e.payload; + const c = s.toolCalls[p.callId]; + if (!c) return anomaly(s, e.sequence, 'unknown_call', p.callId); + if (c.status !== 'running') return anomaly(s, e.sequence, 'invalid_transition', `call ${p.callId} already ${c.status}`); + let next = patchCall(s, p.callId, { + status: p.status, + endedSeq: e.sequence, + endedAt: e.timestamp, + endedEventId: e.eventId, + durationMs: p.durationMs, + exitCode: p.exitCode ?? null, + outputSummary: p.outputSummary, + metrics: p.metrics ?? null, + failingTests: p.failingTests ?? [], + outputArtifactIds: p.outputArtifactIds ?? [], + errorType: p.errorType ?? null, + }); + next = finishCall(next, c); + if (p.status !== 'error') return next; + return addIncident(next, { + seq: e.sequence, + eventId: e.eventId, + kind: 'tool_error', + refId: p.callId, + agentId: c.agentId, + taskId: c.taskId, + message: p.outputSummary, + }); + }, + + 'tool.call_failed': (s, e) => { + const p = e.payload; + const c = s.toolCalls[p.callId]; + if (!c) return anomaly(s, e.sequence, 'unknown_call', p.callId); + if (c.status !== 'running') return anomaly(s, e.sequence, 'invalid_transition', `call ${p.callId} already ${c.status}`); + let next = patchCall(s, p.callId, { + status: 'failed', + endedSeq: e.sequence, + endedAt: e.timestamp, + endedEventId: e.eventId, + durationMs: p.durationMs, + errorType: p.errorType, + message: p.message, + }); + next = finishCall(next, c); + return addIncident(next, { + seq: e.sequence, + eventId: e.eventId, + kind: 'tool_failed', + refId: p.callId, + agentId: c.agentId, + taskId: c.taskId, + message: `${p.errorType}: ${p.message}`, + }); + }, + + 'tool.permission_requested': (s, e) => { + const p = e.payload; + if (s.permissions[p.permissionId]) { + return anomaly(s, e.sequence, 'duplicate_id', `permission ${p.permissionId} requested twice`); + } + const next: ExecState = { + ...s, + permissions: { + ...s.permissions, + [p.permissionId]: { + id: p.permissionId, + agentId: p.agentId, + callId: p.callId ?? null, + action: p.action, + scope: p.scope, + status: 'pending', + requestedSeq: e.sequence, + resolvedSeq: null, + resolvedBy: null, + }, + }, + }; + if (!next.agents[p.agentId]) return anomaly(next, e.sequence, 'unknown_agent', p.agentId); + return patchAgent(next, p.agentId, { pendingPermissionId: p.permissionId }); + }, + + 'tool.permission_resolved': (s, e) => { + const p = e.payload; + const perm = s.permissions[p.permissionId]; + if (!perm) return anomaly(s, e.sequence, 'unknown_permission', p.permissionId); + if (perm.status !== 'pending') { + return anomaly(s, e.sequence, 'invalid_transition', `permission ${p.permissionId} already ${perm.status}`); + } + let next: ExecState = { + ...s, + permissions: { + ...s.permissions, + [p.permissionId]: { ...perm, status: p.decision, resolvedSeq: e.sequence, resolvedBy: p.by }, + }, + }; + if (next.agents[perm.agentId]?.pendingPermissionId === p.permissionId) { + next = patchAgent(next, perm.agentId, { pendingPermissionId: null }); + } + return next; + }, + + 'artifact.created': (s, e) => { + const p = e.payload; + if (s.artifacts[p.artifactId]) return anomaly(s, e.sequence, 'duplicate_id', `artifact ${p.artifactId} created twice`); + const call = p.producedByCallId ? s.toolCalls[p.producedByCallId] : undefined; + const next: ExecState = { + ...s, + artifacts: { + ...s.artifacts, + [p.artifactId]: { + ...p, + seq: e.sequence, + eventId: e.eventId, + agentId: e.actorId ?? call?.agentId ?? null, + taskId: e.taskId ?? call?.taskId ?? null, + createdAt: e.timestamp, + }, + }, + artifactOrder: [...s.artifactOrder, p.artifactId], + }; + if (p.producedByCallId && !call) return anomaly(next, e.sequence, 'unknown_call', p.producedByCallId); + return next; + }, +}; + +/** Bookkeeping shared by tool.call_completed / tool.call_failed. */ +function finishCall(s: ExecState, call: ToolCallExec): ExecState { + let next = s; + if (next.agents[call.agentId]?.activeCallId === call.id) next = patchAgent(next, call.agentId, { activeCallId: null }); + const t = call.taskId ? next.tasks[call.taskId] : undefined; + // Count the step only if the call belongs to the task's current attempt. + if (t && t.startedSeq !== null && call.startedSeq >= t.startedSeq) { + next = patchTask(next, t.id, { stepsDoneThisAttempt: t.stepsDoneThisAttempt + 1 }); + } + return next; +} diff --git a/packages/core/src/selectors.test.ts b/packages/core/src/selectors.test.ts new file mode 100644 index 0000000..8d0d48b --- /dev/null +++ b/packages/core/src/selectors.test.ts @@ -0,0 +1,80 @@ +import type { RunEvent } from '@runloop-world/events'; +import { codingMissionFixture } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { foldExecution } from './reduce-execution'; +import { + selectAgentFlow, + selectEmbodiedAgents, + selectOpenIncidents, + selectReadyTasks, + selectRecentActions, + selectTaskProgress, +} from './selectors'; + +const events = codingMissionFixture(); +/** State right after the first event matching `pred`. */ +function after(pred: (e: RunEvent) => boolean) { + const e = events.find(pred); + if (!e) throw new Error('no such event'); + return foldExecution(events.slice(0, e.sequence)); +} +const completed = (taskId: string) => (e: RunEvent) => e.type === 'task.completed' && e.payload.taskId === taskId; + +describe('selectors', () => { + it('selectReadyTasks respects dependencies', () => { + const created = after((e) => e.type === 'task.created' && e.payload.taskId === 'T3'); + expect(selectReadyTasks(created).map((t) => t.id)).toEqual(['T1']); + expect(selectReadyTasks(after(completed('T1'))).map((t) => t.id)).toEqual(['T2']); + expect(selectReadyTasks(after(completed('T2'))).map((t) => t.id)).toEqual(['T3']); + expect(selectReadyTasks(foldExecution(events))).toEqual([]); + }); + + it('selectRecentActions lists an agent’s calls newest first', () => { + const s = foldExecution(events); + expect(selectRecentActions(s, 'builder').map((c) => c.id)).toEqual(['call-8', 'call-7', 'call-5', 'call-4']); + expect(selectRecentActions(s, 'builder', 2).map((c) => c.id)).toEqual(['call-8', 'call-7']); + expect(selectRecentActions(s, 'coordinator')).toEqual([]); + }); + + it('selectTaskProgress follows finished steps of the current attempt', () => { + const firstCall = after((e) => e.type === 'tool.call_completed' && e.payload.callId === 'call-1'); + expect(selectTaskProgress(firstCall, 'T1')).toBeCloseTo(1 / 3); + expect(selectTaskProgress(firstCall, 'T2')).toBe(0); + expect(selectTaskProgress(after(completed('T1')), 'T1')).toBe(1); + expect(selectTaskProgress(firstCall, 'missing')).toBeNull(); + + // T3's retry starts again from zero. + const retry = after((e) => e.type === 'task.started' && e.payload.taskId === 'T3' && e.sequence > 40); + expect(selectTaskProgress(retry, 'T3')).toBe(0); + }); + + it('selectTaskProgress is null without plannedSteps', () => { + const s = foldExecution(events.slice(0, 6)); + const t1 = s.tasks.T1!; + const noPlan = { ...s, tasks: { ...s.tasks, T1: { ...t1, plannedSteps: null } } }; + expect(selectTaskProgress(noPlan, 'T1')).toBeNull(); + }); + + it('selectAgentFlow has one chip per task kind with live status', () => { + const kinds = (s: ReturnType) => selectAgentFlow(s).map((c) => `${c.kind}:${c.status}`); + expect(kinds(after((e) => e.type === 'task.started'))).toEqual(['research:current', 'code:pending', 'test:pending']); + expect(kinds(after((e) => e.type === 'task.blocked'))).toEqual([ + 'research:done', + 'code:done', + 'test:current', + 'fix:pending', + ]); + expect(kinds(foldExecution(events))).toEqual(['research:done', 'code:done', 'test:done', 'fix:done']); + expect(selectAgentFlow(foldExecution(events))[2]!.taskIds).toEqual(['T3']); + }); + + it('selectEmbodiedAgents skips the coordinator', () => { + expect(selectEmbodiedAgents(foldExecution(events)).map((a) => a.id)).toEqual(['researcher', 'builder', 'tester']); + }); + + it('selectOpenIncidents drops incidents once their task completes', () => { + const failing = after((e) => e.type === 'tool.call_completed' && e.payload.status === 'error'); + expect(selectOpenIncidents(failing).map((i) => i.refId)).toEqual(['call-6']); + expect(selectOpenIncidents(foldExecution(events))).toEqual([]); + }); +}); diff --git a/packages/core/src/selectors.ts b/packages/core/src/selectors.ts new file mode 100644 index 0000000..d0ab213 --- /dev/null +++ b/packages/core/src/selectors.ts @@ -0,0 +1,63 @@ +import type { TaskKind } from '@runloop-world/events'; +import type { AgentExec, ExecState, FlowChip, Incident, TaskExec, ToolCallExec } from './types'; + +/** Pending tasks whose dependencies have all completed (readiness is computed, never stored). */ +export function selectReadyTasks(s: ExecState): TaskExec[] { + return s.taskOrder + .map((id) => s.tasks[id]!) + .filter((t) => t.status === 'pending' && t.dependsOn.every((d) => s.tasks[d]?.status === 'completed')); +} + +/** The agent's most recent tool calls, newest first. */ +export function selectRecentActions(s: ExecState, agentId: string, n = 5): ToolCallExec[] { + const out: ToolCallExec[] = []; + for (let i = s.callOrder.length - 1; i >= 0 && out.length < n; i--) { + const c = s.toolCalls[s.callOrder[i]!]!; + if (c.agentId === agentId) out.push(c); + } + return out; +} + +/** + * Progress of a task in 0..1: finished tool calls of the current attempt over + * `plannedSteps`. Completed → 1. `null` = indeterminate (no plannedSteps, or unknown task). + */ +export function selectTaskProgress(s: ExecState, taskId: string): number | null { + const t = s.tasks[taskId]; + if (!t) return null; + if (t.status === 'completed') return 1; + if (t.plannedSteps === null) return null; + return Math.min(1, t.stepsDoneThisAttempt / t.plannedSteps); +} + +/** + * One chip per task kind, in first-seen order. A chip is `current` while any of + * its tasks is active (assigned / in progress / blocked), else `failed` if one + * failed, else `done` when all completed, else `pending`. + */ +export function selectAgentFlow(s: ExecState): FlowChip[] { + const byKind = new Map(); + for (const id of s.taskOrder) { + const t = s.tasks[id]!; + const list = byKind.get(t.kind); + if (list) list.push(t); + else byKind.set(t.kind, [t]); + } + return [...byKind].map(([kind, tasks]) => { + let status: FlowChip['status'] = 'pending'; + if (tasks.some((t) => t.status === 'assigned' || t.status === 'in_progress' || t.status === 'blocked')) status = 'current'; + else if (tasks.some((t) => t.status === 'failed')) status = 'failed'; + else if (tasks.every((t) => t.status === 'completed')) status = 'done'; + return { kind, status, taskIds: tasks.map((t) => t.id) }; + }); +} + +/** Agents that get a character in the world, in registration order. */ +export function selectEmbodiedAgents(s: ExecState): AgentExec[] { + return s.agentOrder.map((id) => s.agents[id]!).filter((a) => a.embodied); +} + +/** Incidents whose task has not (yet) completed. */ +export function selectOpenIncidents(s: ExecState): Incident[] { + return s.incidents.filter((i) => i.resolvedSeq === null); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7c12446..e71845f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -164,7 +164,7 @@ export interface ExecState { /** Last folded sequence (0 = nothing folded). */ lastSequence: number; runId: string | null; - /** Timestamp (ms since epoch) of run.started, or of the first event folded. */ + /** Timestamp (ms since epoch) of the first event folded (normally run.started). */ runStartMs: number | null; run: RunExec; agents: Record; From 59525bf3e79c6ea8a1413ce798edaaca5361e262 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:33:52 +0000 Subject: [PATCH 07/18] Add world engine (Layer 2) Compiles a world definition, plans deterministic grid paths, derives each character's intent from execution state, reserves station slots with overflow queues, and evaluates closed-form motion so live and replayed positions are identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/world-engine/src/compile.ts | 77 ++++ packages/world-engine/src/grid.test.ts | 64 ++++ packages/world-engine/src/grid.ts | 70 ++++ packages/world-engine/src/index.ts | 15 + packages/world-engine/src/intent.ts | 69 ++++ packages/world-engine/src/motion.test.ts | 131 +++++++ packages/world-engine/src/motion.ts | 112 ++++++ .../world-engine/src/presentation.test.ts | 83 +++++ packages/world-engine/src/presentation.ts | 61 +++ .../world-engine/src/reduce-world.test.ts | 350 ++++++++++++++++++ packages/world-engine/src/reduce-world.ts | 288 ++++++++++++++ packages/world-engine/src/test-utils.ts | 74 ++++ packages/world-engine/src/types.ts | 6 +- packages/world-engine/src/world.test.ts | 70 ++++ 14 files changed, 1469 insertions(+), 1 deletion(-) create mode 100644 packages/world-engine/src/compile.ts create mode 100644 packages/world-engine/src/grid.test.ts create mode 100644 packages/world-engine/src/grid.ts create mode 100644 packages/world-engine/src/intent.ts create mode 100644 packages/world-engine/src/motion.test.ts create mode 100644 packages/world-engine/src/motion.ts create mode 100644 packages/world-engine/src/presentation.test.ts create mode 100644 packages/world-engine/src/presentation.ts create mode 100644 packages/world-engine/src/reduce-world.test.ts create mode 100644 packages/world-engine/src/reduce-world.ts create mode 100644 packages/world-engine/src/test-utils.ts create mode 100644 packages/world-engine/src/world.test.ts diff --git a/packages/world-engine/src/compile.ts b/packages/world-engine/src/compile.ts new file mode 100644 index 0000000..ece52f0 --- /dev/null +++ b/packages/world-engine/src/compile.ts @@ -0,0 +1,77 @@ +import { parseWorldDefinition, type Cell, type CharacterPreset, type Station, type StationKind, type WorldDefinition, type Zone } from './definition'; +import type { CompiledWorld } from './types'; + +const key = (x: number, y: number) => `${x},${y}`; + +/** + * Build lookup tables for a world definition. Throws on structural errors + * (ragged grid, dangling ids, slots or homes that are not walkable). + */ +export function compileWorld(def: WorldDefinition): CompiledWorld { + const height = def.grid.length; + const width = def.grid[0]!.length; + const problems: string[] = []; + def.grid.forEach((row, y) => { + if (row.length !== width) problems.push(`grid row ${y} has length ${row.length}, expected ${width}`); + }); + + const blocked = new Set(); + def.grid.forEach((row, y) => { + for (let x = 0; x < row.length; x++) if (row[x] === '#') blocked.add(key(x, y)); + }); + for (const s of def.stations) blocked.add(key(...s.cell)); + for (const p of def.props) blocked.add(key(...p.cell)); + for (const c of def.hub.core) blocked.add(key(...c)); + + const inBounds = (x: number, y: number) => x >= 0 && y >= 0 && x < width && y < height; + const isWalkable = (x: number, y: number) => + Number.isInteger(x) && Number.isInteger(y) && inBounds(x, y) && !blocked.has(key(x, y)); + + const zonesById: Record = {}; + for (const z of def.zones) { + if (zonesById[z.id]) problems.push(`duplicate zone id ${z.id}`); + zonesById[z.id] = z; + } + + const stationsById: Record = {}; + const stationsByKind: Partial> = {}; + for (const s of def.stations) { + if (stationsById[s.id]) problems.push(`duplicate station id ${s.id}`); + stationsById[s.id] = s; + (stationsByKind[s.kind] ??= []).push(s); + if (!zonesById[s.zoneId]) problems.push(`station ${s.id}: unknown zone ${s.zoneId}`); + if (!inBounds(...s.cell)) problems.push(`station ${s.id}: cell out of bounds`); + s.slots.forEach((c, i) => { + if (!isWalkable(...c)) problems.push(`station ${s.id}: slot ${i} [${c}] is not walkable`); + }); + if (!isWalkable(...s.overflow)) problems.push(`station ${s.id}: overflow [${s.overflow}] is not walkable`); + } + for (const list of Object.values(stationsByKind)) list.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + const presetsById: Record = {}; + for (const p of def.characterPresets) { + if (presetsById[p.id]) problems.push(`duplicate preset id ${p.id}`); + presetsById[p.id] = p; + if (!isWalkable(...p.home)) problems.push(`preset ${p.id}: home [${p.home}] is not walkable`); + } + if (!presetsById[def.defaultPreset]) problems.push(`defaultPreset ${def.defaultPreset} is not a preset`); + + if (problems.length) throw new Error(`Invalid world "${def.id}": ${problems.join('; ')}`); + return { def, width, height, isWalkable, zonesById, stationsById, stationsByKind, presetsById }; +} + +/** Parse (zod) and compile a world definition from JSON. */ +export function loadWorld(raw: unknown): CompiledWorld { + return compileWorld(parseWorldDefinition(raw)); +} + +/** Cells a character may be routed to: every slot, every overflow cell and every preset home. */ +export function destinationCells(world: CompiledWorld): { label: string; cell: Cell }[] { + const out: { label: string; cell: Cell }[] = []; + for (const s of world.def.stations) { + s.slots.forEach((cell, i) => out.push({ label: `${s.id} slot ${i}`, cell })); + out.push({ label: `${s.id} overflow`, cell: s.overflow }); + } + for (const p of world.def.characterPresets) out.push({ label: `${p.id} home`, cell: p.home }); + return out; +} diff --git a/packages/world-engine/src/grid.test.ts b/packages/world-engine/src/grid.test.ts new file mode 100644 index 0000000..f1ad591 --- /dev/null +++ b/packages/world-engine/src/grid.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { compileWorld } from './compile'; +import type { Cell, WorldDefinition } from './definition'; +import { bfs } from './grid'; +import { campus } from './test-utils'; + +/** A minimal world around an ASCII grid. */ +function tiny(grid: string[]) { + const def: WorldDefinition = { + id: 'tiny', + version: 1, + name: 'Tiny', + tickHz: 10, + ticksPerCell: 3, + grid, + zones: [{ id: 'z', label: 'Z', kind: 'build', rect: [0, 0, 1, 1], color: '#ffffff' }], + hub: { center: [0, 0], core: [] }, + stations: [{ id: 's', zoneId: 'z', kind: 'build', label: 'S', cell: [0, 0], facing: 's', slots: [[0, 1]], overflow: [1, 1] }], + props: [], + activityRules: [], + characterPresets: [{ id: 'p', label: 'P', color: '#ffffff', icon: 'x', home: [1, 1] }], + defaultPreset: 'p', + }; + return compileWorld(def); +} + +const adjacent = (a: Cell, b: Cell) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1; + +describe('bfs', () => { + it('finds an inclusive shortest path with N,E,S,W tie-breaking', () => { + const w = tiny(['....', '....', '....']); + expect(bfs(w, [1, 2], [2, 1])).toEqual([ + [1, 2], + [1, 1], + [2, 1], + ]); + expect(bfs(w, [3, 2], [3, 2])).toEqual([[3, 2]]); + }); + + it('routes around walls and blocked footprints', () => { + const w = tiny(['....', '....', '.##.', '....']); + const path = bfs(w, [1, 3], [2, 1])!; + expect(path.length).toBe(6); // 5 steps instead of 2: the wall forces a detour + for (let i = 1; i < path.length; i++) expect(adjacent(path[i - 1]!, path[i]!)).toBe(true); + expect(path.every(([x, y]) => w.isWalkable(x, y))).toBe(true); + expect(bfs(w, [1, 3], [0, 0])).toBeNull(); // station footprint is blocked + }); + + it('returns null when the target is unreachable', () => { + const w = tiny(['..#.', '..#.', '..#.']); + expect(bfs(w, [1, 1], [3, 1])).toBeNull(); + expect(bfs(w, [1, 1], [9, 9])).toBeNull(); + }); + + it('is deterministic and memoized per world', () => { + const a = campus(); + const b = campus(); + const p1 = bfs(a, [10, 8], [3, 4]); + expect(bfs(a, [10, 8], [3, 4])).toBe(p1); // memo hit + expect(bfs(b, [10, 8], [3, 4])).toEqual(p1); // fresh world, same answer + expect(Object.isFrozen(p1)).toBe(true); + expect(p1!.length - 1).toBe(11); // |dx| + |dy| on open floor + }); +}); diff --git a/packages/world-engine/src/grid.ts b/packages/world-engine/src/grid.ts new file mode 100644 index 0000000..23300a1 --- /dev/null +++ b/packages/world-engine/src/grid.ts @@ -0,0 +1,70 @@ +import type { Cell } from './definition'; +import type { CompiledWorld } from './types'; + +/** Fixed neighbour order N, E, S, W keeps paths deterministic. */ +const DIRS: readonly Cell[] = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], +]; + +const cache = new WeakMap>(); + +/** + * Shortest 4-connected path from `from` to `to`, both inclusive, or null when + * unreachable. `to` must be walkable; `from` may be any in-bounds cell (the + * character's current cell). Results are memoized per world and frozen — + * copy before mutating. + */ +export function bfs(world: CompiledWorld, from: Cell, to: Cell): Cell[] | null { + let memo = cache.get(world); + if (!memo) { + memo = new Map(); + cache.set(world, memo); + } + const k = `${from[0]},${from[1]}>${to[0]},${to[1]}`; + if (memo.has(k)) return memo.get(k) as Cell[] | null; + const path = search(world, from, to); + const frozen = path ? Object.freeze(path.map((c) => Object.freeze(c) as Cell)) : null; + memo.set(k, frozen); + return frozen as Cell[] | null; +} + +function search(world: CompiledWorld, from: Cell, to: Cell): Cell[] | null { + const { width, height } = world; + const inBounds = ([x, y]: Cell) => x >= 0 && y >= 0 && x < width && y < height; + if (!inBounds(from) || !world.isWalkable(to[0], to[1])) return null; + if (from[0] === to[0] && from[1] === to[1]) return [[from[0], from[1]]]; + + const idx = (x: number, y: number) => y * width + x; + const prev = new Int32Array(width * height).fill(-1); + const start = idx(from[0], from[1]); + prev[start] = start; + const queue: number[] = [start]; + for (let head = 0; head < queue.length; head++) { + const cur = queue[head]!; + const cx = cur % width; + const cy = (cur - cx) / width; + for (const [dx, dy] of DIRS) { + const nx = cx + dx; + const ny = cy + dy; + if (!world.isWalkable(nx, ny)) continue; + const n = idx(nx, ny); + if (prev[n] !== -1) continue; + prev[n] = cur; + if (nx === to[0] && ny === to[1]) return unwind(prev, n, start, width); + queue.push(n); + } + } + return null; +} + +function unwind(prev: Int32Array, end: number, start: number, width: number): Cell[] { + const out: Cell[] = []; + for (let n = end; ; n = prev[n]!) { + out.push([n % width, Math.floor(n / width)]); + if (n === start) break; + } + return out.reverse(); +} diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index 43ce043..1665196 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -1,2 +1,17 @@ export * from './definition'; export * from './types'; +export { compileWorld, loadWorld, destinationCells } from './compile'; +export { bfs } from './grid'; +export { + positionAt, + enteringCell, + arrivalTick, + msToTick, + eventTick, + haltMotion, + redirectMotion, + type MotionSample, +} from './motion'; +export { deriveIntent, matchActivityRule, type Intent } from './intent'; +export { initialWorldState, reduceWorld } from './reduce-world'; +export { presentationAt, gridToScene, stationWorkCell, SUCCESS_DECAY_TICKS } from './presentation'; diff --git a/packages/world-engine/src/intent.ts b/packages/world-engine/src/intent.ts new file mode 100644 index 0000000..6ee27ff --- /dev/null +++ b/packages/world-engine/src/intent.ts @@ -0,0 +1,69 @@ +import type { ExecState } from '@runloop-world/core'; +import type { TaskKind } from '@runloop-world/events'; +import type { ActivityRule, StationKind } from './definition'; +import type { Activity, CompiledWorld } from './types'; + +export interface Intent { + activity: Activity; + taskId: string | null; + /** Station kind to go to; null = home (or stay, for 'failed'). */ + stationKind: StationKind | null; +} + +const IDLE: Intent = { activity: 'idle', taskId: null, stationKind: null }; + +/** + * First matching activity rule. Rules with `permission: true` only apply to a + * pending permission request, and other rules only apply to work. + */ +export function matchActivityRule( + rules: readonly ActivityRule[], + ctx: { taskKind: TaskKind | null; permission: boolean }, +): StationKind | null { + for (const r of rules) { + if (Boolean(r.when.permission) !== ctx.permission) continue; + if (r.when.taskKind && (!ctx.taskKind || !r.when.taskKind.includes(ctx.taskKind))) continue; + return r.stationKind; + } + return null; +} + +/** + * What the world wants an agent's character to do, from Layer-1 state only. + * Priority: failed → awaiting_approval → waiting (blocked) → working → + * succeeded (last task completed, nothing current) → idle. + */ +export function deriveIntent(exec: ExecState, agentId: string, world: CompiledWorld): Intent { + const agent = exec.agents[agentId]; + if (!agent) return IDLE; + const task = agent.currentTaskId ? (exec.tasks[agent.currentTaskId] ?? null) : null; + const last = agent.lastTaskOutcome; + const rules = world.def.activityRules; + + const lastFailed = !task && last?.status === 'failed' && exec.tasks[last.taskId]?.status === 'failed'; + if (agent.state === 'failed' || task?.status === 'failed' || lastFailed) { + return { activity: 'failed', taskId: task?.id ?? last?.taskId ?? null, stationKind: null }; + } + + const perm = agent.pendingPermissionId ? exec.permissions[agent.pendingPermissionId] : undefined; + if (perm?.status === 'pending') { + return { + activity: 'awaiting_approval', + taskId: task?.id ?? null, + stationKind: matchActivityRule(rules, { taskKind: task?.kind ?? null, permission: true }), + }; + } + + if (task?.status === 'blocked') return { activity: 'waiting', taskId: task.id, stationKind: null }; + + if (task && (task.status === 'assigned' || task.status === 'in_progress')) { + return { + activity: 'working', + taskId: task.id, + stationKind: matchActivityRule(rules, { taskKind: task.kind, permission: false }), + }; + } + + if (!task && last?.status === 'completed') return { activity: 'succeeded', taskId: last.taskId, stationKind: null }; + return IDLE; +} diff --git a/packages/world-engine/src/motion.test.ts b/packages/world-engine/src/motion.test.ts new file mode 100644 index 0000000..11bcd14 --- /dev/null +++ b/packages/world-engine/src/motion.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import type { Cell } from './definition'; +import { arrivalTick, enteringCell, eventTick, haltMotion, msToTick, positionAt, redirectMotion } from './motion'; +import { campus } from './test-utils'; +import type { Motion } from './types'; + +const path: Cell[] = [ + [0, 0], + [1, 0], + [2, 0], + [2, 1], +]; +const m: Motion = { path, startTick: 10 }; +const TPC = 3; + +/** Same place (positions from different but equivalent motions may differ by float rounding). */ +function expectSamePlace(a: { x: number; y: number }, b: { x: number; y: number }) { + expect(a.x).toBeCloseTo(b.x, 9); + expect(a.y).toBeCloseTo(b.y, 9); +} + +describe('positionAt', () => { + it('is at the first cell at (and before) the start', () => { + expect(positionAt(m, 10, TPC)).toMatchObject({ x: 0, y: 0, cell: [0, 0], next: [1, 0], frac: 0, arrived: false }); + expect(positionAt(m, 2, TPC)).toMatchObject({ x: 0, y: 0, cell: [0, 0] }); + }); + + it('interpolates mid-step, including fractional ticks', () => { + expect(positionAt(m, 11, TPC)).toMatchObject({ cell: [0, 0], next: [1, 0], arrived: false }); + expect(positionAt(m, 11, TPC).x).toBeCloseTo(1 / 3); + expect(positionAt(m, 14.5, TPC).x).toBeCloseTo(1.5); + const turn = positionAt(m, 17.5, TPC); + expect(turn.cell).toEqual([2, 0]); + expect(turn.x).toBe(2); + expect(turn.y).toBeCloseTo(0.5); + }); + + it('is exactly on a cell at whole steps', () => { + expect(positionAt(m, 13, TPC)).toMatchObject({ x: 1, y: 0, cell: [1, 0], next: [2, 0], frac: 0 }); + }); + + it('arrives at the end and stays there', () => { + expect(arrivalTick(m, TPC)).toBe(19); + expect(positionAt(m, 19, TPC)).toMatchObject({ x: 2, y: 1, cell: [2, 1], next: [2, 1], arrived: true }); + expect(positionAt(m, 500, TPC)).toMatchObject({ x: 2, y: 1, arrived: true }); + expect(positionAt({ path: [[4, 4]], startTick: 0 }, 0, TPC)).toMatchObject({ x: 4, y: 4, arrived: true }); + }); + + it('moves smoothly: tiny tick deltas give tiny position deltas', () => { + let prev = positionAt(m, 10, TPC); + for (let t = 10; t <= 20; t += 0.05) { + const p = positionAt(m, t, TPC); + expect(Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y)).toBeLessThanOrEqual(0.05 / TPC + 1e-9); + prev = p; + } + }); +}); + +describe('enteringCell', () => { + it('is the next cell mid-step and the current cell on a cell', () => { + expect(enteringCell(m, 10, TPC)).toEqual([0, 0]); + expect(enteringCell(m, 11, TPC)).toEqual([1, 0]); + expect(enteringCell(m, 13, TPC)).toEqual([1, 0]); + expect(enteringCell(m, 17, TPC)).toEqual([2, 1]); + expect(enteringCell(m, 99, TPC)).toEqual([2, 1]); + }); +}); + +describe('ticks', () => { + it('msToTick is fractional', () => { + expect(msToTick(1234, 10)).toBeCloseTo(12.34); + expect(msToTick(0, 10)).toBe(0); + }); + + it('eventTick floors and never goes backwards', () => { + const start = 1_000_000; + expect(eventTick(0, start + 1290, start, 10)).toBe(12); + expect(eventTick(12, start + 900, start, 10)).toBe(12); // earlier timestamp → clamped + expect(eventTick(0, start - 500, start, 10)).toBe(0); + expect(eventTick(12, start + 5000, start, 10)).toBe(50); + }); +}); + +describe('haltMotion / redirectMotion', () => { + const world = campus(); + const walk: Motion = { + path: [ + [10, 8], + [9, 8], + [8, 8], + [7, 8], + ], + startTick: 0, + }; + + it('halt mid-step finishes the step into the entering cell', () => { + const h = haltMotion(walk, 4, TPC); // between [9,8] and [8,8] + expectSamePlace(positionAt(h, 4, TPC), positionAt(walk, 4, TPC)); + expect(positionAt(h, 100, TPC)).toMatchObject({ x: 8, y: 8, arrived: true }); + }); + + it('halt on a cell stops right there', () => { + const h = haltMotion(walk, 3, TPC); + expect(h.path).toEqual([[9, 8]]); + expect(haltMotion({ path: [[1, 1]], startTick: 0 }, 5, TPC).path).toEqual([[1, 1]]); + }); + + it('redirect keeps the position continuous and replans from the entering cell', () => { + const r = redirectMotion(world, walk, 4, [10, 8]); // turn back mid-step + expectSamePlace(positionAt(r, 4, TPC), positionAt(walk, 4, TPC)); + expect(r.path.slice(0, 3)).toEqual([ + [9, 8], + [8, 8], + [9, 8], + ]); + expect(positionAt(r, 1000, TPC)).toMatchObject({ x: 10, y: 8, arrived: true }); + }); + + it('redirect to the same destination keeps the motion', () => { + expect(redirectMotion(world, walk, 4, [7, 8])).toBe(walk); + }); + + it('redirect after arrival starts at the event tick', () => { + const r = redirectMotion(world, walk, 50, [7, 9]); + expect(r.startTick).toBe(50); + expect(r.path).toEqual([ + [7, 8], + [7, 9], + ]); + }); +}); diff --git a/packages/world-engine/src/motion.ts b/packages/world-engine/src/motion.ts new file mode 100644 index 0000000..cec37eb --- /dev/null +++ b/packages/world-engine/src/motion.ts @@ -0,0 +1,112 @@ +import type { Cell } from './definition'; +import { bfs } from './grid'; +import type { CompiledWorld, Motion } from './types'; + +export interface MotionSample { + /** Continuous grid position (cell centres are integers). */ + x: number; + y: number; + /** Cell the character is in (or leaving). */ + cell: Cell; + /** Cell being walked to next (equals `cell` once arrived). */ + next: Cell; + /** Progress from `cell` to `next` in [0, 1). */ + frac: number; + arrived: boolean; +} + +/** + * Closed-form position on a motion at `tick` (fractional ticks allowed, giving + * smooth continuous x/y). No tick loop: one cell per `ticksPerCell` ticks. + */ +export function positionAt(motion: Motion, tick: number, ticksPerCell: number): MotionSample { + const { path } = motion; + const last = path.length - 1; + const steps = Math.max(0, tick - motion.startTick) / ticksPerCell; + if (steps >= last) { + const end = path[last]!; + return { x: end[0], y: end[1], cell: end, next: end, frac: 0, arrived: true }; + } + const i = Math.floor(steps); + const frac = steps - i; + const cell = path[i]!; + const next = path[i + 1]!; + return { + x: cell[0] + (next[0] - cell[0]) * frac, + y: cell[1] + (next[1] - cell[1]) * frac, + cell, + next, + frac, + arrived: false, + }; +} + +/** The cell the character occupies or is currently stepping into at `tick`. */ +export function enteringCell(motion: Motion, tick: number, ticksPerCell: number): Cell { + const p = positionAt(motion, tick, ticksPerCell); + return p.frac > 0 ? p.next : p.cell; +} + +/** Tick at which the motion reaches its last cell. */ +export function arrivalTick(motion: Motion, ticksPerCell: number): number { + return motion.startTick + (motion.path.length - 1) * ticksPerCell; +} + +/** Milliseconds → fractional ticks. */ +export function msToTick(ms: number, tickHz: number): number { + return (ms * tickHz) / 1000; +} + +/** + * Integer tick of an event: floor of its time since run start, clamped so it + * never goes below the previous event's tick (timestamps may be non-monotonic; + * order always comes from `sequence`). + */ +export function eventTick(prevTick: number, eventMs: number, runStartMs: number, tickHz: number): number { + return Math.max(prevTick, 0, Math.floor(msToTick(eventMs - runStartMs, tickHz))); +} + +const sameCell = (a: Cell, b: Cell) => a[0] === b[0] && a[1] === b[1]; + +/** + * Where a walk is when interrupted at `tick`: the step index `i` of the cell + * being left and whether the character is mid-step (heading to path[i + 1]). + */ +function interruptionPoint(motion: Motion, tick: number, ticksPerCell: number) { + const last = motion.path.length - 1; + const steps = Math.max(0, tick - motion.startTick) / ticksPerCell; + if (steps >= last) return { i: last, midStep: false }; + const i = Math.floor(steps); + return { i, midStep: steps > i }; +} + +/** + * Stop at the cell being entered at `tick`: finish the current step, then + * stand still. Never teleports or reverses mid-step. + */ +export function haltMotion(motion: Motion, tick: number, ticksPerCell: number): Motion { + const { i, midStep } = interruptionPoint(motion, tick, ticksPerCell); + const { path } = motion; + if (midStep) return { path: [path[i]!, path[i + 1]!], startTick: motion.startTick + i * ticksPerCell }; + if (i === path.length - 1) return motion; // already standing at the end + return { path: [path[i]!], startTick: tick }; +} + +/** + * Re-plan towards `dest` from the cell being entered at `tick`. The current + * step is kept as a prefix so positionAt stays continuous across the switch. + * If the motion already ends at `dest` it is returned unchanged. + */ +export function redirectMotion(world: CompiledWorld, motion: Motion, tick: number, dest: Cell): Motion { + const tpc = world.def.ticksPerCell; + if (sameCell(motion.path[motion.path.length - 1]!, dest)) return motion; + const { i, midStep } = interruptionPoint(motion, tick, tpc); + const { path } = motion; + if (!midStep) { + const from = path[i]!; + return { path: bfs(world, from, dest) ?? [from], startTick: Math.max(tick, motion.startTick) }; + } + const entering = path[i + 1]!; + const rest = bfs(world, entering, dest) ?? [entering]; + return { path: [path[i]!, ...rest], startTick: motion.startTick + i * tpc }; +} diff --git a/packages/world-engine/src/presentation.test.ts b/packages/world-engine/src/presentation.test.ts new file mode 100644 index 0000000..01ed35d --- /dev/null +++ b/packages/world-engine/src/presentation.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { gridToScene, presentationAt, stationWorkCell, SUCCESS_DECAY_TICKS } from './presentation'; +import { campus } from './test-utils'; +import type { CharacterWorld } from './types'; + +const world = campus(); + +function character(patch: Partial): CharacterWorld { + return { + agentId: 'a', + presetId: 'builder', + home: [13, 8], + activity: 'idle', + taskId: null, + stationId: null, + target: { kind: 'home' }, + motion: { path: [[13, 8]], startTick: 0 }, + sinceTick: 0, + ...patch, + }; +} + +const walking = { path: [[13, 8], [14, 8], [15, 8]] as [number, number][], startTick: 10 }; + +describe('presentationAt', () => { + it('shows moving while walking and the activity once arrived', () => { + const c = character({ activity: 'working', motion: walking, target: { kind: 'slot', stationId: 'forge', index: 0 } }); + expect(presentationAt(c, 11, world)).toMatchObject({ state: 'moving', moving: true, cell: [14, 8], heading: 0 }); + expect(presentationAt(c, 11, world).x).toBeCloseTo(13 + 1 / 3); + expect(presentationAt(c, 16, world)).toMatchObject({ state: 'working', moving: false, x: 15, y: 8 }); + }); + + it('lets failed and awaiting_approval override moving', () => { + expect(presentationAt(character({ activity: 'failed', motion: walking }), 11, world)).toMatchObject({ + state: 'failed', + moving: true, + }); + expect(presentationAt(character({ activity: 'awaiting_approval', motion: walking }), 11, world).state).toBe( + 'awaiting_approval', + ); + }); + + it('shows paused when the run is paused', () => { + expect(presentationAt(character({ activity: 'working', motion: walking }), 11, world, { paused: true }).state).toBe( + 'paused', + ); + expect(presentationAt(character({ activity: 'failed' }), 11, world, { paused: true }).state).toBe('failed'); + }); + + it('shows waiting when queued at an overflow cell', () => { + const c = character({ activity: 'working', target: { kind: 'overflow', stationId: 'assembler' } }); + expect(presentationAt(c, 5, world).state).toBe('waiting'); + }); + + it('shows succeeded briefly, then idle (or moving while still walking home)', () => { + const c = character({ activity: 'succeeded', sinceTick: 10, motion: { ...walking, path: [...walking.path, [16, 8], [17, 8], [18, 8], [19, 8], [20, 8], [21, 8], [22, 8], [23, 8]] } }); + expect(presentationAt(c, 12, world)).toMatchObject({ state: 'succeeded', moving: true }); + expect(presentationAt(c, 10 + SUCCESS_DECAY_TICKS + 1, world).state).toBe('moving'); + expect(presentationAt(c, 100, world).state).toBe('idle'); + expect(presentationAt(character({ activity: 'succeeded', sinceTick: 0 }), 5, world).state).toBe('succeeded'); + }); + + it('faces the station when working and the hub when idle at home', () => { + const atForge = character({ activity: 'working', motion: { path: [[17, 4]], startTick: 0 }, target: { kind: 'slot', stationId: 'forge', index: 0 } }); + expect(presentationAt(atForge, 5, world).heading).toBeCloseTo(-Math.PI / 2); + const idle = presentationAt(character({}), 5, world).heading; + expect(idle).toBeCloseTo(Math.atan2(9.5 - 8, 11.5 - 13)); + }); +}); + +describe('gridToScene / stationWorkCell', () => { + it('centres the grid on the origin with 1 unit per cell', () => { + expect(gridToScene(0, 0, world)).toEqual({ x: -11.5, z: -9.5 }); + expect(gridToScene(23, 19, world)).toEqual({ x: 11.5, z: 9.5 }); + expect(gridToScene(11.5, 9.5, world)).toEqual({ x: 0, z: 0 }); + }); + + it('returns slot cells and falls back to the overflow cell', () => { + expect(stationWorkCell(world, 'archive', 0)).toEqual([3, 4]); + expect(stationWorkCell(world, 'archive', 3)).toEqual([4, 5]); + expect(() => stationWorkCell(world, 'nope', 0)).toThrow(); + }); +}); diff --git a/packages/world-engine/src/presentation.ts b/packages/world-engine/src/presentation.ts new file mode 100644 index 0000000..1c6c8bc --- /dev/null +++ b/packages/world-engine/src/presentation.ts @@ -0,0 +1,61 @@ +import type { Cell } from './definition'; +import { positionAt } from './motion'; +import type { CharacterPose, CharacterWorld, CompiledWorld, PresentationState } from './types'; + +/** How long (ticks) a 'succeeded' reaction shows before the character reads as idle. */ +export const SUCCESS_DECAY_TICKS = 20; + +const headingOf = (from: Cell | readonly [number, number], to: Cell | readonly [number, number]) => + Math.atan2(to[1] - from[1], to[0] - from[0]); + +/** + * Pose of a character at `tick` (fractional ticks give smooth motion). + * State priority: failed → awaiting_approval → paused → succeeded (briefly) + * → moving → waiting (queued at overflow) → the activity. + */ +export function presentationAt( + c: CharacterWorld, + tick: number, + world: CompiledWorld, + opts: { paused?: boolean } = {}, +): CharacterPose { + const p = positionAt(c.motion, tick, world.def.ticksPerCell); + const moving = !p.arrived; + const heading = moving ? headingOf(p.cell, p.next) : restingHeading(c, world); + + let state: PresentationState; + if (c.activity === 'failed') state = 'failed'; + else if (c.activity === 'awaiting_approval') state = 'awaiting_approval'; + else if (opts.paused) state = 'paused'; + else if (c.activity === 'succeeded' && tick - c.sinceTick <= SUCCESS_DECAY_TICKS) state = 'succeeded'; + else if (moving) state = 'moving'; + else if (c.target.kind === 'overflow') state = 'waiting'; + else if (c.activity === 'succeeded') state = 'idle'; + else state = c.activity; + + return { state, x: p.x, y: p.y, heading, moving, cell: p.frac > 0 ? p.next : p.cell }; +} + +/** Facing when standing still: the station when at one, else the last step's direction, else the hub. */ +function restingHeading(c: CharacterWorld, world: CompiledWorld): number { + const path = c.motion.path; + const here = path[path.length - 1]!; + if (c.target.kind === 'slot' || c.target.kind === 'overflow') { + const station = world.stationsById[c.target.stationId]; + if (station) return headingOf(here, station.cell); + } + if (path.length >= 2) return headingOf(path[path.length - 2]!, here); + return headingOf(here, world.def.hub.center); +} + +/** Grid coordinates → scene coordinates: the grid is centred on the origin, 1 cell = 1 unit, y → z. */ +export function gridToScene(x: number, y: number, world: CompiledWorld): { x: number; z: number } { + return { x: x - (world.width - 1) / 2, z: y - (world.height - 1) / 2 }; +} + +/** Cell where a character stands to work at a station slot (out-of-range index → overflow cell). */ +export function stationWorkCell(world: CompiledWorld, stationId: string, index: number): Cell { + const station = world.stationsById[stationId]; + if (!station) throw new Error(`Unknown station ${stationId}`); + return station.slots[index] ?? station.overflow; +} diff --git a/packages/world-engine/src/reduce-world.test.ts b/packages/world-engine/src/reduce-world.test.ts new file mode 100644 index 0000000..e823018 --- /dev/null +++ b/packages/world-engine/src/reduce-world.test.ts @@ -0,0 +1,350 @@ +import { SequenceGapError, reduceExecution } from '@runloop-world/core'; +import type { TaskKind } from '@runloop-world/events'; +import { codingMissionFixture, mulberry32 } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { loadWorld } from './compile'; +import type { Cell } from './definition'; +import { arrivalTick, enteringCell, positionAt } from './motion'; +import { presentationAt } from './presentation'; +import { reduceWorld } from './reduce-world'; +import { campus, campusJson, finalWorld, foldSteps, startRun, stateAtTick, type Step } from './test-utils'; +import type { CompiledWorld, WorldState } from './types'; + +const TPC = 3; +const world = campus(); +const pos = (w: WorldState, id: string, tick: number) => { + const p = presentationAt(w.characters[id]!, tick, world); + return [p.x, p.y]; +}; + +/** Every character's position, sampled every `dt` ticks, never jumps more than one step allows. */ +function expectContinuous(steps: readonly Step[], w: CompiledWorld, ids: string[], dt = 0.25) { + const end = Math.max(...steps.map((s) => s.world.tick)) + 60; + for (const id of ids) { + const first = steps.find((s) => s.world.characters[id])!.world.tick; + let prev: { x: number; y: number } | null = null; + for (let t = first; t <= end; t += dt) { + const c = stateAtTick(steps, t).characters[id]!; + const p = presentationAt(c, t, w); + if (prev) { + const d = Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y); + expect(d, `${id} jumped ${d} at tick ${t}`).toBeLessThanOrEqual(dt / w.def.ticksPerCell + 1e-9); + } + prev = p; + } + } +} + +/** Motion paths are chains of 4-adjacent walkable cells. */ +function expectValidPaths(steps: readonly Step[], w: CompiledWorld) { + for (const s of steps) { + for (const id of s.world.characterOrder) { + const path = s.world.characters[id]!.motion.path; + path.forEach((c, i) => { + expect(w.isWalkable(...c), `[${c}] walkable`).toBe(true); + if (i > 0) expect(Math.abs(c[0] - path[i - 1]![0]) + Math.abs(c[1] - path[i - 1]![1])).toBe(1); + }); + } + } +} + +describe('reduceWorld — characters', () => { + it('creates characters only for embodied agents, at their preset homes', () => { + const w = finalWorld(codingMissionFixture(), world); + expect(w.characterOrder).toEqual(['researcher', 'builder', 'tester']); + expect(w.characters.coordinator).toBeUndefined(); + expect(w.characters.researcher!.home).toEqual(world.presetsById.researcher!.home); + expect(w.characters.tester!.presetId).toBe('tester'); + }); + + it('uses the default preset and offsets shared homes deterministically', () => { + const b = startRun([ + { id: 'b1', preset: 'builder' }, + { id: 'b2', preset: 'builder' }, + { id: 'b3', preset: 'builder' }, + { id: 'x', preset: 'wizard' }, + { id: 'y' }, + ]); + const w = finalWorld(b.events, world); + const homes = w.characterOrder.map((id) => w.characters[id]!.home); + expect(homes).toEqual([ + [13, 8], + [13, 7], + [14, 8], + [13, 11], + [13, 10], + ]); + expect(w.characters.x!.presetId).toBe('operator'); + expect(w.characters.y!.presetId).toBe('operator'); + expect(finalWorld(b.events, world)).toEqual(w); + for (const h of homes) expect(world.isWalkable(...h)).toBe(true); + }); +}); + +describe('reduceWorld — ordering', () => { + const events = codingMissionFixture(); + const steps = foldSteps(events, world); + + it('ignores already-folded sequences (same reference) and throws on gaps', () => { + const s = steps[10]!; + expect(reduceWorld(s.world, s.exec, events[10]!, world)).toBe(s.world); + expect(reduceWorld(s.world, s.exec, events[2]!, world)).toBe(s.world); + expect(() => reduceWorld(s.world, s.exec, events[12]!, world)).toThrow(SequenceGapError); + }); + + it('derives ticks from timestamps with a monotonic clamp', () => { + const b = startRun([{ id: 'r', preset: 'researcher' }]); + b.wait(1000).add('agent.state_changed', { agentId: 'r', state: 'working' }); + b.wait(-700).add('agent.state_changed', { agentId: 'r', state: 'idle' }); // clock went backwards + b.wait(1200).add('agent.state_changed', { agentId: 'r', state: 'working' }); + expect(foldSteps(b.events, world).map((s) => s.world.tick)).toEqual([0, 1, 11, 11, 16]); + }); + + it('is deterministic: two folds give identical JSON', () => { + expect(JSON.stringify(foldSteps(events, world).at(-1)!.world)).toBe(JSON.stringify(steps.at(-1)!.world)); + }); +}); + +describe('reduceWorld — stations and motion', () => { + function assignResearch() { + const b = startRun([{ id: 'r', preset: 'researcher' }]); // ticks 0, 1 + b.wait(100).add('task.created', { taskId: 'T1', title: 't', kind: 'research', dependsOn: [] }); // 2 + b.wait(100).add('task.assigned', { taskId: 'T1', agentId: 'r' }); // 3 + return b; + } + + it('reserves a slot at event time, walks there, then works on arrival', () => { + const w = finalWorld(assignResearch().events, world); + const c = w.characters.r!; + expect(c).toMatchObject({ activity: 'working', taskId: 'T1', stationId: 'archive', sinceTick: 3 }); + expect(c.target).toEqual({ kind: 'slot', stationId: 'archive', index: 0 }); + expect(w.stations.archive!.slots).toEqual(['r']); + expect(c.motion.startTick).toBe(3); + const arrive = arrivalTick(c.motion, TPC); + expect(arrive).toBe(3 + 11 * TPC); + expect(presentationAt(c, 3, world).state).toBe('moving'); + expect(presentationAt(c, arrive - 0.5, world).state).toBe('moving'); + const there = presentationAt(c, arrive, world); + expect(there).toMatchObject({ state: 'working', x: 3, y: 4, moving: false }); + expect(there.heading).toBeCloseTo(-Math.PI / 2); // facing the terminal to the north + }); + + it('failure while walking stops at the entering cell and releases the slot', () => { + const b = assignResearch(); + b.wait(400).add('task.failed', { taskId: 'T1', reason: 'crashed' }); // tick 7: 4 ticks in → mid-step + const steps = foldSteps(b.events, world); + const before = steps.at(-2)!.world.characters.r!; + const after = steps.at(-1)!.world; + const c = after.characters.r!; + expect(after.tick).toBe(7); + expect(c).toMatchObject({ activity: 'failed', target: { kind: 'stay' }, stationId: null }); + expect(after.stations.archive!.slots).toEqual([null]); + + const entering = enteringCell(before.motion, 7, TPC); + expect(positionAt(before.motion, 7, TPC).frac).toBeGreaterThan(0); + expect(positionAt(c.motion, 1e6, TPC)).toMatchObject({ x: entering[0], y: entering[1], arrived: true }); + expect(pos(after, 'r', 7)).toEqual(pos(steps.at(-2)!.world, 'r', 7)); // no teleport + expect(presentationAt(c, 7.5, world)).toMatchObject({ state: 'failed', moving: true }); // failed overrides moving + expect(presentationAt(c, 100, world)).toMatchObject({ state: 'failed', moving: false }); + + // The freed slot is available to the next researcher. + b.wait(100).add('agent.registered', { + agentId: 'r2', + name: 'r2', + role: 'r', + model: null, + tools: [], + embodied: true, + characterPreset: 'researcher', + }); + b.wait(100).add('task.created', { taskId: 'T2', title: 't', kind: 'research', dependsOn: [] }); + b.wait(100).add('task.assigned', { taskId: 'T2', agentId: 'r2' }); + const w = finalWorld(b.events, world); + expect(w.characters.r2!.target).toEqual({ kind: 'slot', stationId: 'archive', index: 0 }); + expect(w.characters.r!.activity).toBe('failed'); // still frozen in place + }); + + it('queues at the overflow cell when every slot is taken and promotes on release', () => { + const b = startRun([ + { id: 'b1', preset: 'builder' }, + { id: 'b2', preset: 'builder' }, + { id: 'b3', preset: 'builder' }, + ]); + for (const t of ['T1', 'T2', 'T3']) b.wait(100).add('task.created', { taskId: t, title: t, kind: 'code', dependsOn: [] }); + b.wait(100).add('task.assigned', { taskId: 'T1', agentId: 'b1' }); + b.wait(100).add('task.assigned', { taskId: 'T2', agentId: 'b2' }); + b.wait(100).add('task.assigned', { taskId: 'T3', agentId: 'b3' }); + let w = finalWorld(b.events, world); + expect(w.characters.b1!.target).toEqual({ kind: 'slot', stationId: 'assembler', index: 0 }); + expect(w.characters.b2!.target).toEqual({ kind: 'slot', stationId: 'forge', index: 0 }); + expect(w.characters.b3!.target).toEqual({ kind: 'overflow', stationId: 'assembler' }); + expect(w.stations.assembler).toEqual({ slots: ['b1'], overflow: ['b3'] }); + const queued = w.characters.b3!; + expect(presentationAt(queued, arrivalTick(queued.motion, TPC), world).state).toBe('waiting'); + + b.wait(5000).add('task.completed', { taskId: 'T1', summary: 'ok', artifactIds: [] }); + const steps = foldSteps(b.events, world); + w = steps.at(-1)!.world; + expect(w.stations.assembler).toEqual({ slots: ['b3'], overflow: [] }); + expect(w.characters.b1).toMatchObject({ activity: 'succeeded', target: { kind: 'home' }, stationId: null }); + const promoted = w.characters.b3!; + expect(promoted).toMatchObject({ activity: 'working', stationId: 'assembler' }); + expect(promoted.target).toEqual({ kind: 'slot', stationId: 'assembler', index: 0 }); + expect(promoted.motion.path.at(-1)).toEqual([20, 4]); + expect(presentationAt(promoted, arrivalTick(promoted.motion, TPC), world).state).toBe('working'); + expectContinuous(steps, world, ['b1', 'b2', 'b3']); + }); + + it('mid-walk reassignments never move a character more than one cell per tick', () => { + const rand = mulberry32(1234); + const kinds: TaskKind[] = ['research', 'code', 'test', 'fix', 'deploy', 'approval']; + const b = startRun([ + { id: 'r', preset: 'researcher' }, + { id: 't', preset: 'tester' }, + ]); + for (let i = 1; i <= 40; i++) { + const agentId = rand() < 0.7 ? 'r' : 't'; + const kind = kinds[Math.floor(rand() * kinds.length)]!; + b.wait(100 + Math.floor(rand() * 1400)).add('task.created', { taskId: `T${i}`, title: '', kind, dependsOn: [] }); + b.wait(100).add('task.assigned', { taskId: `T${i}`, agentId }); + if (rand() < 0.2) b.wait(Math.floor(rand() * 500)).add('task.blocked', { taskId: `T${i}`, reason: 'wait' }); + } + const steps = foldSteps(b.events, world); + expect(steps.at(-1)!.exec.anomalies).toEqual([]); + // Several interruptions really happened mid-step. + let midStep = 0; + for (let i = 1; i < steps.length; i++) { + const prev = steps[i - 1]!.world.characters.r; + if (prev && steps[i]!.world.characters.r!.motion !== prev.motion && positionAt(prev.motion, steps[i]!.world.tick, TPC).frac > 0) midStep++; + } + expect(midStep).toBeGreaterThan(3); + expectContinuous(steps, world, ['r', 't']); + expectValidPaths(steps, world); + }); + + it('swapping activityRules changes the target station', () => { + const def = campusJson(); + def.activityRules = def.activityRules.map((r) => + r.when.taskKind?.includes('research') ? { ...r, stationKind: 'build' as const } : r, + ); + const swapped = loadWorld(def); + const events = assignResearch().events; + expect(finalWorld(events, world).characters.r!.stationId).toBe('archive'); + expect(finalWorld(events, swapped).characters.r!.stationId).toBe('assembler'); + expect(finalWorld(events, swapped).characters.r!.motion.path.at(-1)).toEqual([20, 4]); + }); +}); + +describe('reduceWorld — intents', () => { + it('blocked → waiting at home; permission → awaiting approval at the gate', () => { + const b = startRun([{ id: 't', preset: 'tester' }]); + b.wait(100).add('task.created', { taskId: 'T1', title: '', kind: 'test', dependsOn: [] }); + b.wait(100).add('task.started', { taskId: 'T1', agentId: 't' }); + let w = finalWorld(b.events, world); + expect(w.characters.t).toMatchObject({ activity: 'working', stationId: 'probe' }); + + b.wait(500).add('tool.permission_requested', { permissionId: 'p1', agentId: 't', action: 'deploy', scope: 'prod' }); + w = finalWorld(b.events, world); + expect(w.characters.t).toMatchObject({ activity: 'awaiting_approval', stationId: 'gate', taskId: 'T1' }); + expect(w.stations.probe!.slots).toEqual([null]); + expect(w.stations.gate!.slots).toEqual(['t']); + expect(presentationAt(w.characters.t!, w.tick + 1, world).state).toBe('awaiting_approval'); // even while walking + + b.wait(3000).add('tool.permission_resolved', { permissionId: 'p1', decision: 'approved', by: 'user' }); + w = finalWorld(b.events, world); + expect(w.characters.t).toMatchObject({ activity: 'working', stationId: 'probe' }); + + b.wait(3000).add('task.blocked', { taskId: 'T1', reason: 'needs fix' }); + const steps = foldSteps(b.events, world); + w = steps.at(-1)!.world; + const c = w.characters.t!; + expect(c).toMatchObject({ activity: 'waiting', target: { kind: 'home' }, stationId: null }); + expect(c.motion.path.at(-1)).toEqual(c.home); + expect(presentationAt(c, arrivalTick(c.motion, TPC), world).state).toBe('waiting'); + expectContinuous(steps, world, ['t']); + }); + + it('pause freezes intents and stops walkers at the entering cell; resume carries on', () => { + const b = startRun([{ id: 'r', preset: 'researcher' }]); + b.wait(100).add('task.created', { taskId: 'T1', title: '', kind: 'research', dependsOn: [] }); + b.wait(100).add('task.assigned', { taskId: 'T1', agentId: 'r' }); // tick 3 + b.wait(400).add('run.paused', {}); // tick 7, mid-step + b.wait(1000).add('task.completed', { taskId: 'T1', summary: '', artifactIds: [] }); // tick 17 + let steps = foldSteps(b.events, world); + const walking = steps.at(-3)!.world.characters.r!; + let w = steps.at(-1)!.world; + expect(w.paused).toBe(true); + const c = w.characters.r!; + expect(c.activity).toBe('working'); // frozen: the completion is not applied while paused + expect(positionAt(c.motion, 1e6, TPC)).toMatchObject({ arrived: true }); + expect(c.motion.path.at(-1)).toEqual(enteringCell(walking.motion, 7, TPC)); + expect(presentationAt(c, 17, world, { paused: true }).state).toBe('paused'); + + b.wait(1000).add('run.resumed', {}); // tick 27 + steps = foldSteps(b.events, world); + w = steps.at(-1)!.world; + expect(w.paused).toBe(false); + expect(w.characters.r).toMatchObject({ activity: 'succeeded', target: { kind: 'home' } }); + expect(w.characters.r!.motion.path.at(-1)).toEqual(w.characters.r!.home); + expect(w.stations.archive!.slots).toEqual([null]); + expectContinuous(steps, world, ['r']); + }); + + it('a pause without pending changes resumes the walk to the same slot', () => { + const b = startRun([{ id: 'r', preset: 'researcher' }]); + b.wait(100).add('task.created', { taskId: 'T1', title: '', kind: 'research', dependsOn: [] }); + b.wait(100).add('task.assigned', { taskId: 'T1', agentId: 'r' }); + b.wait(400).add('run.paused', {}); + b.wait(2000).add('run.resumed', {}); + const steps = foldSteps(b.events, world); + const c = steps.at(-1)!.world.characters.r!; + expect(c.motion.path.at(-1)).toEqual([3, 4] satisfies Cell); + expect(presentationAt(c, arrivalTick(c.motion, TPC), world).state).toBe('working'); + expectContinuous(steps, world, ['r']); + }); +}); + +describe('reduceWorld — coding mission', () => { + const events = codingMissionFixture(); + const steps = foldSteps(events, world); + const w = steps.at(-1)!.world; + + it('sends each agent to the station for its task kind', () => { + const stationOf = (agentId: string, taskId: string) => + steps.find((s) => s.world.characters[agentId]?.taskId === taskId && s.world.characters[agentId]!.activity === 'working') + ?.world.characters[agentId]!.stationId; + expect(stationOf('researcher', 'T1')).toBe('archive'); + expect(stationOf('builder', 'T2')).toBe('assembler'); + expect(stationOf('tester', 'T3')).toBe('probe'); + expect(stationOf('builder', 'T4')).toBe('assembler'); + const blocked = steps.find((s) => s.event.type === 'task.blocked')!.world.characters.tester!; + expect(blocked).toMatchObject({ activity: 'waiting', target: { kind: 'home' } }); + }); + + it('ends with every slot free and everyone heading home', () => { + for (const st of Object.values(w.stations)) { + expect(st.slots.every((s) => s === null)).toBe(true); + expect(st.overflow).toEqual([]); + } + for (const id of w.characterOrder) { + const c = w.characters[id]!; + expect(c.activity).toBe('succeeded'); + expect(c.motion.path.at(-1)).toEqual(c.home); + } + }); + + it('moves continuously along walkable paths', () => { + expectContinuous(steps, world, w.characterOrder, 0.5); + expectValidPaths(steps, world); + }); + + it('matches a fold that starts from a mid-run snapshot', () => { + let exec = steps[29]!.exec; + let ws = steps[29]!.world; + for (const e of events.slice(30)) { + exec = reduceExecution(exec, e); + ws = reduceWorld(ws, exec, e, world); + } + expect(ws).toEqual(w); + }); +}); diff --git a/packages/world-engine/src/reduce-world.ts b/packages/world-engine/src/reduce-world.ts new file mode 100644 index 0000000..84034fc --- /dev/null +++ b/packages/world-engine/src/reduce-world.ts @@ -0,0 +1,288 @@ +import { SequenceGapError, type ExecState } from '@runloop-world/core'; +import type { AnyEvent } from '@runloop-world/events'; +import type { Cell, CharacterPreset } from './definition'; +import { deriveIntent, type Intent } from './intent'; +import { eventTick, haltMotion, redirectMotion } from './motion'; +import type { CharacterWorld, CompiledWorld, StationWorld, Target, WorldState } from './types'; + +export function initialWorldState(): WorldState { + return { + lastSequence: 0, + tick: 0, + runStartMs: null, + paused: false, + characters: {}, + characterOrder: [], + stations: {}, + }; +} + +/** + * Fold one event into the world simulation. Pure. `exec` is the Layer-1 state + * AFTER folding `e`; Layer 2 never reads tool output, only that state. + * Same idempotency / gap rules as reduceExecution. + * + * Each event: create characters for newly registered embodied agents, then + * re-derive every character's intent. Changed intents release their station + * place, queued characters are promoted into freed slots, new places are + * reserved (logically, at event time) and characters re-route from the cell + * they are entering. While the run is paused intents freeze and walkers stop + * at the cell they are entering; resuming re-derives and re-routes everyone. + */ +export function reduceWorld(w: WorldState, exec: ExecState, e: AnyEvent, world: CompiledWorld): WorldState { + if (e.sequence <= w.lastSequence) return w; + if (e.sequence !== w.lastSequence + 1) throw new SequenceGapError(w.lastSequence + 1, e.sequence); + + const ms = Date.parse(e.timestamp); + const runStartMs = w.runStartMs ?? ms; + const tick = eventTick(w.tick, ms, runStartMs, world.def.tickHz); + const sim = new Sim(w, world, tick); + sim.registerCharacters(exec); + + if (exec.run.status === 'paused') { + if (!w.paused) sim.freeze(); + return sim.finish({ lastSequence: e.sequence, tick, runStartMs, paused: true }); + } + sim.updateIntents(exec, w.paused); + return sim.finish({ lastSequence: e.sequence, tick, runStartMs, paused: false }); +} + +const cellKey = (c: Cell) => `${c[0]},${c[1]}`; + +/** Copy-on-write working set for one reduceWorld step (keeps unchanged references). */ +class Sim { + private characters: Record; + private characterOrder: string[]; + private stations: Record; + private copiedChars = false; + private copiedStations = false; + private readonly w: WorldState; + private readonly world: CompiledWorld; + private readonly tick: number; + + constructor(w: WorldState, world: CompiledWorld, tick: number) { + this.w = w; + this.world = world; + this.tick = tick; + this.characters = w.characters; + this.characterOrder = w.characterOrder; + this.stations = w.stations; + if (w.lastSequence === 0) { + this.stations = {}; + for (const s of world.def.stations) this.stations[s.id] = { slots: s.slots.map(() => null), overflow: [] }; + this.copiedStations = true; + } + } + + finish(fields: Pick): WorldState { + return { + ...this.w, + ...fields, + characters: this.characters, + characterOrder: this.characterOrder, + stations: this.stations, + }; + } + + private setChar(c: CharacterWorld) { + if (!this.copiedChars) { + this.characters = { ...this.characters }; + this.copiedChars = true; + } + this.characters[c.agentId] = c; + } + + private setStation(id: string, st: StationWorld) { + if (!this.copiedStations) { + this.stations = { ...this.stations }; + this.copiedStations = true; + } + this.stations[id] = st; + } + + /** Embodied agents get a character at their preset's home (offset if taken). */ + registerCharacters(exec: ExecState) { + for (const id of exec.agentOrder) { + const a = exec.agents[id]!; + if (!a.embodied || this.characters[id]) continue; + const preset = + (a.characterPreset ? this.world.presetsById[a.characterPreset] : undefined) ?? + this.world.presetsById[this.world.def.defaultPreset]!; + const home = this.homeFor(preset); + this.setChar({ + agentId: id, + presetId: preset.id, + home, + activity: 'idle', + taskId: null, + stationId: null, + target: { kind: 'home' }, + motion: { path: [home], startTick: this.tick }, + sinceTick: this.tick, + }); + this.characterOrder = [...this.characterOrder, id]; + } + } + + /** + * The preset home, or (when another character already lives there) the + * nearest walkable cell, in BFS order N,E,S,W, that is nobody's home, no + * preset's home and no station slot/overflow. + */ + private homeFor(preset: CharacterPreset): Cell { + const taken = new Set(this.characterOrder.map((id) => cellKey(this.characters[id]!.home))); + if (!taken.has(cellKey(preset.home))) return preset.home; + const reserved = new Set(taken); + for (const p of this.world.def.characterPresets) reserved.add(cellKey(p.home)); + for (const s of this.world.def.stations) { + for (const c of s.slots) reserved.add(cellKey(c)); + reserved.add(cellKey(s.overflow)); + } + const seen = new Set([cellKey(preset.home)]); + const queue: Cell[] = [preset.home]; + for (let head = 0; head < queue.length; head++) { + const [x, y] = queue[head]!; + for (const [dx, dy] of [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], + ] as const) { + const n: Cell = [x + dx, y + dy]; + const k = cellKey(n); + if (seen.has(k) || !this.world.isWalkable(n[0], n[1])) continue; + if (!reserved.has(k)) return n; + seen.add(k); + queue.push(n); + } + } + return preset.home; + } + + /** Pause: everyone finishes the step they are on and stands still. */ + freeze() { + const tpc = this.world.def.ticksPerCell; + for (const id of this.characterOrder) { + const c = this.characters[id]!; + const motion = haltMotion(c.motion, this.tick, tpc); + if (motion !== c.motion) this.setChar({ ...c, motion }); + } + } + + updateIntents(exec: ExecState, resuming: boolean) { + const changed: { id: string; intent: Intent }[] = []; + for (const id of this.characterOrder) { + const c = this.characters[id]!; + const intent = deriveIntent(exec, id, this.world); + // With fixed rules, the station kind is a function of (activity, task). + if (intent.activity !== c.activity || intent.taskId !== c.taskId) changed.push({ id, intent }); + } + + for (const { id } of changed) this.release(id); + this.promoteQueued(); + for (const { id, intent } of changed) { + const c = this.characters[id]!; + const target = this.reserve(id, intent); + this.setChar({ + ...c, + activity: intent.activity, + taskId: intent.taskId, + stationId: target.kind === 'slot' || target.kind === 'overflow' ? target.stationId : null, + target, + motion: this.route(c, target), + sinceTick: intent.activity !== c.activity ? this.tick : c.sinceTick, + }); + } + + if (resuming) { + // Walkers were halted by the pause: send everyone on to their target again. + for (const id of this.characterOrder) { + const c = this.characters[id]!; + const motion = this.route(c, c.target); + if (motion !== c.motion) this.setChar({ ...c, motion }); + } + } + } + + /** Give up the character's slot or overflow place. */ + private release(agentId: string) { + const t = this.characters[agentId]!.target; + if (t.kind === 'slot') { + const st = this.stations[t.stationId]!; + if (st.slots[t.index] !== agentId) return; + const slots = st.slots.slice(); + slots[t.index] = null; + this.setStation(t.stationId, { ...st, slots }); + } else if (t.kind === 'overflow') { + const st = this.stations[t.stationId]!; + this.setStation(t.stationId, { ...st, overflow: st.overflow.filter((a) => a !== agentId) }); + } + } + + /** Fill free slots with the heads of overflow queues of the same station kind. */ + private promoteQueued() { + for (const station of this.world.def.stations) { + const candidates = this.world.stationsByKind[station.kind] ?? []; + for (;;) { + const index = this.stations[station.id]!.slots.indexOf(null); + if (index < 0) break; + const queue = candidates.find((s) => this.stations[s.id]!.overflow.length > 0); + if (!queue) break; + const q = this.stations[queue.id]!; + const agentId = q.overflow[0]!; + this.setStation(queue.id, { ...q, overflow: q.overflow.slice(1) }); + this.occupy(station.id, index, agentId); + const c = this.characters[agentId]!; + const target: Target = { kind: 'slot', stationId: station.id, index }; + this.setChar({ ...c, stationId: station.id, target, motion: this.route(c, target) }); + } + } + } + + private occupy(stationId: string, index: number, agentId: string) { + const st = this.stations[stationId]!; + const slots = st.slots.slice(); + slots[index] = agentId; + this.setStation(stationId, { ...st, slots }); + } + + /** + * Choose and reserve the character's new place. Stations of the wanted kind + * are tried in id order, lowest free slot first; if all are full the agent + * queues at the first candidate's overflow cell. + */ + private reserve(agentId: string, intent: Intent): Target { + if (intent.activity === 'failed') return { kind: 'stay' }; + const wantsStation = intent.activity === 'working' || intent.activity === 'awaiting_approval'; + const candidates = wantsStation && intent.stationKind ? (this.world.stationsByKind[intent.stationKind] ?? []) : []; + if (candidates.length === 0) return { kind: 'home' }; + for (const s of candidates) { + const index = this.stations[s.id]!.slots.indexOf(null); + if (index >= 0) { + this.occupy(s.id, index, agentId); + return { kind: 'slot', stationId: s.id, index }; + } + } + const first = candidates[0]!; + const st = this.stations[first.id]!; + this.setStation(first.id, { ...st, overflow: [...st.overflow, agentId] }); + return { kind: 'overflow', stationId: first.id }; + } + + private route(c: CharacterWorld, target: Target) { + const tpc = this.world.def.ticksPerCell; + if (target.kind === 'stay') return haltMotion(c.motion, this.tick, tpc); + return redirectMotion(this.world, c.motion, this.tick, this.cellOf(c, target)); + } + + private cellOf(c: CharacterWorld, target: Target): Cell { + switch (target.kind) { + case 'slot': + return this.world.stationsById[target.stationId]!.slots[target.index]!; + case 'overflow': + return this.world.stationsById[target.stationId]!.overflow; + default: + return c.home; + } + } +} diff --git a/packages/world-engine/src/test-utils.ts b/packages/world-engine/src/test-utils.ts new file mode 100644 index 0000000..a8a6f7d --- /dev/null +++ b/packages/world-engine/src/test-utils.ts @@ -0,0 +1,74 @@ +/** Helpers for world-engine tests (not exported from the package). */ +import { readFileSync } from 'node:fs'; +import { initialExecState, reduceExecution, type ExecState } from '@runloop-world/core'; +import type { AnyEvent } from '@runloop-world/events'; +import { RunBuilder, runBuilder } from '@runloop-world/testkit'; +import { loadWorld } from './compile'; +import type { WorldDefinition } from './definition'; +import { initialWorldState, reduceWorld } from './reduce-world'; +import type { CompiledWorld, WorldState } from './types'; + +export const campusJson = (): WorldDefinition => + JSON.parse(readFileSync(new URL('../../../worlds/research-campus/world.json', import.meta.url), 'utf8')); + +export const campus = (): CompiledWorld => loadWorld(campusJson()); + +export interface Step { + event: AnyEvent; + exec: ExecState; + world: WorldState; +} + +/** Fold events through both layers, keeping the state after every event. */ +export function foldSteps(events: readonly AnyEvent[], world: CompiledWorld): Step[] { + let exec = initialExecState(); + let w = initialWorldState(); + return events.map((event) => { + exec = reduceExecution(exec, event); + w = reduceWorld(w, exec, event, world); + return { event, exec, world: w }; + }); +} + +export const finalWorld = (events: readonly AnyEvent[], world: CompiledWorld) => foldSteps(events, world).at(-1)!.world; + +/** The world state in effect at `tick` (state after the last event at or before it). */ +export function stateAtTick(steps: readonly Step[], tick: number): WorldState { + let found = steps[0]!.world; + for (const s of steps) { + if (s.world.tick <= tick) found = s.world; + else break; + } + return found; +} + +export interface AgentSpec { + id: string; + preset?: string; + embodied?: boolean; +} + +/** run.started plus agent registrations, 100 ms (1 tick) apart. */ +export function startRun(agents: AgentSpec[]): RunBuilder { + const b = runBuilder({ startIso: '2026-10-17T09:00:00.000Z' }); + b.add('run.started', { + title: 'test', + missionId: 'm', + worldId: 'research-campus', + seed: 1, + adapter: { id: 'x', kind: 'scripted', label: 'X' }, + workspace: 'workspaces/x', + }); + for (const a of agents) { + b.wait(100).add('agent.registered', { + agentId: a.id, + name: a.id, + role: 'r', + model: null, + tools: [], + embodied: a.embodied ?? true, + ...(a.preset ? { characterPreset: a.preset } : {}), + }); + } + return b; +} diff --git a/packages/world-engine/src/types.ts b/packages/world-engine/src/types.ts index 201e4eb..01f3247 100644 --- a/packages/world-engine/src/types.ts +++ b/packages/world-engine/src/types.ts @@ -16,7 +16,11 @@ export interface CompiledWorld { /** Closed-form movement: walk `path` one cell per ticksPerCell, starting at startTick. */ export interface Motion { - /** Cells from the start cell to the destination (inclusive). Length ≥ 1. */ + /** + * Cells from the start cell to the destination (inclusive). Length ≥ 1. + * After a mid-step re-plan, path[0] is the cell being left and path[1] the + * cell being entered, so positions stay continuous. + */ path: Cell[]; startTick: number; } diff --git a/packages/world-engine/src/world.test.ts b/packages/world-engine/src/world.test.ts new file mode 100644 index 0000000..c8e8ff3 --- /dev/null +++ b/packages/world-engine/src/world.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { compileWorld, destinationCells } from './compile'; +import { WorldDefinitionSchema } from './definition'; +import { bfs } from './grid'; +import { campus, campusJson } from './test-utils'; + +describe('worlds/research-campus/world.json', () => { + const world = campus(); + const def = world.def; + + it('parses and compiles', () => { + expect(() => WorldDefinitionSchema.parse(campusJson())).not.toThrow(); + expect(world.width).toBe(24); + expect(world.height).toBe(20); + expect(def.grid.every((row) => row.length === world.width)).toBe(true); + }); + + it('blocks walls, station footprints, props and the hub core', () => { + expect(world.isWalkable(1, 1)).toBe(false); // wall + for (const s of def.stations) expect(world.isWalkable(...s.cell)).toBe(false); + for (const p of def.props) expect(world.isWalkable(...p.cell)).toBe(false); + for (const c of def.hub.core) expect(world.isWalkable(...c)).toBe(false); + expect(world.isWalkable(0, 0)).toBe(true); + expect(world.isWalkable(-1, 0)).toBe(false); + expect(world.isWalkable(24, 0)).toBe(false); + }); + + it('has every slot, overflow cell and preset home walkable and reachable from every home', () => { + const homes = def.characterPresets.map((p) => p.home); + const cells = destinationCells(world); + expect(cells.length).toBe(def.stations.reduce((n, s) => n + s.slots.length + 1, 0) + homes.length); + for (const { label, cell } of cells) { + expect(world.isWalkable(...cell), `${label} walkable`).toBe(true); + for (const home of homes) expect(bfs(world, home, cell), `${label} reachable from [${home}]`).not.toBeNull(); + } + }); + + it('places slots next to their station and keeps destinations distinct', () => { + for (const s of def.stations) { + for (const slot of s.slots) expect(Math.abs(slot[0] - s.cell[0]) + Math.abs(slot[1] - s.cell[1])).toBe(1); + } + const keys = destinationCells(world).map(({ cell }) => cell.join(',')); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('references existing zones, presets and station kinds', () => { + for (const s of def.stations) expect(world.zonesById[s.zoneId]).toBeDefined(); + expect(world.presetsById[def.defaultPreset]).toBeDefined(); + for (const r of def.activityRules) expect(world.stationsByKind[r.stationKind]?.length).toBeGreaterThan(0); + // Stations of a kind are sorted by id (deterministic allocation order). + for (const list of Object.values(world.stationsByKind)) { + expect(list.map((s) => s.id)).toEqual(list.map((s) => s.id).sort()); + } + }); +}); + +describe('compileWorld', () => { + it('rejects slots that are not walkable', () => { + const def = campusJson(); + def.stations[0]!.slots = [[1, 1]]; + expect(() => compileWorld(WorldDefinitionSchema.parse(def))).toThrow(/slot 0 \[1,1\] is not walkable/); + }); + + it('rejects ragged grids and an unknown default preset', () => { + const def = campusJson(); + def.grid[3] = def.grid[3]!.slice(1); + def.defaultPreset = 'nobody'; + expect(() => compileWorld(WorldDefinitionSchema.parse(def))).toThrow(/grid row 3.*defaultPreset nobody/); + }); +}); From 0a7bcd6b718d59d3c706339aa6a49a3fa2351477 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:33:52 +0000 Subject: [PATCH 08/18] Add replay index Reconstructs combined execution and world state at any sequence or time, with tests proving replay equals the live fold under shuffled and duplicated delivery. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/replay/src/index.ts | 2 + packages/replay/src/reduce-run.ts | 19 +++ packages/replay/src/replay-index.ts | 113 ++++++++++++++++ packages/replay/src/replay.test.ts | 195 ++++++++++++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 packages/replay/src/reduce-run.ts create mode 100644 packages/replay/src/replay-index.ts create mode 100644 packages/replay/src/replay.test.ts diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index fcb073f..c6ef6a6 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -1 +1,3 @@ export * from './types'; +export { initialRunState, reduceRun } from './reduce-run'; +export { ReplayIndex } from './replay-index'; diff --git a/packages/replay/src/reduce-run.ts b/packages/replay/src/reduce-run.ts new file mode 100644 index 0000000..d640e38 --- /dev/null +++ b/packages/replay/src/reduce-run.ts @@ -0,0 +1,19 @@ +import { initialExecState, reduceExecution } from '@runloop-world/core'; +import type { AnyEvent } from '@runloop-world/events'; +import { initialWorldState, reduceWorld, type CompiledWorld } from '@runloop-world/world-engine'; +import type { RunState } from './types'; + +export function initialRunState(): RunState { + return { exec: initialExecState(), world: initialWorldState() }; +} + +/** + * Fold one event into both layers: execution first, then the world from the + * new execution state. Used identically by live view and replay. Redelivered + * sequences return `s` unchanged; gaps throw SequenceGapError. + */ +export function reduceRun(s: RunState, e: AnyEvent, world: CompiledWorld): RunState { + const exec = reduceExecution(s.exec, e); + if (exec === s.exec) return s; + return { exec, world: reduceWorld(s.world, exec, e, world) }; +} diff --git a/packages/replay/src/replay-index.ts b/packages/replay/src/replay-index.ts new file mode 100644 index 0000000..c0928f6 --- /dev/null +++ b/packages/replay/src/replay-index.ts @@ -0,0 +1,113 @@ +import { SequenceGapError } from '@runloop-world/core'; +import type { AnyEvent } from '@runloop-world/events'; +import { msToTick, type CompiledWorld } from '@runloop-world/world-engine'; +import { initialRunState, reduceRun } from './reduce-run'; +import type { RunState } from './types'; + +const SNAPSHOT_EVERY = 50; +const MEMO_SIZE = 64; + +/** + * Random access over one run's ordered events: the state at any sequence and + * the mapping between sequences and (monotonic) time since run start. Replay + * never re-executes anything; it is a plain fold with snapshots and a memo. + */ +export class ReplayIndex { + private readonly world: CompiledWorld; + private readonly list: AnyEvent[] = []; + /** times[i] = ms since run start of event i+1, clamped to be non-decreasing. */ + private readonly times: number[] = []; + private readonly snapshots = new Map(); + private readonly memo = new Map(); + private head: RunState = initialRunState(); + private startMs: number | null = null; + + constructor(world: CompiledWorld, events: readonly AnyEvent[] = []) { + this.world = world; + this.snapshots.set(0, this.head); + this.append(events); + } + + /** + * Add events in sequence order. Already-known sequences are ignored; a gap + * throws SequenceGapError (order and dedupe upstream with SequenceBuffer). + */ + append(events: readonly AnyEvent[]): void { + for (const e of events) { + const last = this.list.length; + if (e.sequence <= last) continue; + if (e.sequence !== last + 1) throw new SequenceGapError(last + 1, e.sequence); + const ms = Date.parse(e.timestamp); + this.startMs ??= ms; + const prev = this.times[last - 1] ?? 0; + this.list.push(e); + this.times.push(Math.max(prev, ms - this.startMs)); + this.head = reduceRun(this.head, e, this.world); + if (e.sequence % SNAPSHOT_EVERY === 0) this.snapshots.set(e.sequence, this.head); + } + } + + get events(): readonly AnyEvent[] { + return this.list; + } + + get lastSequence(): number { + return this.list.length; + } + + get runStartMs(): number | null { + return this.startMs; + } + + /** Time of the last event since run start (monotonic clamp). */ + get durationMs(): number { + return this.times[this.times.length - 1] ?? 0; + } + + /** State after folding events 1..seq (seq clamped to [0, lastSequence]). Memoized. */ + stateAt(seq: number): RunState { + const n = Math.max(0, Math.min(this.lastSequence, Math.floor(seq))); + if (n === this.lastSequence) return this.head; + const snap = this.snapshots.get(n); + if (snap) return snap; + const hit = this.memo.get(n); + if (hit) return hit; + + // Fold forward from the nearest snapshot (or a memoized state closer to n). + let from = Math.floor(n / SNAPSHOT_EVERY) * SNAPSHOT_EVERY; + for (const k of this.memo.keys()) if (k < n && k > from) from = k; + let s = this.snapshots.get(from) ?? this.memo.get(from)!; + for (let i = from; i < n; i++) s = reduceRun(s, this.list[i]!, this.world); + + this.memo.set(n, s); + if (this.memo.size > MEMO_SIZE) this.memo.delete(this.memo.keys().next().value!); + return s; + } + + latest(): RunState { + return this.head; + } + + /** Ms since run start of event `seq` (0 for seq ≤ 0; clamped to the last event). */ + timeOfSeq(seq: number): number { + if (seq <= 0 || this.list.length === 0) return 0; + return this.times[Math.min(this.list.length, Math.floor(seq)) - 1]!; + } + + /** Largest sequence whose time is ≤ ms (0 if none). */ + seqAtTime(ms: number): number { + let lo = 0; + let hi = this.times.length; // answer in [0, length] + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.times[mid - 1]! <= ms) lo = mid; + else hi = mid - 1; + } + return lo; + } + + /** Fractional world tick for a time since run start. */ + tickAtMs(ms: number): number { + return msToTick(ms, this.world.def.tickHz); + } +} diff --git a/packages/replay/src/replay.test.ts b/packages/replay/src/replay.test.ts new file mode 100644 index 0000000..f36c302 --- /dev/null +++ b/packages/replay/src/replay.test.ts @@ -0,0 +1,195 @@ +import { readFileSync } from 'node:fs'; +import { SequenceGapError } from '@runloop-world/core'; +import { SequenceBuffer, type AnyEvent, type RunEvent } from '@runloop-world/events'; +import { codingMissionFixture, deliver, mulberry32, runBuilder } from '@runloop-world/testkit'; +import { loadWorld, presentationAt } from '@runloop-world/world-engine'; +import { describe, expect, it } from 'vitest'; +import { initialRunState, reduceRun } from './reduce-run'; +import { ReplayIndex } from './replay-index'; +import type { RunState } from './types'; + +const world = loadWorld( + JSON.parse(readFileSync(new URL('../../../worlds/research-campus/world.json', import.meta.url), 'utf8')), +); +const fixture = codingMissionFixture(); + +/** States after each event (index 0 = initial), folded one by one. */ +function incremental(events: readonly AnyEvent[]): RunState[] { + const out = [initialRunState()]; + for (const e of events) out.push(reduceRun(out.at(-1)!, e, world)); + return out; +} + +/** A long run (≈ 250 events) with irregular timing, to exercise snapshots. */ +function longRun(seed = 9): RunEvent[] { + const rand = mulberry32(seed); + const b = runBuilder({ startIso: '2026-10-17T10:00:00.000Z', namespace: 0xa1 }); + b.add('run.started', { + title: 'long', + missionId: 'm', + worldId: 'research-campus', + seed, + adapter: { id: 'x', kind: 'scripted', label: 'X' }, + workspace: 'workspaces/long', + }); + const agents = ['researcher', 'builder', 'tester']; + for (const a of agents) { + b.wait(100).add('agent.registered', { agentId: a, name: a, role: a, model: null, tools: [], embodied: true, characterPreset: a }); + } + const kinds = ['research', 'code', 'test', 'fix'] as const; + let call = 0; + for (let i = 1; i <= 40; i++) { + const agentId = agents[i % 3]!; + const taskId = `T${i}`; + b.wait(100 + Math.floor(rand() * 900)).add('task.created', { taskId, title: taskId, kind: kinds[i % 4]!, dependsOn: [], plannedSteps: 2 }); + b.wait(Math.floor(rand() * 400)).add('task.started', { taskId, agentId }); + for (let k = 0; k < 2; k++) { + call += 1; + b.wait(Math.floor(rand() * 1500)).add('tool.call_started', { callId: `c${call}`, agentId, taskId, tool: 'read_file', inputSummary: 'f' }); + // Occasionally the clock runs backwards a little: order must still come from sequence. + b.wait(rand() < 0.15 ? -300 : Math.floor(rand() * 300)).add('tool.call_completed', { + callId: `c${call}`, + status: rand() < 0.2 ? 'error' : 'success', + durationMs: 5, + outputSummary: 'ok', + }); + } + if (rand() < 0.15) b.wait(200).add('task.failed', { taskId, reason: 'nope' }); + else b.wait(200).add('task.completed', { taskId, summary: 'done', artifactIds: [] }); + } + b.wait(500).add('run.completed', { outcome: 'succeeded' }); + return b.events; +} + +describe('reduceRun', () => { + it('folds execution then world, and ignores redelivered sequences', () => { + const s = incremental(fixture.slice(0, 10)).at(-1)!; + expect(s.exec.lastSequence).toBe(10); + expect(s.world.lastSequence).toBe(10); + expect(reduceRun(s, fixture[4]!, world)).toBe(s); + expect(() => reduceRun(s, fixture[11]!, world)).toThrow(SequenceGapError); + }); +}); + +describe('ReplayIndex', () => { + it('stateAt(N) deep-equals the incremental fold for every N (fixture)', () => { + const idx = new ReplayIndex(world, fixture); + const folds = incremental(fixture); + // Visit out of order so snapshots and the memo are both exercised. + const order = fixture.map((e) => e.sequence).reverse(); + order.push(0, 25, 51, 50, 7); + for (const n of order) expect(idx.stateAt(n)).toEqual(folds[n]); + expect(idx.latest()).toBe(idx.stateAt(fixture.length)); + expect(idx.latest()).toEqual(folds.at(-1)); + }); + + it('stateAt(N) deep-equals the incremental fold for a long run (snapshots every 50)', () => { + const events = longRun(); + expect(events.length).toBeGreaterThan(200); + const idx = new ReplayIndex(world, events); + const folds = incremental(events); + const rand = mulberry32(3); + for (let i = 0; i < 300; i++) { + const n = Math.floor(rand() * (events.length + 1)); + expect(idx.stateAt(n)).toEqual(folds[n]); + } + for (let n = 0; n <= events.length; n++) expect(idx.stateAt(n)).toEqual(folds[n]); + }); + + it('memoizes and clamps', () => { + const idx = new ReplayIndex(world, fixture); + expect(idx.stateAt(23)).toBe(idx.stateAt(23)); + expect(idx.stateAt(23.7)).toBe(idx.stateAt(23)); + expect(idx.stateAt(-4)).toEqual(initialRunState()); + expect(idx.stateAt(10_000)).toBe(idx.latest()); + expect(new ReplayIndex(world).latest()).toEqual(initialRunState()); + }); + + it('append builds the same index incrementally, ignores known sequences and throws on gaps', () => { + const idx = new ReplayIndex(world); + for (let i = 0; i < fixture.length; i += 7) idx.append(fixture.slice(Math.max(0, i - 2), i + 7)); + expect(idx.lastSequence).toBe(fixture.length); + expect(idx.events).toEqual(fixture); + expect(idx.latest()).toEqual(new ReplayIndex(world, fixture).latest()); + const short = new ReplayIndex(world, fixture.slice(0, 5)); + expect(() => short.append([fixture[6]!])).toThrow(SequenceGapError); + }); + + it('shuffled + duplicated delivery through SequenceBuffer then folding equals replay', () => { + const replay = new ReplayIndex(world, fixture).latest(); + for (let seed = 1; seed <= 50; seed++) { + const buffer = new SequenceBuffer(); + const live = new ReplayIndex(world); + let state = initialRunState(); + for (const e of deliver(fixture, seed, { dupRate: 0.2, shuffleWindow: 8 })) { + const released = buffer.push(e); + live.append(released); + for (const r of released) state = reduceRun(state, r, world); + } + expect(buffer.missing()).toBeNull(); + expect(buffer.conflicts).toEqual([]); + expect(state).toEqual(replay); + expect(live.latest()).toEqual(replay); + } + }); + + it('two independent folds produce identical JSON', () => { + const a = JSON.stringify(new ReplayIndex(world, codingMissionFixture()).latest()); + const b = JSON.stringify(incremental(codingMissionFixture()).at(-1)); + expect(a).toBe(b); + }); +}); + +describe('ReplayIndex time mapping', () => { + it('maps sequences to monotonic time and back, even when timestamps go backwards', () => { + const b = runBuilder({ startIso: '2026-10-17T10:00:00.000Z' }); + const reg = (id: string) => ({ agentId: id, name: id, role: 'r', model: null, tools: [], embodied: false }); + b.add('agent.registered', reg('a')); // 0 ms + b.wait(1000).add('agent.registered', reg('b')); // 1000 + b.wait(-600).add('agent.registered', reg('c')); // 400 → clamped to 1000 + b.wait(1100).add('agent.registered', reg('d')); // 1500 + const idx = new ReplayIndex(world, b.events); + expect([0, 1, 2, 3, 4].map((s) => idx.timeOfSeq(s))).toEqual([0, 0, 1000, 1000, 1500]); + expect(idx.durationMs).toBe(1500); + expect(idx.runStartMs).toBe(Date.parse('2026-10-17T10:00:00.000Z')); + expect(idx.seqAtTime(-1)).toBe(0); + expect(idx.seqAtTime(0)).toBe(1); + expect(idx.seqAtTime(999)).toBe(1); + expect(idx.seqAtTime(1000)).toBe(3); + expect(idx.seqAtTime(1499)).toBe(3); + expect(idx.seqAtTime(1e9)).toBe(4); + expect(idx.tickAtMs(1250)).toBeCloseTo(12.5); + expect(new ReplayIndex(world).seqAtTime(5000)).toBe(0); + expect(new ReplayIndex(world).durationMs).toBe(0); + }); + + it('agrees with the world tick of every event', () => { + const events = longRun(4); + const idx = new ReplayIndex(world, events); + for (const e of events) { + expect(Math.floor(idx.tickAtMs(idx.timeOfSeq(e.sequence)))).toBe(idx.stateAt(e.sequence).world.tick); + expect(idx.seqAtTime(idx.timeOfSeq(e.sequence))).toBeGreaterThanOrEqual(e.sequence); + } + }); + + it('positions are identical between a live fold and replay at many ticks', () => { + for (const events of [fixture, longRun(11)]) { + const idx = new ReplayIndex(world, events); + // "Live": fold events as their (monotonic) time arrives, render at the current time. + let live = initialRunState(); + let next = 0; + const end = idx.durationMs + 5000; + for (let ms = 0; ms <= end; ms += 37) { + while (next < events.length && idx.timeOfSeq(next + 1) <= ms) live = reduceRun(live, events[next++]!, world); + const replay = idx.stateAt(idx.seqAtTime(ms)); + expect(replay.exec.lastSequence).toBe(live.exec.lastSequence); + const tick = idx.tickAtMs(ms); + for (const id of live.world.characterOrder) { + const a = presentationAt(live.world.characters[id]!, tick, world, { paused: live.world.paused }); + const r = presentationAt(replay.world.characters[id]!, tick, world, { paused: replay.world.paused }); + expect(r).toEqual(a); + } + } + } + }); +}); From 5bbe72d3255acdd4b1b085be904c27ac4e7c3778 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:33:52 +0000 Subject: [PATCH 09/18] Add local runtime and the coding-mission example SQLite event recorder (persist, then publish), sandboxed file and test tools, a scripted adapter that performs real tool calls, a Hono HTTP API with resumable SSE, and serve/demo CLI commands. The example repo's planted bug makes 2 of 12 tests fail for real before the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- examples/coding-mission/README.md | 41 ++ examples/coding-mission/mission.json | 15 + examples/coding-mission/repo/README.md | 23 ++ examples/coding-mission/repo/package.json | 10 + .../coding-mission/repo/src/task-store.js | 4 + .../repo/test/task-store.test.js | 93 +++++ .../coding-mission/script/implement.buggy.js | 33 ++ .../coding-mission/script/implement.fixed.js | 33 ++ packages/runtime/README.md | 84 ++++ packages/runtime/src/adapter.ts | 79 ++++ .../src/adapters/scripted-coding-mission.ts | 260 +++++++++++++ packages/runtime/src/bin.ts | 13 + packages/runtime/src/bus.ts | 57 +++ packages/runtime/src/cli.ts | 144 +++++++ packages/runtime/src/config.ts | 47 +++ packages/runtime/src/http/app.ts | 207 ++++++++++ packages/runtime/src/http/sse.ts | 92 +++++ packages/runtime/src/index.ts | 18 +- packages/runtime/src/mission.ts | 49 +++ packages/runtime/src/recorder.ts | 366 ++++++++++++++++++ packages/runtime/src/run-manager.ts | 268 +++++++++++++ packages/runtime/src/runtime.ts | 78 ++++ packages/runtime/src/sandbox/command.ts | 121 ++++++ packages/runtime/src/sandbox/fs-tools.ts | 74 ++++ packages/runtime/src/sandbox/paths.ts | 62 +++ packages/runtime/src/sandbox/tap.ts | 57 +++ packages/runtime/src/store/db.ts | 44 +++ packages/runtime/src/store/migrations.ts | 48 +++ packages/runtime/src/tool-invoker.ts | 270 +++++++++++++ packages/runtime/src/trace-printer.ts | 91 +++++ .../runtime/test/coding-mission.int.test.ts | 254 ++++++++++++ packages/runtime/test/helpers.ts | 116 ++++++ packages/runtime/test/http.test.ts | 142 +++++++ packages/runtime/test/recorder.test.ts | 173 +++++++++ packages/runtime/test/sandbox.test.ts | 212 ++++++++++ packages/runtime/test/sse.test.ts | 100 +++++ packages/runtime/test/tool-invoker.test.ts | 72 ++++ packages/runtime/test/trace-printer.test.ts | 16 + packages/runtime/vitest.config.ts | 2 + 39 files changed, 3867 insertions(+), 1 deletion(-) create mode 100644 examples/coding-mission/README.md create mode 100644 examples/coding-mission/mission.json create mode 100644 examples/coding-mission/repo/README.md create mode 100644 examples/coding-mission/repo/package.json create mode 100644 examples/coding-mission/repo/src/task-store.js create mode 100644 examples/coding-mission/repo/test/task-store.test.js create mode 100644 examples/coding-mission/script/implement.buggy.js create mode 100644 examples/coding-mission/script/implement.fixed.js create mode 100644 packages/runtime/README.md create mode 100644 packages/runtime/src/adapter.ts create mode 100644 packages/runtime/src/adapters/scripted-coding-mission.ts create mode 100644 packages/runtime/src/bin.ts create mode 100644 packages/runtime/src/bus.ts create mode 100644 packages/runtime/src/cli.ts create mode 100644 packages/runtime/src/config.ts create mode 100644 packages/runtime/src/http/app.ts create mode 100644 packages/runtime/src/http/sse.ts create mode 100644 packages/runtime/src/mission.ts create mode 100644 packages/runtime/src/recorder.ts create mode 100644 packages/runtime/src/run-manager.ts create mode 100644 packages/runtime/src/runtime.ts create mode 100644 packages/runtime/src/sandbox/command.ts create mode 100644 packages/runtime/src/sandbox/fs-tools.ts create mode 100644 packages/runtime/src/sandbox/paths.ts create mode 100644 packages/runtime/src/sandbox/tap.ts create mode 100644 packages/runtime/src/store/db.ts create mode 100644 packages/runtime/src/store/migrations.ts create mode 100644 packages/runtime/src/tool-invoker.ts create mode 100644 packages/runtime/src/trace-printer.ts create mode 100644 packages/runtime/test/coding-mission.int.test.ts create mode 100644 packages/runtime/test/helpers.ts create mode 100644 packages/runtime/test/http.test.ts create mode 100644 packages/runtime/test/recorder.test.ts create mode 100644 packages/runtime/test/sandbox.test.ts create mode 100644 packages/runtime/test/sse.test.ts create mode 100644 packages/runtime/test/tool-invoker.test.ts create mode 100644 packages/runtime/test/trace-printer.test.ts diff --git a/examples/coding-mission/README.md b/examples/coding-mission/README.md new file mode 100644 index 0000000..89428a6 --- /dev/null +++ b/examples/coding-mission/README.md @@ -0,0 +1,41 @@ +# Coding mission (demo) + +The mission `pnpm demo` and the Studio's "Run demo mission" button start. + +## Files + +| Path | Role | +| --- | --- | +| `mission.json` | Mission metadata: title, world, seed, adapter, and the scripted adapter's settings. | +| `repo/` | The starting repository. Each run copies it to `/workspaces//`, and every tool works on that copy. | +| `repo/test/task-store.test.js` | 12 `node:test` tests. | +| `repo/src/task-store.js` | A stub that throws `not implemented`. | +| `script/implement.buggy.js` | The Builder's first implementation. `listOpen()` keeps done tasks (`filter(t => t.done)`), which breaks exactly 2 tests. | +| `script/implement.fixed.js` | The Builder's fix: `filter(t => !t.done)`. | + +`script/` is the scripted adapter's playbook, not part of the repository. It is never copied into a workspace. + +## What happens in a run + +The adapter's decisions are scripted: who does what, and which file contents the Builder writes. Every tool result is real. + +1. The Coordinator creates T1 (research), T2 (code) and T3 (test). +2. The Researcher lists the repository, reads `README.md` and the test file, and tells the Builder what the suite expects. +3. The Builder writes `script/implement.buggy.js` into `src/task-store.js`. +4. The Tester runs `node --test --test-reporter=tap` in the workspace. It fails for real: **12 tests, 10 passed, 2 failed**. + - T3 is blocked. + - The Coordinator creates T4 (fix). + - The Tester sends the real failing test names to the Builder. +5. The Builder writes the fix, and the Tester reruns the suite: **12 passed**. The run ends `succeeded`. + +The branches follow the real results. If the first test run passes, there is no fix task. If the rerun still fails, T3 fails and the run ends `failed`. + +## Checking the repository by hand + +Run these from this directory: + +```sh +tmp=$(mktemp -d) && cp -r repo/. "$tmp" +cp script/implement.buggy.js "$tmp/src/task-store.js" && (cd "$tmp" && node --test --test-reporter=tap) # 10 pass, 2 fail +cp script/implement.fixed.js "$tmp/src/task-store.js" && (cd "$tmp" && node --test --test-reporter=tap) # 12 pass +``` diff --git a/examples/coding-mission/mission.json b/examples/coding-mission/mission.json new file mode 100644 index 0000000..9a87b4b --- /dev/null +++ b/examples/coding-mission/mission.json @@ -0,0 +1,15 @@ +{ + "id": "coding-mission", + "title": "Build a task store and make its test suite pass", + "description": "A coordinator plans research, implementation and testing for a tiny task-store library. The test suite runs for real; a planted bug makes the first run fail, which drives a fix and a passing rerun.", + "worldId": "research-campus", + "seed": 42, + "adapter": "scripted-coding-mission", + "repo": "repo", + "script": { + "target": "src/task-store.js", + "implementation": "script/implement.buggy.js", + "fix": "script/implement.fixed.js", + "testCommand": ["node", "--test", "--test-reporter=tap"] + } +} diff --git a/examples/coding-mission/repo/README.md b/examples/coding-mission/repo/README.md new file mode 100644 index 0000000..f6e0bef --- /dev/null +++ b/examples/coding-mission/repo/README.md @@ -0,0 +1,23 @@ +# task-store + +A tiny in-memory task store with no dependencies. + +## API + +`createStore()` returns a store with these methods: + +| Method | Behaviour | +| -------------- | ---------------------------------------------------------------------- | +| `add(title)` | Adds a task `{ id, title, done: false }` and returns it. Ids are unique. Throws a `TypeError` for an empty title. | +| `get(id)` | Returns the task with that id, or `undefined`. | +| `all()` | Returns every task in insertion order, as a new array. | +| `complete(id)` | Marks the task as done and returns it. Throws for an unknown id. | +| `listOpen()` | Returns the tasks that are not done, in insertion order. | + +## Tests + +```sh +node --test --test-reporter=tap +``` + +`src/task-store.js` is still a stub, so every test fails until it is implemented. diff --git a/examples/coding-mission/repo/package.json b/examples/coding-mission/repo/package.json new file mode 100644 index 0000000..ccea6ba --- /dev/null +++ b/examples/coding-mission/repo/package.json @@ -0,0 +1,10 @@ +{ + "name": "task-store", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "A tiny in-memory task store. Used by the Runloop coding-mission demo.", + "scripts": { + "test": "node --test --test-reporter=tap" + } +} diff --git a/examples/coding-mission/repo/src/task-store.js b/examples/coding-mission/repo/src/task-store.js new file mode 100644 index 0000000..4473373 --- /dev/null +++ b/examples/coding-mission/repo/src/task-store.js @@ -0,0 +1,4 @@ +// TODO: implement the store described in README.md. +export function createStore() { + throw new Error('not implemented'); +} diff --git a/examples/coding-mission/repo/test/task-store.test.js b/examples/coding-mission/repo/test/task-store.test.js new file mode 100644 index 0000000..809897b --- /dev/null +++ b/examples/coding-mission/repo/test/task-store.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createStore } from '../src/task-store.js'; + +test('add returns a task with an id and the given title', () => { + const store = createStore(); + const task = store.add('write docs'); + assert.equal(task.title, 'write docs'); + assert.notEqual(task.id, undefined); +}); + +test('add assigns unique ids', () => { + const store = createStore(); + const ids = ['a', 'b', 'c'].map((title) => store.add(title).id); + assert.equal(new Set(ids).size, 3); +}); + +test('new tasks start not done', () => { + const store = createStore(); + assert.equal(store.add('a').done, false); +}); + +test('add rejects an empty title', () => { + const store = createStore(); + assert.throws(() => store.add(''), TypeError); +}); + +test('get returns the task with that id', () => { + const store = createStore(); + store.add('a'); + const b = store.add('b'); + assert.equal(store.get(b.id).title, 'b'); +}); + +test('get returns undefined for an unknown id', () => { + const store = createStore(); + store.add('a'); + assert.equal(store.get(999), undefined); +}); + +test('all returns tasks in insertion order', () => { + const store = createStore(); + store.add('a'); + store.add('b'); + store.add('c'); + assert.deepEqual( + store.all().map((t) => t.title), + ['a', 'b', 'c'], + ); +}); + +test('all returns a copy of the list', () => { + const store = createStore(); + store.add('a'); + store.all().push({ id: 'x', title: 'intruder', done: false }); + assert.equal(store.all().length, 1); +}); + +test('complete marks a task as done', () => { + const store = createStore(); + const task = store.add('a'); + store.complete(task.id); + assert.equal(store.get(task.id).done, true); +}); + +test('complete throws for an unknown id', () => { + const store = createStore(); + assert.throws(() => store.complete(42)); +}); + +test('listOpen returns only tasks that are not done', () => { + const store = createStore(); + store.add('a'); + const b = store.add('b'); + store.add('c'); + store.complete(b.id); + assert.deepEqual( + store.listOpen().map((t) => t.title), + ['a', 'c'], + ); +}); + +test('listOpen reflects complete()', () => { + const store = createStore(); + const a = store.add('a'); + store.add('b'); + assert.equal(store.listOpen().length, 2); + store.complete(a.id); + assert.deepEqual( + store.listOpen().map((t) => t.title), + ['b'], + ); +}); diff --git a/examples/coding-mission/script/implement.buggy.js b/examples/coding-mission/script/implement.buggy.js new file mode 100644 index 0000000..73b6144 --- /dev/null +++ b/examples/coding-mission/script/implement.buggy.js @@ -0,0 +1,33 @@ +// In-memory task store (see README.md for the API). +export function createStore() { + const tasks = []; + let nextId = 1; + + const find = (id) => tasks.find((t) => t.id === id); + + return { + add(title) { + if (typeof title !== 'string' || title.trim() === '') { + throw new TypeError('title must be a non-empty string'); + } + const task = { id: nextId++, title, done: false }; + tasks.push(task); + return task; + }, + get(id) { + return find(id); + }, + all() { + return [...tasks]; + }, + complete(id) { + const task = find(id); + if (!task) throw new Error(`unknown task: ${id}`); + task.done = true; + return task; + }, + listOpen() { + return tasks.filter((t) => t.done); + }, + }; +} diff --git a/examples/coding-mission/script/implement.fixed.js b/examples/coding-mission/script/implement.fixed.js new file mode 100644 index 0000000..1642e5a --- /dev/null +++ b/examples/coding-mission/script/implement.fixed.js @@ -0,0 +1,33 @@ +// In-memory task store (see README.md for the API). +export function createStore() { + const tasks = []; + let nextId = 1; + + const find = (id) => tasks.find((t) => t.id === id); + + return { + add(title) { + if (typeof title !== 'string' || title.trim() === '') { + throw new TypeError('title must be a non-empty string'); + } + const task = { id: nextId++, title, done: false }; + tasks.push(task); + return task; + }, + get(id) { + return find(id); + }, + all() { + return [...tasks]; + }, + complete(id) { + const task = find(id); + if (!task) throw new Error(`unknown task: ${id}`); + task.done = true; + return task; + }, + listOpen() { + return tasks.filter((t) => !t.done); + }, + }; +} diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 0000000..4835ba0 --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,84 @@ +# @runloop-world/runtime + +The local runtime. It runs missions with real tools, records every event to SQLite, and serves the log over HTTP and SSE to Studio. + +```sh +pnpm serve # API on http://127.0.0.1:4317 +pnpm demo # run the coding mission headless and print the trace +pnpm demo -- --speed 0 # same, with no pacing delays +pnpm demo -- --keep # keep serving the API after the run ends +``` + +Environment variables: +- **`RUNLOOP_DATA_DIR`** sets the data directory (default `/.runloop`). It holds `runloop.db`, `artifacts//` and `workspaces//`. +- **`RUNLOOP_ALLOWED_ORIGINS`** adds browser origins to the defaults (`http://localhost:3000` and `http://127.0.0.1:3000`). + +Only one runtime process may use a data dir at a time; `runtime.pid` acts as the lock. + +## How a run flows + +``` +RunManager.start ─► copy examples//repo → workspaces/ + ─► Recorder.createRun (run.started) + ─► adapter.run(ctx, mission) (background) + ctx.emit(agent.*/task.*) ─┐ + ctx.tools.invoke(...) ─┤─► Recorder.append ─► EventBus ─► SSE / CLI trace + ctx.pace(ms) │ + ─► run.completed {succeeded|failed|stopped} +boot ─► Recorder.recoverInterrupted() (runs left "running" → interrupted) +``` + +### Recorder (`src/recorder.ts`) + +`append` is synchronous and runs these steps in order: +1. Redact secrets. +2. Assign the next sequence. +3. Validate with `parseKnownEvent`. An invalid event throws here and consumes no sequence. +4. In one transaction, insert the event and update the run row. +5. Publish to the bus. + +Nothing is published before it is durable. + +### Adapters (`src/adapter.ts`) + +Adapters may only emit `agent.*` and `task.*` events: +- `run.*` comes from the run manager. +- `tool.*` and `artifact.*` come from the tool invoker. + +So an adapter cannot fabricate a tool result or an outcome. Every `tool.call_started` gets exactly one terminal event, linked by `parentEventId`: +- **`tool.call_completed`:** the tool ran. A failing test run is `status: error` with its real metrics. +- **`tool.call_failed`:** the tool could not run (denied, timeout, aborted, spawn_error, exception or runtime_restarted). + +### Sandbox (`src/sandbox/`) + +- Paths are resolved inside the workspace, and realpath catches symlink escapes. +- Reads and writes are capped at 256 KB; listings at 500 entries. +- `run_command` allows only the exact argv `node --test --test-reporter=tap`. It runs as `process.execPath` with `shell: false` and a minimal env, has a 30 s timeout, and its process group is SIGKILLed on timeout or abort. + +### Scripted coding mission (`src/adapters/scripted-coding-mission.ts`) + +The decisions are scripted; the tool results are real. See `examples/coding-mission/README.md`. + +## HTTP API + +The contract is `packages/events/src/protocol.ts`. Every request needs a `localhost` or `127.0.0.1` Host header. POSTs need `Content-Type: application/json` and, when an `Origin` is sent, an allowed origin. + +| Route | Result | +| --- | --- | +| `GET /api/health` | `{ ok, version, activeRunId }` | +| `GET /api/runs` | `{ runs }`, newest first | +| `POST /api/runs` `{ missionId, speed? }` | 202 `{ runId }`. 409 if a run is active, 404 for an unknown mission. | +| `GET /api/runs/:id` | `{ run, world }`, where `world` is `worlds//world.json`, validated | +| `GET /api/runs/:id/events?after&limit` | `{ events, nextAfter, hasMore }` (limit ≤ 1000) | +| `GET /api/runs/:id/stream` | SSE. Resume with `Last-Event-ID` or `?after`. Sends `: ping` every 15 s and an `end` event when the run is terminal. | +| `POST /api/runs/:id/{stop,pause,resume}` | 202 `{ run }`, or 409 if the run is not active | +| `GET /api/runs/:id/artifacts/:artifactId` | The stored bytes, with their media type | + +## Tests + +```sh +pnpm vitest run --project runtime +pnpm --filter @runloop-world/runtime typecheck +``` + +`test/coding-mission.int.test.ts` runs the real mission at speed 0 and checks the result against `@runloop-world/testkit`'s canonical fixture: the event types, envelope refs and payload fields must all match. diff --git a/packages/runtime/src/adapter.ts b/packages/runtime/src/adapter.ts new file mode 100644 index 0000000..987b942 --- /dev/null +++ b/packages/runtime/src/adapter.ts @@ -0,0 +1,79 @@ +import type { AdapterInfo, EventOf, EventType, PayloadOf, TestMetrics } from '@runloop-world/events'; +import type { Mission } from './mission'; + +/** + * Event types an adapter may emit itself. run.*, tool.* and artifact.* are + * produced only by the runtime (run manager and tool invoker), so an adapter + * cannot fabricate a tool result or an outcome. + */ +export type AdapterEventType = Exclude; + +export function isAdapterEventType(type: string): type is AdapterEventType { + return !/^(run|tool|artifact)\./.test(type); +} + +export type ToolCall = + | { tool: 'list_files'; path?: string } + | { tool: 'read_file'; path: string } + /** `summary` is the agent's description of the change; it labels the file artifact. */ + | { tool: 'write_file'; path: string; content: string; summary?: string } + | { tool: 'run_command'; argv: string[] }; + +export type ToolName = ToolCall['tool']; + +export interface ToolResult { + callId: string; + /** + * success / error: the tool ran (tool.call_completed; a failing test run is "error"). + * failed: the tool could not run at all (tool.call_failed). + */ + status: 'success' | 'error' | 'failed'; + durationMs: number; + /** The outputSummary that was recorded (for "failed": the failure message). */ + outputSummary: string; + errorType?: string; + exitCode?: number; + metrics?: TestMetrics; + failingTests?: string[]; + artifactIds: string[]; + /** Raw tool output for the agent's next decision (file content, listing, stdout). Not recorded. */ + output?: string; +} + +export interface RunContext { + readonly runId: string; + /** Aborted when the run is stopped. */ + readonly signal: AbortSignal; + /** Pacing multiplier; 0 = no delays. */ + readonly speed: number; + emit( + type: T, + payload: PayloadOf, + meta?: { actorId?: string; taskId?: string }, + ): EventOf; + readonly tools: { + /** Emits tool.call_started → artifact.created* → tool.call_completed | tool.call_failed. */ + invoke(agentId: string, taskId: string, call: ToolCall): Promise; + }; + /** Wait `ms / speed` (abortable), then wait while the run is paused. */ + pace(ms: number): Promise; +} + +export interface AdapterResult { + outcome: 'succeeded' | 'failed'; + summary: string; +} + +export interface AgentAdapter { + readonly info: AdapterInfo; + run(ctx: RunContext, mission: Mission): Promise; +} + +/** The error thrown by ctx.pace / ctx.emit / tools.invoke once the run is stopped. */ +export function abortError(): Error { + return new DOMException('The run was stopped', 'AbortError'); +} + +export function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === 'AbortError'; +} diff --git a/packages/runtime/src/adapters/scripted-coding-mission.ts b/packages/runtime/src/adapters/scripted-coding-mission.ts new file mode 100644 index 0000000..efb219e --- /dev/null +++ b/packages/runtime/src/adapters/scripted-coding-mission.ts @@ -0,0 +1,260 @@ +/** + * Scripted adapter for examples/coding-mission. + * + * The *decisions* are scripted (which agent does what, and which source file + * the Builder writes, taken from the mission's script/ directory). Every tool + * result is real: files are listed, read and written in the run workspace and + * the test suite really runs. Branches follow those real results: + * - first test run passes → no fix task, run succeeds; + * - it fails → T3 blocks, the coordinator creates T4 (fix), the Builder writes + * the fix, T3 is retried; the rerun's real result decides the outcome. + * + * Ids match @runloop-world/testkit CODING_MISSION, and a successful run with a + * failing first test run produces exactly codingMissionTypeSequence(). + */ +import { readFileSync } from 'node:fs'; +import type { PayloadOf } from '@runloop-world/events'; +import type { AdapterEventType, AdapterResult, AgentAdapter, RunContext, ToolCall, ToolResult } from '../adapter'; +import type { Mission } from '../mission'; +import { resolveInside } from '../sandbox/paths'; + +/** Rough pacing at speed 1 (ms); mirrors testkit CODING_MISSION_PACE. */ +const PACE = { working: 1500, step: 700 } as const; + +const AGENTS = { + coordinator: { agentId: 'coordinator', name: 'Coordinator', role: 'coordinator', tools: [] as string[] }, + researcher: { agentId: 'researcher', name: 'Researcher', role: 'research', tools: ['list_files', 'read_file'], preset: 'researcher' }, + builder: { agentId: 'builder', name: 'Builder', role: 'build', tools: ['read_file', 'write_file'], preset: 'builder' }, + tester: { agentId: 'tester', name: 'Tester', role: 'test', tools: ['run_command', 'read_file'], preset: 'tester' }, +} as const; + +const TASKS = { + research: { taskId: 'T1', title: 'Survey the repository and the test suite', kind: 'research', plannedSteps: 3 }, + code: { taskId: 'T2', title: 'Implement the task store', kind: 'code', plannedSteps: 2 }, + test: { taskId: 'T3', title: 'Run the test suite', kind: 'test', plannedSteps: 1 }, + fix: { taskId: 'T4', title: 'Fix the failing tests', kind: 'fix', plannedSteps: 2 }, +} as const; + +type TaskDef = (typeof TASKS)[keyof typeof TASKS]; + +interface ScriptConfig { + /** Workspace file the Builder implements. */ + target: string; + /** Mission-relative files holding the Builder's first implementation and its fix. */ + implementation: string; + fix: string; + testCommand: string[]; +} + +function readScriptConfig(mission: Mission): ScriptConfig { + const s = mission.raw.script as Partial | undefined; + const ok = + s && + typeof s.target === 'string' && + typeof s.implementation === 'string' && + typeof s.fix === 'string' && + Array.isArray(s.testCommand) && + s.testCommand.every((a) => typeof a === 'string'); + if (!ok) throw new Error('mission.json: "script" needs target, implementation, fix and testCommand'); + return s as ScriptConfig; +} + +/** A step the mission cannot recover from: the task fails and the run ends as failed. */ +class TaskFailure extends Error { + constructor( + readonly taskId: string, + readonly agentId: string, + readonly reason: string, + readonly errorType?: string, + ) { + super(reason); + } +} + +export const scriptedCodingMission: AgentAdapter = { + info: { + id: 'scripted-coding-mission', + kind: 'scripted', + label: 'Scripted adapter — deterministic decisions, real tools', + }, + + async run(ctx: RunContext, mission: Mission): Promise { + const cfg = readScriptConfig(mission); + const source = (rel: string) => readFileSync(resolveInside(mission.dir, rel), 'utf8'); + const { coordinator, researcher, builder, tester } = AGENTS; + let msgN = 0; + + const say = async ( + waitMs: number, + type: T, + payload: PayloadOf, + meta?: { actorId?: string }, + ) => { + await ctx.pace(waitMs); + return ctx.emit(type, payload, meta); + }; + const working = async (agentId: string, reason?: string) => { + await say(200, 'agent.state_changed', { agentId, state: 'working', ...(reason ? { reason } : {}) }); + await ctx.pace(PACE.working); + }; + const idle = (agentId: string) => say(100, 'agent.state_changed', { agentId, state: 'idle' }); + const create = (t: TaskDef, dependsOn: string[]) => + say( + 300, + 'task.created', + { taskId: t.taskId, title: t.title, kind: t.kind, dependsOn, plannedSteps: t.plannedSteps }, + { actorId: coordinator.agentId }, + ); + const assignAndStart = async (taskId: string, agentId: string) => { + await say(PACE.step, 'task.assigned', { taskId, agentId }, { actorId: coordinator.agentId }); + await say(100, 'task.started', { taskId, agentId }); + }; + const message = (fromAgentId: string, to: string, text: string) => + say(PACE.step, 'agent.message_sent', { messageId: `msg-${++msgN}`, fromAgentId, to, text }); + const complete = (taskId: string, summary: string, artifactIds: string[]) => + say(PACE.step, 'task.completed', { taskId, summary, artifactIds }); + const tool = async (agentId: string, taskId: string, call: ToolCall): Promise => { + await ctx.pace(PACE.step); + return ctx.tools.invoke(agentId, taskId, call); + }; + /** A tool that must succeed for the task to continue. */ + const must = async (agentId: string, taskId: string, call: ToolCall): Promise => { + const r = await tool(agentId, taskId, call); + if (r.status !== 'success') throw new TaskFailure(taskId, agentId, `${call.tool} failed: ${r.outputSummary}`, r.errorType); + return r; + }; + const testsPassed = async (run: ToolResult): Promise => { + await complete( + TASKS.test.taskId, + run.metrics ? `All ${run.metrics.tests} tests pass` : 'The test command passed', + run.artifactIds, + ); + await idle(tester.agentId); + await ctx.pace(PACE.step); + const counts = run.metrics ? `; ${run.metrics.passed}/${run.metrics.tests} tests pass` : ''; + return { outcome: 'succeeded', summary: `All tasks completed${counts}` }; + }; + + try { + // ── Setup ─────────────────────────────────────────────────── + await say(100, 'agent.registered', { + agentId: coordinator.agentId, + name: coordinator.name, + role: coordinator.role, + model: null, + tools: [], + embodied: false, + }); + for (const a of [researcher, builder, tester]) { + await say(100, 'agent.registered', { + agentId: a.agentId, + name: a.name, + role: a.role, + model: null, + tools: [...a.tools], + embodied: true, + characterPreset: a.preset, + }); + } + await create(TASKS.research, []); + await create(TASKS.code, [TASKS.research.taskId]); + await create(TASKS.test, [TASKS.code.taskId]); + + // ── T1 research: what does the test suite expect? ────────── + const T1 = TASKS.research.taskId; + await assignAndStart(T1, researcher.agentId); + await working(researcher.agentId); + const listing = await must(researcher.agentId, T1, { tool: 'list_files', path: '.' }); + await must(researcher.agentId, T1, { tool: 'read_file', path: 'README.md' }); + const testFile = (listing.output ?? '').split('\n').find((f) => /\.test\.[cm]?js$/.test(f)); + if (!testFile) throw new TaskFailure(T1, researcher.agentId, 'No test file found in the repository'); + const suite = (await must(researcher.agentId, T1, { tool: 'read_file', path: testFile })).output ?? ''; + const testCount = suite.match(/^\s*test\(/gm)?.length ?? 0; + const methods = [...new Set([...suite.matchAll(/store\.(\w+)\(/g)].map((m) => m[1]!))]; + await message( + researcher.agentId, + builder.agentId, + `${cfg.target} needs ${methods.join('/') || 'an implementation'}; ${testCount} tests in ${testFile}`, + ); + await complete(T1, `Mapped the API surface and the ${testCount} tests`, []); + await idle(researcher.agentId); + + // ── T2 code ──────────────────────────────────────────────── + const T2 = TASKS.code.taskId; + await assignAndStart(T2, builder.agentId); + await working(builder.agentId); + await must(builder.agentId, T2, { tool: 'read_file', path: cfg.target }); + const written = await must(builder.agentId, T2, { + tool: 'write_file', + path: cfg.target, + content: source(cfg.implementation), + summary: `First implementation of ${cfg.target}`, + }); + await complete(T2, `Implemented ${cfg.target}`, written.artifactIds); + await idle(builder.agentId); + + // ── T3 test (attempt 1) ───────────────────────────────────── + const T3 = TASKS.test.taskId; + await assignAndStart(T3, tester.agentId); + await working(tester.agentId); + const first = await tool(tester.agentId, T3, { tool: 'run_command', argv: cfg.testCommand }); + if (first.status === 'failed') { + throw new TaskFailure(T3, tester.agentId, `The test command could not run: ${first.outputSummary}`, first.errorType); + } + if (first.status === 'success') return await testsPassed(first); + + // Real failure → block T3 on a new fix task. + const failing = first.failingTests ?? []; + const T4 = TASKS.fix.taskId; + await create(TASKS.fix, []); + await say(100, 'task.blocked', { + taskId: T3, + reason: first.metrics ? `${first.metrics.failed} of ${first.metrics.tests} tests failing` : `Tests failed (${first.outputSummary})`, + blockedBy: [T4], + }); + await say(100, 'agent.state_changed', { agentId: tester.agentId, state: 'waiting', reason: 'waiting for a fix' }); + await message( + tester.agentId, + builder.agentId, + failing.length ? `Failing: ${failing.join('; ')}` : `Tests failed: ${first.outputSummary}`, + ); + + // ── T4 fix ───────────────────────────────────────────────── + await assignAndStart(T4, builder.agentId); + await working(builder.agentId); + await must(builder.agentId, T4, { tool: 'read_file', path: cfg.target }); + const fixed = await must(builder.agentId, T4, { + tool: 'write_file', + path: cfg.target, + content: source(cfg.fix), + summary: failing.length ? `Fix for: ${failing.join('; ')}` : `Fix for the failing tests`, + }); + await complete(T4, `Patched ${cfg.target} for ${failing.length || 'the'} failing test${failing.length === 1 ? '' : 's'}`, fixed.artifactIds); + await idle(builder.agentId); + + // ── T3 retry (attempt 2) ──────────────────────────────────── + await say(PACE.step, 'task.started', { taskId: T3, agentId: tester.agentId }); + await working(tester.agentId, 'retrying after fix'); + const rerun = await tool(tester.agentId, T3, { tool: 'run_command', argv: cfg.testCommand }); + if (rerun.status === 'success') return await testsPassed(rerun); + throw new TaskFailure( + T3, + tester.agentId, + rerun.status === 'failed' + ? `The test command could not run: ${rerun.outputSummary}` + : `Tests still failing after the fix: ${rerun.outputSummary}`, + rerun.errorType, + ); + } catch (err) { + if (!(err instanceof TaskFailure)) throw err; + await say(PACE.step, 'task.failed', { + taskId: err.taskId, + reason: err.reason, + ...(err.errorType ? { errorType: err.errorType } : {}), + }); + await say(100, 'agent.state_changed', { agentId: err.agentId, state: 'failed', reason: err.reason }); + await ctx.pace(PACE.step); + return { outcome: 'failed', summary: err.reason }; + } + }, +}; diff --git a/packages/runtime/src/bin.ts b/packages/runtime/src/bin.ts new file mode 100644 index 0000000..7b3fc75 --- /dev/null +++ b/packages/runtime/src/bin.ts @@ -0,0 +1,13 @@ +// Entry point: `node --disable-warning=ExperimentalWarning --import tsx src/bin.ts `. +// (The flag hides node:sqlite's ExperimentalWarning on Node 22.) +import { main } from './cli'; + +main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + (err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }, +); diff --git a/packages/runtime/src/bus.ts b/packages/runtime/src/bus.ts new file mode 100644 index 0000000..029d2f6 --- /dev/null +++ b/packages/runtime/src/bus.ts @@ -0,0 +1,57 @@ +import type { RunEvent } from '@runloop-world/events'; + +export type Listener = (event: RunEvent) => void; +export type Unsubscribe = () => void; + +/** + * In-process fan-out of recorded events. Delivery is synchronous and in + * publish order, even when a listener publishes from inside its callback + * (those events are queued until the current one reached every listener). + * Listener exceptions are caught and reported, never propagated. + */ +export class EventBus { + private readonly byRun = new Map>(); + private readonly any = new Set(); + private readonly queue: RunEvent[] = []; + private delivering = false; + + constructor(private readonly onListenerError: (err: unknown) => void = (err) => console.error('[bus] listener error', err)) {} + + subscribe(runId: string, fn: Listener): Unsubscribe { + let set = this.byRun.get(runId); + if (!set) this.byRun.set(runId, (set = new Set())); + set.add(fn); + return () => { + set.delete(fn); + if (set.size === 0 && this.byRun.get(runId) === set) this.byRun.delete(runId); + }; + } + + /** Listen to every run (used by the CLI trace printer). */ + subscribeAll(fn: Listener): Unsubscribe { + this.any.add(fn); + return () => this.any.delete(fn); + } + + publish(event: RunEvent): void { + this.queue.push(event); + if (this.delivering) return; + this.delivering = true; + try { + for (let e = this.queue.shift(); e; e = this.queue.shift()) this.deliver(e); + } finally { + this.delivering = false; + } + } + + private deliver(e: RunEvent): void { + const listeners = [...(this.byRun.get(e.runId) ?? []), ...this.any]; + for (const fn of listeners) { + try { + fn(e); + } catch (err) { + this.onListenerError(err); + } + } + } +} diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts new file mode 100644 index 0000000..10929dd --- /dev/null +++ b/packages/runtime/src/cli.ts @@ -0,0 +1,144 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { serve } from '@hono/node-server'; +import { DEFAULT_RUNTIME_HOST, DEFAULT_RUNTIME_PORT } from '@runloop-world/events'; +import { findRepoRoot, resolveDataDir } from './config'; +import { createRuntime, type Runtime } from './runtime'; +import { TracePrinter } from './trace-printer'; + +const USAGE = `Usage: + runloop serve [--port ${DEFAULT_RUNTIME_PORT}] [--host ${DEFAULT_RUNTIME_HOST}] + runloop demo [--speed 1] [--keep] [--port ${DEFAULT_RUNTIME_PORT}] [--host ${DEFAULT_RUNTIME_HOST}] + +serve Start the local runtime API (Studio talks to it). +demo Run the coding mission headless and print one trace line per event. + Also serves the API while it runs; exits after run.completed unless --keep. + +Environment: + RUNLOOP_DATA_DIR data directory (default /.runloop) + RUNLOOP_ALLOWED_ORIGINS extra comma-separated browser origins`; + +/** + * One runtime process per data dir: boot recovery would otherwise mark + * another live process's active run as interrupted. + */ +function acquireLock(dataDir: string): () => void { + const file = path.join(dataDir, 'runtime.pid'); + try { + const pid = Number(readFileSync(file, 'utf8').trim()); + if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) { + try { + process.kill(pid, 0); + throw new Error( + `Another runtime (pid ${pid}) is using ${dataDir}. Stop it, or set RUNLOOP_DATA_DIR to another directory.`, + ); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') throw err; + } + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + writeFileSync(file, String(process.pid)); + return () => rmSync(file, { force: true }); +} + +function listen(rt: Runtime, host: string, port: number): Promise<{ close(): Promise; url: string }> { + return new Promise((resolve, reject) => { + const server = serve({ fetch: rt.app.fetch, hostname: host, port }, (info) => { + resolve({ + url: `http://${host}:${info.port}`, + close: () => + new Promise((r) => { + if ('closeAllConnections' in server) server.closeAllConnections(); + server.close(() => r()); + }), + }); + }); + server.once('error', reject); + }); +} + +function parsePort(value: string | undefined): number { + const port = Number(value ?? DEFAULT_RUNTIME_PORT); + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid --port: ${value}`); + return port; +} + +export async function main(argv: string[]): Promise { + const [command, ...rest] = argv; + if (!command || command === '--help' || command === '-h' || command === 'help') { + console.log(USAGE); + return command ? 0 : 1; + } + if (command !== 'serve' && command !== 'demo') { + console.error(`Unknown command: ${command}\n\n${USAGE}`); + return 1; + } + const { values } = parseArgs({ + args: rest.filter((a) => a !== '--'), + options: { + port: { type: 'string' }, + host: { type: 'string' }, + speed: { type: 'string' }, + keep: { type: 'boolean', default: false }, + }, + strict: true, + }); + const port = parsePort(values.port); + const host = values.host ?? DEFAULT_RUNTIME_HOST; + + const repoRoot = findRepoRoot(); + const dataDir = resolveDataDir(process.env, repoRoot); + mkdirSync(dataDir, { recursive: true }); + const releaseLock = acquireLock(dataDir); + const rt = createRuntime({ repoRoot, dataDir }); + for (const runId of rt.recovered) console.log(`[runtime] run ${runId} was left running by a previous process; marked interrupted`); + + let server: { close(): Promise; url: string } | null = null; + let shuttingDown = false; + const shutdown = async (code: number) => { + if (shuttingDown) return; + shuttingDown = true; + await rt.close(); + await server?.close(); + releaseLock(); + process.exit(code); + }; + process.once('SIGINT', () => void shutdown(130)); + process.once('SIGTERM', () => void shutdown(143)); + + try { + server = await listen(rt, host, port); + } catch (err) { + if (command === 'serve') { + console.error(`[runtime] could not listen on ${host}:${port}: ${(err as Error).message}`); + await shutdown(1); + return 1; + } + console.warn(`[runtime] API not served (${(err as Error).message}); continuing headless`); + } + + if (command === 'serve') { + console.log(`Runloop runtime ${server!.url} (data: ${dataDir})`); + return new Promise(() => {}); // until a signal arrives + } + + // demo + const speed = Number(values.speed ?? 1); + if (!Number.isFinite(speed) || speed < 0) throw new Error(`Invalid --speed: ${values.speed}`); + // This process runs a single run, so every recorded event belongs to it. + const printer = new TracePrinter(); + rt.recorder.bus.subscribeAll((e) => console.log(printer.format(e))); + console.log(`# Runloop demo · speed ${speed} · data ${dataDir}${server ? ` · API ${server.url}` : ''}`); + const { runId } = rt.manager.start({ missionId: 'coding-mission', speed }); + + const summary = await rt.manager.whenDone(runId); + if (values.keep && server) { + console.log(`# run finished (${summary.status}); still serving ${server.url}. Ctrl-C to exit.`); + return new Promise(() => {}); + } + await shutdown(summary.status === 'succeeded' ? 0 : 1); + return 0; +} diff --git a/packages/runtime/src/config.ts b/packages/runtime/src/config.ts new file mode 100644 index 0000000..6da93ad --- /dev/null +++ b/packages/runtime/src/config.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const RUNTIME_VERSION = '0.1.0'; + +/** Origins allowed to call the API from a browser (the studio dev server). */ +export const DEFAULT_ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000']; + +/** Env keys whose values are treated as secrets and redacted from events and artifacts. */ +const SECRET_KEY_RE = /KEY|TOKEN|SECRET|PASSWORD|AUTH|COOKIE|CREDENTIAL/i; + +/** Walk up from `start` to the directory that contains pnpm-workspace.yaml. */ +export function findRepoRoot(start: string = path.dirname(fileURLToPath(import.meta.url))): string { + let dir = path.resolve(start); + for (;;) { + if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) throw new Error(`Could not find the repository root (pnpm-workspace.yaml) above ${start}`); + dir = parent; + } +} + +/** `RUNLOOP_DATA_DIR`, or `/.runloop`. */ +export function resolveDataDir(env: NodeJS.ProcessEnv, repoRoot: string): string { + const fromEnv = env.RUNLOOP_DATA_DIR?.trim(); + return fromEnv ? path.resolve(fromEnv) : path.join(repoRoot, '.runloop'); +} + +/** Defaults plus a comma-separated `RUNLOOP_ALLOWED_ORIGINS`. */ +export function resolveAllowedOrigins(env: NodeJS.ProcessEnv): string[] { + const extra = (env.RUNLOOP_ALLOWED_ORIGINS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return [...new Set([...DEFAULT_ALLOWED_ORIGINS, ...extra])]; +} + +/** Values (≥ 8 chars) of env vars whose name looks like a credential. */ +export function collectEnvSecrets(env: NodeJS.ProcessEnv): string[] { + const out = new Set(); + for (const [key, value] of Object.entries(env)) { + if (value && value.length >= 8 && SECRET_KEY_RE.test(key)) out.add(value); + } + // Longest first so a secret that contains another is masked whole. + return [...out].sort((a, b) => b.length - a.length); +} diff --git a/packages/runtime/src/http/app.ts b/packages/runtime/src/http/app.ts new file mode 100644 index 0000000..9ac5ad1 --- /dev/null +++ b/packages/runtime/src/http/app.ts @@ -0,0 +1,207 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { + apiPaths, + type ErrorResponse, + type EventsPageResponse, + type HealthResponse, + type ListRunsResponse, + type RunDetailResponse, + type StartRunResponse, +} from '@runloop-world/events'; +import { parseWorldDefinition, type WorldDefinition } from '@runloop-world/world-engine'; +import { Hono, type Context } from 'hono'; +import { cors } from 'hono/cors'; +import { RUNTIME_VERSION } from '../config'; +import { MissionNotFoundError } from '../mission'; +import { UnknownRunError, type Recorder } from '../recorder'; +import { RunConflictError, RunNotActiveError, type RunManager } from '../run-manager'; +import { streamRunEvents } from './sse'; + +export interface AppOptions { + recorder: Recorder; + manager: RunManager; + /** Directory holding `/world.json` (the repo's `worlds/`). */ + worldsDir: string; + allowedOrigins: readonly string[]; + /** SSE keep-alive interval (tests shorten it). */ + pingMs?: number; +} + +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const SAFE_WORLD_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; +const MAX_PAGE = 1000; + +function hostnameOf(c: Context): string | null { + const host = c.req.header('host'); + try { + return host ? new URL(`http://${host}`).hostname : new URL(c.req.url).hostname; + } catch { + return null; + } +} + +/** Non-negative integer from a query/header value; undefined if absent, null if invalid. */ +function parseSeq(value: string | undefined): number | undefined | null { + if (value === undefined || value === '') return undefined; + return /^\d{1,15}$/.test(value) ? Number(value) : null; +} + +function errorBody(error: string): ErrorResponse { + return { error }; +} + +/** + * The local HTTP API (see packages/events/src/protocol.ts). Guards: the Host + * header must be localhost/127.0.0.1 (blocks DNS rebinding), POSTs must be + * JSON (forces a CORS preflight) and come from an allowed Origin when a + * browser sends one. + */ +export function createApp(opts: AppOptions): Hono { + const { recorder, manager, allowedOrigins } = opts; + const worlds = new Map(); + const loadWorld = (worldId: string): WorldDefinition => { + let w = worlds.get(worldId); + if (!w) { + if (!SAFE_WORLD_ID.test(worldId)) throw new Error(`Invalid world id: ${worldId}`); + const raw: unknown = JSON.parse(readFileSync(path.join(opts.worldsDir, worldId, 'world.json'), 'utf8')); + w = parseWorldDefinition(raw); + worlds.set(worldId, w); + } + return w; + }; + + const app = new Hono(); + + app.use('*', async (c, next) => { + const hostname = hostnameOf(c); + if (!hostname || !LOCAL_HOSTNAMES.has(hostname)) return c.json(errorBody('Host not allowed'), 403); + await next(); + }); + + app.use( + '/api/*', + cors({ + origin: [...allowedOrigins], + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Last-Event-ID'], + maxAge: 600, + }), + ); + + app.use('/api/*', async (c, next) => { + if (c.req.method === 'POST') { + const origin = c.req.header('origin'); + if (origin !== undefined && !allowedOrigins.includes(origin)) return c.json(errorBody('Origin not allowed'), 403); + const type = c.req.header('content-type') ?? ''; + if (!/^application\/json\s*(;|$)/i.test(type)) { + return c.json(errorBody('Content-Type must be application/json'), 415); + } + } + await next(); + }); + + app.get(apiPaths.health, (c) => { + const body: HealthResponse = { ok: true, version: RUNTIME_VERSION, activeRunId: manager.activeRunId }; + return c.json(body); + }); + + app.get(apiPaths.runs, (c) => { + const body: ListRunsResponse = { runs: recorder.listRuns() }; + return c.json(body); + }); + + app.post(apiPaths.runs, async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json(errorBody('Invalid JSON body'), 400); + } + const req = body as { missionId?: unknown; speed?: unknown } | null; + if (!req || typeof req !== 'object' || typeof req.missionId !== 'string' || req.missionId === '') { + return c.json(errorBody('missionId (string) is required'), 400); + } + if (req.speed !== undefined && (typeof req.speed !== 'number' || !Number.isFinite(req.speed) || req.speed < 0 || req.speed > 100)) { + return c.json(errorBody('speed must be a number between 0 and 100'), 400); + } + try { + const { runId } = manager.start({ missionId: req.missionId, ...(req.speed !== undefined ? { speed: req.speed } : {}) }); + const res: StartRunResponse = { runId }; + return c.json(res, 202); + } catch (err) { + if (err instanceof RunConflictError) return c.json(errorBody(err.message), 409); + if (err instanceof MissionNotFoundError) return c.json(errorBody(err.message), 404); + throw err; + } + }); + + app.get('/api/runs/:id', (c) => { + const run = recorder.getRun(c.req.param('id')); + if (!run) return c.json(errorBody('Run not found'), 404); + const body: RunDetailResponse = { run, world: loadWorld(run.worldId) }; + return c.json(body); + }); + + app.get('/api/runs/:id/events', (c) => { + const run = recorder.getRun(c.req.param('id')); + if (!run) return c.json(errorBody('Run not found'), 404); + const after = parseSeq(c.req.query('after')); + const limit = parseSeq(c.req.query('limit')); + if (after === null || limit === null || limit === 0) return c.json(errorBody('after and limit must be non-negative integers (limit ≥ 1)'), 400); + const events = recorder.list(run.runId, { after: after ?? 0, limit: Math.min(limit ?? 500, MAX_PAGE) }); + const nextAfter = events.at(-1)?.sequence ?? after ?? 0; + const body: EventsPageResponse = { events, nextAfter, hasMore: nextAfter < run.lastSequence }; + return c.json(body); + }); + + app.get('/api/runs/:id/stream', (c) => { + const run = recorder.getRun(c.req.param('id')); + if (!run) return c.json(errorBody('Run not found'), 404); + const header = c.req.header('last-event-id'); + const after = header !== undefined ? parseSeq(header) : parseSeq(c.req.query('after')); + if (after === null) return c.json(errorBody('Last-Event-ID / after must be a non-negative integer'), 400); + return streamRunEvents(c, { + recorder, + runId: run.runId, + after: after ?? 0, + ...(opts.pingMs !== undefined ? { pingMs: opts.pingMs } : {}), + }); + }); + + const control = (action: 'stop' | 'pause' | 'resume') => (c: Context) => { + const runId = c.req.param('id')!; + try { + const run = manager[action](runId); + return c.json({ run }, 202); + } catch (err) { + if (err instanceof UnknownRunError) return c.json(errorBody('Run not found'), 404); + if (err instanceof RunNotActiveError) return c.json(errorBody(err.message), 409); + throw err; + } + }; + app.post('/api/runs/:id/stop', control('stop')); + app.post('/api/runs/:id/pause', control('pause')); + app.post('/api/runs/:id/resume', control('resume')); + + app.get('/api/runs/:id/artifacts/:aid', (c) => { + const artifact = recorder.getArtifact(c.req.param('id'), c.req.param('aid')); + if (!artifact) return c.json(errorBody('Artifact not found'), 404); + const textual = /^text\/|^application\/json/.test(artifact.mediaType); + return c.body(new Uint8Array(artifact.content), 200, { + 'Content-Type': textual ? `${artifact.mediaType}; charset=utf-8` : artifact.mediaType, + 'Content-Disposition': `inline; filename="${artifact.name.replace(/[^\w.-]/g, '_')}"`, + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'", + 'Cache-Control': 'no-cache', + }); + }); + + app.notFound((c) => c.json(errorBody('Not found'), 404)); + app.onError((err, c) => { + console.error('[runtime] request failed:', err); + return c.json(errorBody(err instanceof Error ? err.message : 'Internal error'), 500); + }); + + return app; +} diff --git a/packages/runtime/src/http/sse.ts b/packages/runtime/src/http/sse.ts new file mode 100644 index 0000000..4639290 --- /dev/null +++ b/packages/runtime/src/http/sse.ts @@ -0,0 +1,92 @@ +import type { RunEvent, StreamEndData } from '@runloop-world/events'; +import { SSE_END_EVENT } from '@runloop-world/events'; +import type { Context } from 'hono'; +import { streamSSE } from 'hono/streaming'; +import type { Recorder } from '../recorder'; + +export const DEFAULT_PING_MS = 15_000; +const PAGE = 500; + +/** + * Stream a run's events as SSE, resuming after sequence `after`. + * + * Subscribe first, then backfill from the DB, then drain live events, so an + * event appended during the backfill is neither lost nor sent twice (anything + * ≤ the last sent sequence is dropped; a gap triggers another backfill). + * Each frame is a default `message` with `id: `. Once the run is + * terminal and everything was sent, an `end` event closes the stream. + */ +export function streamRunEvents( + c: Context, + opts: { recorder: Recorder; runId: string; after: number; pingMs?: number }, +): Response { + const { recorder, runId } = opts; + const pingMs = opts.pingMs ?? DEFAULT_PING_MS; + + return streamSSE(c, async (stream) => { + const queue: RunEvent[] = []; + let wake: (() => void) | null = null; + const notify = () => { + const w = wake; + wake = null; + w?.(); + }; + const unsubscribe = recorder.subscribe(runId, (e) => { + queue.push(e); + notify(); + }); + stream.onAbort(notify); + c.req.raw.signal?.addEventListener('abort', () => stream.abort(), { once: true }); + + let lastSent = opts.after; + const send = async (e: RunEvent) => { + await stream.writeSSE({ id: String(e.sequence), data: JSON.stringify(e) }); + lastSent = e.sequence; + }; + const backfill = async () => { + for (;;) { + const page = recorder.list(runId, { after: lastSent, limit: PAGE }); + for (const e of page) { + if (stream.aborted) return; + await send(e); + } + if (page.length < PAGE) return; + } + }; + + try { + await stream.write('retry: 2000\n\n'); + await backfill(); + while (!stream.aborted) { + while (queue.length > 0 && !stream.aborted) { + const e = queue.shift()!; + if (e.sequence <= lastSent) continue; + if (e.sequence > lastSent + 1) await backfill(); + if (e.sequence > lastSent) await send(e); + } + if (stream.aborted) break; + const run = recorder.getRun(runId); + if (!run) break; + if (run.status !== 'running' && lastSent >= run.lastSequence) { + const end: StreamEndData = { status: run.status, lastSequence: run.lastSequence }; + await stream.writeSSE({ event: SSE_END_EVENT, data: JSON.stringify(end) }); + break; + } + if (queue.length > 0) continue; + const timedOut = await new Promise((resolve) => { + const timer = setTimeout(() => { + wake = null; + resolve(true); + }, pingMs); + wake = () => { + clearTimeout(timer); + resolve(false); + }; + }); + if (timedOut && !stream.aborted) await stream.write(': ping\n\n'); + } + } finally { + unsubscribe(); + } + }); +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index cb0ff5c..579b9a9 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1 +1,17 @@ -export {}; +export * from './adapter'; +export * from './bus'; +export * from './config'; +export * from './mission'; +export * from './recorder'; +export * from './run-manager'; +export * from './runtime'; +export * from './tool-invoker'; +export * from './trace-printer'; +export { createApp, type AppOptions } from './http/app'; +export { streamRunEvents } from './http/sse'; +export { openDb, migrate, tx, type Db } from './store/db'; +export { SandboxError, resolveInside } from './sandbox/paths'; +export { ALLOWED_COMMANDS, isAllowedCommand, minimalEnv, runCommand, type CommandResult } from './sandbox/command'; +export { MAX_FILE_BYTES, MAX_LIST_ENTRIES, listFiles, readWorkspaceFile, writeWorkspaceFile } from './sandbox/fs-tools'; +export { parseTap, formatTestSummary, type TapSummary } from './sandbox/tap'; +export { scriptedCodingMission } from './adapters/scripted-coding-mission'; diff --git a/packages/runtime/src/mission.ts b/packages/runtime/src/mission.ts new file mode 100644 index 0000000..0b021d8 --- /dev/null +++ b/packages/runtime/src/mission.ts @@ -0,0 +1,49 @@ +import { readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +/** A mission loaded from `//mission.json` (data, read by path). */ +export interface Mission { + id: string; + title: string; + worldId: string; + seed: number; + /** Adapter id that runs this mission. */ + adapter: string; + /** Absolute mission directory. Adapters may read their own files from here. */ + dir: string; + /** Absolute path of the repo copied into each run's workspace. */ + repoDir: string; + /** The full mission.json, for adapter-specific settings. */ + raw: Record; +} + +export class MissionNotFoundError extends Error {} + +const MISSION_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; +const SAFE_WORLD_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; + +export function loadMission(missionsDir: string, missionId: string): Mission { + if (!MISSION_ID.test(missionId)) throw new MissionNotFoundError(`Unknown mission: ${missionId}`); + const dir = path.join(missionsDir, missionId); + let raw: Record; + try { + raw = JSON.parse(readFileSync(path.join(dir, 'mission.json'), 'utf8')) as Record; + } catch { + throw new MissionNotFoundError(`Unknown mission: ${missionId}`); + } + const str = (key: string): string => { + const v = raw[key]; + if (typeof v !== 'string' || v.length === 0) throw new Error(`mission.json: "${key}" must be a non-empty string`); + return v; + }; + if (raw.id !== missionId) throw new Error(`mission.json: id "${String(raw.id)}" does not match directory "${missionId}"`); + const worldId = str('worldId'); + if (!SAFE_WORLD_ID.test(worldId)) throw new Error(`mission.json: invalid worldId "${worldId}"`); + const seed = raw.seed ?? 0; + if (typeof seed !== 'number' || !Number.isInteger(seed)) throw new Error('mission.json: "seed" must be an integer'); + const repoDir = path.resolve(dir, typeof raw.repo === 'string' ? raw.repo : 'repo'); + if (!repoDir.startsWith(dir + path.sep) || !statSync(repoDir, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`mission.json: "repo" must be a directory inside ${dir}`); + } + return { id: missionId, title: str('title'), worldId, seed, adapter: str('adapter'), dir, repoDir, raw }; +} diff --git a/packages/runtime/src/recorder.ts b/packages/runtime/src/recorder.ts new file mode 100644 index 0000000..5c4bd2f --- /dev/null +++ b/packages/runtime/src/recorder.ts @@ -0,0 +1,366 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { + SCHEMA_VERSION, + deriveRefs, + parseKnownEvent, + redactEvent, + redactText, + type EventDraft, + type EventOf, + type PayloadOf, + type RunEvent, + type RunStatus, + type RunSummary, +} from '@runloop-world/events'; +import { EventBus, type Listener, type Unsubscribe } from './bus'; +import { tx, type Db } from './store/db'; + +export type RunManifest = PayloadOf<'run.started'>; + +export interface RecorderOptions { + db: Db; + /** Root of the runtime data dir; artifact bytes go to `/artifacts//`. */ + dataDir: string; + bus?: EventBus; + /** Literal secret values to mask everywhere (see collectEnvSecrets). */ + secrets?: readonly string[]; + now?: () => Date; + newId?: () => string; +} + +export interface StoredArtifact { + runId: string; + artifactId: string; + kind: string; + name: string; + mediaType: string; + bytes: number; + sha256: string; + content: Buffer; +} + +export class UnknownRunError extends Error { + constructor(readonly runId: string) { + super(`Unknown run: ${runId}`); + } +} + +export class RunClosedError extends Error { + constructor(readonly runId: string, status: RunStatus) { + super(`Run ${runId} is already ${status}; no more events can be appended`); + } +} + +/** The draft did not produce a valid known event. No sequence number was consumed. */ +export class EventValidationError extends Error {} + +interface RunRow { + run_id: string; + mission_id: string; + world_id: string; + title: string; + adapter_label: string; + status: RunStatus; + paused: number; + created_at: string; + ended_at: string | null; + last_sequence: number; +} + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function toSummary(r: RunRow): RunSummary { + return { + runId: r.run_id, + title: r.title, + missionId: r.mission_id, + worldId: r.world_id, + status: r.status, + createdAt: r.created_at, + endedAt: r.ended_at, + lastSequence: r.last_sequence, + adapterLabel: r.adapter_label, + paused: r.paused === 1, + }; +} + +/** + * The single writer of the event log. `append` is synchronous and always: + * redact → assign the next sequence → validate (strict) → persist in one + * transaction → publish. Nothing is published that is not already durable. + */ +export class Recorder { + readonly bus: EventBus; + private readonly db: Db; + private readonly dataDir: string; + private readonly secrets: readonly string[]; + private readonly now: () => Date; + private readonly newId: () => string; + /** Cached per-run write state; the DB is the source of truth on a cache miss. */ + private readonly runs = new Map(); + + constructor(opts: RecorderOptions) { + this.db = opts.db; + this.dataDir = opts.dataDir; + this.bus = opts.bus ?? new EventBus(); + this.secrets = opts.secrets ?? []; + this.now = opts.now ?? (() => new Date()); + this.newId = opts.newId ?? randomUUID; + } + + /** Mask secrets in arbitrary text (used for artifact bodies and error messages). */ + redact(text: string): string { + return redactText(text, { secrets: this.secrets }); + } + + /** Create the run row and its `run.started` event (sequence 1) in one transaction. */ + createRun(input: { runId?: string; manifest: RunManifest }): EventOf<'run.started'> { + const runId = input.runId ?? this.newId(); + if (this.findRow(runId)) throw new Error(`Run ${runId} already exists`); + const event = this.build(runId, 1, { type: 'run.started', payload: input.manifest, actorId: 'system' }); + if (event.type !== 'run.started') throw new EventValidationError('expected run.started'); + tx(this.db, () => { + this.db + .prepare( + `INSERT INTO runs (run_id, mission_id, world_id, title, adapter_label, status, paused, manifest_json, created_at, last_sequence) + VALUES (?, ?, ?, ?, ?, 'running', 0, ?, ?, 0)`, + ) + .run( + runId, + event.payload.missionId, + event.payload.worldId, + event.payload.title, + event.payload.adapter.label, + JSON.stringify(event.payload), + event.timestamp, + ); + this.persist(event); + }); + this.runs.set(runId, { lastSequence: 1, status: 'running' }); + this.bus.publish(event); + return event; + } + + append(runId: string, draft: EventDraft): EventOf; + append(runId: string, draft: EventDraft): RunEvent { + if (draft.type === 'run.started') throw new EventValidationError('run.started is only emitted by createRun()'); + const state = this.state(runId); + if (state.status !== 'running') throw new RunClosedError(runId, state.status); + const event = this.build(runId, state.lastSequence + 1, draft); + tx(this.db, () => this.persist(event)); + state.lastSequence = event.sequence; + if (event.type === 'run.completed') state.status = event.payload.outcome; + this.bus.publish(event); + return event; + } + + list(runId: string, opts: { after?: number; limit?: number } = {}): RunEvent[] { + const rows = this.db + .prepare('SELECT envelope_json FROM events WHERE run_id = ? AND sequence > ? ORDER BY sequence LIMIT ?') + .all(runId, opts.after ?? 0, opts.limit ?? 500) as { envelope_json: string }[]; + return rows.map((r) => JSON.parse(r.envelope_json) as RunEvent); + } + + /** Every event of a run, in order. */ + listAll(runId: string): RunEvent[] { + const out: RunEvent[] = []; + for (;;) { + const page = this.list(runId, { after: out.at(-1)?.sequence ?? 0, limit: 1000 }); + out.push(...page); + if (page.length < 1000) return out; + } + } + + getRun(runId: string): RunSummary | null { + const row = this.findRow(runId); + return row ? toSummary(row) : null; + } + + listRuns(): RunSummary[] { + const rows = this.db.prepare('SELECT * FROM runs ORDER BY created_at DESC, rowid DESC').all() as unknown as RunRow[]; + return rows.map(toSummary); + } + + subscribe(runId: string, fn: Listener): Unsubscribe { + return this.bus.subscribe(runId, fn); + } + + /** + * Store artifact bytes (text is redacted first) before the matching + * `artifact.created` event is appended. Returns the size and hash to put in it. + */ + putArtifactContent(runId: string, artifactId: string, content: string | Uint8Array): { bytes: number; sha256: string } { + this.state(runId); + const file = this.artifactPath(runId, artifactId); + const buf = typeof content === 'string' ? Buffer.from(this.redact(content), 'utf8') : Buffer.from(content); + mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp-${process.pid}`; + writeFileSync(tmp, buf); + renameSync(tmp, file); + return { bytes: buf.byteLength, sha256: createHash('sha256').update(buf).digest('hex') }; + } + + getArtifact(runId: string, artifactId: string): StoredArtifact | null { + if (!SAFE_ID.test(artifactId)) return null; + const row = this.db + .prepare('SELECT kind, name, media_type, byte_size, sha256 FROM artifacts WHERE run_id = ? AND artifact_id = ?') + .get(runId, artifactId) as + | { kind: string; name: string; media_type: string; byte_size: number; sha256: string } + | undefined; + if (!row) return null; + let content: Buffer; + try { + content = readFileSync(this.artifactPath(runId, artifactId)); + } catch { + return null; + } + return { + runId, + artifactId, + kind: row.kind, + name: row.name, + mediaType: row.media_type, + bytes: row.byte_size, + sha256: row.sha256, + content, + }; + } + + /** + * Boot recovery: any run still marked `running` belonged to a runtime that + * died. Each tool call without a terminal event gets + * `tool.call_failed{runtime_restarted}`, then the run gets + * `run.completed{interrupted}`. Returns the recovered run ids. + */ + recoverInterrupted(): string[] { + const rows = this.db.prepare(`SELECT run_id FROM runs WHERE status = 'running' ORDER BY created_at`).all() as { + run_id: string; + }[]; + for (const { run_id: runId } of rows) { + this.runs.delete(runId); + const open = new Map>(); + for (const e of this.listAll(runId)) { + if (e.type === 'tool.call_started') open.set(e.payload.callId, e); + else if (e.type === 'tool.call_completed' || e.type === 'tool.call_failed') open.delete(e.payload.callId); + } + const nowMs = this.now().getTime(); + for (const started of open.values()) { + this.append(runId, { + type: 'tool.call_failed', + payload: { + callId: started.payload.callId, + errorType: 'runtime_restarted', + message: 'The runtime stopped before this call finished', + durationMs: Math.max(0, nowMs - Date.parse(started.timestamp)), + }, + actorId: started.payload.agentId, + ...(started.payload.taskId ? { taskId: started.payload.taskId } : {}), + parentEventId: started.eventId, + }); + } + this.append(runId, { + type: 'run.completed', + payload: { outcome: 'interrupted', summary: 'The runtime stopped while this run was active' }, + actorId: 'system', + }); + } + return rows.map((r) => r.run_id); + } + + // ── internals ─────────────────────────────────────────────────── + + private build(runId: string, sequence: number, draft: EventDraft): RunEvent { + const { event: d } = redactEvent(draft, { secrets: this.secrets }); + const raw = { + eventId: this.newId(), + runId, + sequence, + timestamp: this.now().toISOString(), + type: d.type, + schemaVersion: SCHEMA_VERSION, + ...deriveRefs(d), + ...(d.actorId !== undefined ? { actorId: d.actorId } : {}), + ...(d.taskId !== undefined ? { taskId: d.taskId } : {}), + ...(d.parentEventId !== undefined ? { parentEventId: d.parentEventId } : {}), + visibility: d.visibility ?? 'public', + payload: d.payload, + }; + const parsed = parseKnownEvent(raw); + if (!parsed.ok) throw new EventValidationError(`Invalid ${draft.type} event: ${parsed.error}`); + return parsed.event; + } + + /** Insert the event and apply its effect on the run row. Call inside tx(). */ + private persist(e: RunEvent): void { + this.db + .prepare( + `INSERT INTO events (run_id, sequence, event_id, type, timestamp, actor_id, task_id, parent_event_id, visibility, envelope_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + e.runId, + e.sequence, + e.eventId, + e.type, + e.timestamp, + e.actorId ?? null, + e.taskId ?? null, + e.parentEventId ?? null, + e.visibility, + JSON.stringify(e), + ); + this.db.prepare('UPDATE runs SET last_sequence = ? WHERE run_id = ?').run(e.sequence, e.runId); + switch (e.type) { + case 'run.completed': + this.db + .prepare('UPDATE runs SET status = ?, ended_at = ?, paused = 0 WHERE run_id = ?') + .run(e.payload.outcome, e.timestamp, e.runId); + break; + case 'run.paused': + case 'run.resumed': + this.db.prepare('UPDATE runs SET paused = ? WHERE run_id = ?').run(e.type === 'run.paused' ? 1 : 0, e.runId); + break; + case 'artifact.created': + this.db + .prepare( + `INSERT INTO artifacts (run_id, artifact_id, kind, name, media_type, byte_size, sha256, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + e.runId, + e.payload.artifactId, + e.payload.kind, + e.payload.name, + e.payload.mediaType, + e.payload.bytes, + e.payload.sha256, + e.timestamp, + ); + break; + default: + break; + } + } + + private findRow(runId: string): RunRow | undefined { + return this.db.prepare('SELECT * FROM runs WHERE run_id = ?').get(runId) as RunRow | undefined; + } + + private state(runId: string): { lastSequence: number; status: RunStatus } { + let s = this.runs.get(runId); + if (!s) { + const row = this.findRow(runId); + if (!row) throw new UnknownRunError(runId); + s = { lastSequence: row.last_sequence, status: row.status }; + this.runs.set(runId, s); + } + return s; + } + + private artifactPath(runId: string, artifactId: string): string { + if (!SAFE_ID.test(runId) || !SAFE_ID.test(artifactId)) throw new Error(`Unsafe artifact id: ${artifactId}`); + return path.join(this.dataDir, 'artifacts', runId, artifactId); + } +} diff --git a/packages/runtime/src/run-manager.ts b/packages/runtime/src/run-manager.ts new file mode 100644 index 0000000..dda44d8 --- /dev/null +++ b/packages/runtime/src/run-manager.ts @@ -0,0 +1,268 @@ +import { randomUUID } from 'node:crypto'; +import { cpSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import type { RunSummary } from '@runloop-world/events'; +import { + abortError, + isAdapterEventType, + type AdapterResult, + type AgentAdapter, + type RunContext, +} from './adapter'; +import { scriptedCodingMission } from './adapters/scripted-coding-mission'; +import { loadMission, type Mission } from './mission'; +import { UnknownRunError, type Recorder } from './recorder'; +import { ToolInvoker } from './tool-invoker'; + +export interface RunManagerOptions { + recorder: Recorder; + dataDir: string; + /** Directory holding `/mission.json` (the repo's `examples/`). */ + missionsDir: string; + /** Adapters by id; defaults to the scripted coding-mission adapter. */ + adapters?: readonly AgentAdapter[]; + commandTimeoutMs?: number; + onError?: (err: unknown) => void; +} + +export interface StartRunOptions { + missionId: string; + /** Pacing multiplier; 0 = no delays. Default 1. */ + speed?: number; +} + +/** Another run is active (HTTP 409). */ +export class RunConflictError extends Error { + constructor(readonly activeRunId: string) { + super(`Run ${activeRunId} is still active`); + } +} + +/** The run exists but is not the active run (HTTP 409). */ +export class RunNotActiveError extends Error { + constructor(readonly runId: string) { + super(`Run ${runId} is not active`); + } +} + +/** Resolves waiters when resumed; rejects them when the run is stopped. */ +class PauseGate { + paused = false; + private waiters: (() => void)[] = []; + + wait(signal: AbortSignal): Promise { + if (!this.paused) return Promise.resolve(); + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + this.waiters.push(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }); + }); + } + + resume(): void { + this.paused = false; + const ws = this.waiters; + this.waiters = []; + for (const w of ws) w(); + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) return reject(abortError()); + const onAbort = () => { + clearTimeout(timer); + reject(abortError()); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +interface ActiveRun { + runId: string; + controller: AbortController; + gate: PauseGate; + stopReason?: string; +} + +/** + * Owns the lifecycle of runs: creates the workspace, emits run.started / + * run.paused / run.resumed / run.completed, and drives the adapter. One active + * run at a time. + */ +export class RunManager { + private readonly recorder: Recorder; + private readonly adapters: Map; + private active: ActiveRun | null = null; + private readonly done = new Map>(); + + constructor(private readonly opts: RunManagerOptions) { + this.recorder = opts.recorder; + this.adapters = new Map((opts.adapters ?? [scriptedCodingMission]).map((a) => [a.info.id, a])); + } + + get activeRunId(): string | null { + return this.active?.runId ?? null; + } + + /** Start a run; returns once run.started is recorded. The adapter runs in the background. */ + start(req: StartRunOptions): { runId: string } { + if (this.active) throw new RunConflictError(this.active.runId); + const mission = loadMission(this.opts.missionsDir, req.missionId); + const adapter = this.adapters.get(mission.adapter); + if (!adapter) throw new Error(`Mission ${mission.id} needs adapter "${mission.adapter}", which is not registered`); + const speed = req.speed ?? 1; + if (!Number.isFinite(speed) || speed < 0) throw new Error('speed must be a number ≥ 0'); + + const runId = randomUUID(); + const workspace = `workspaces/${runId}`; + const workspaceDir = path.join(this.opts.dataDir, workspace); + mkdirSync(path.dirname(workspaceDir), { recursive: true }); + // Only repo/ is copied: the mission's script/ never enters the workspace. + cpSync(mission.repoDir, workspaceDir, { + recursive: true, + errorOnExist: true, + filter: (src) => !['node_modules', '.git'].includes(path.basename(src)), + }); + + this.recorder.createRun({ + runId, + manifest: { + title: mission.title, + missionId: mission.id, + worldId: mission.worldId, + seed: mission.seed, + adapter: { ...adapter.info }, + workspace, + }, + }); + + const active: ActiveRun = { runId, controller: new AbortController(), gate: new PauseGate() }; + this.active = active; + const ctx = this.context(active, workspaceDir, speed); + this.done.set(runId, this.drive(active, adapter, ctx, mission)); + return { runId }; + } + + /** Resolves with the final summary once the run has completed (any outcome). */ + async whenDone(runId: string): Promise { + const p = this.done.get(runId); + if (p) return p; + const run = this.recorder.getRun(runId); + if (!run) throw new UnknownRunError(runId); + return run; + } + + stop(runId: string, reason = 'Stopped by request'): RunSummary { + const a = this.requireActive(runId); + a.stopReason ??= reason; + a.controller.abort(); + return this.summary(runId); + } + + pause(runId: string): RunSummary { + const a = this.requireActive(runId); + if (!a.gate.paused) { + this.recorder.append(runId, { type: 'run.paused', payload: { reason: 'Paused by request' }, actorId: 'system' }); + a.gate.paused = true; + } + return this.summary(runId); + } + + resume(runId: string): RunSummary { + const a = this.requireActive(runId); + if (a.gate.paused) { + this.recorder.append(runId, { type: 'run.resumed', payload: {}, actorId: 'system' }); + a.gate.resume(); + } + return this.summary(runId); + } + + /** Stop the active run (if any) and wait for it to finish. */ + async shutdown(reason = 'The runtime is shutting down'): Promise { + const a = this.active; + if (!a) return; + this.stop(a.runId, reason); + await this.done.get(a.runId); + } + + // ── internals ─────────────────────────────────────────────────── + + private context(active: ActiveRun, workspaceDir: string, speed: number): RunContext { + const { recorder } = this; + const { runId, controller, gate } = active; + const signal = controller.signal; + const invoker = new ToolInvoker({ + recorder, + runId, + workspaceDir, + signal, + ...(this.opts.commandTimeoutMs !== undefined ? { commandTimeoutMs: this.opts.commandTimeoutMs } : {}), + }); + return { + runId, + signal, + speed, + emit(type, payload, meta) { + if (signal.aborted) throw abortError(); + if (!isAdapterEventType(type)) throw new Error(`Adapters may not emit ${type}`); + // The generic draft type cannot be narrowed from T here; the recorder validates strictly. + return recorder.append(runId, { type, payload, ...meta } as never); + }, + tools: { invoke: (agentId, taskId, call) => invoker.invoke(agentId, taskId, call) }, + async pace(ms) { + if (signal.aborted) throw abortError(); + const scaled = speed > 0 ? ms / speed : 0; + if (scaled > 0) await sleep(scaled, signal); + // At speed 0, still yield to the event loop so HTTP requests (stop, pause) get through. + else await new Promise((r) => setImmediate(r)); + await gate.wait(signal); + if (signal.aborted) throw abortError(); + }, + }; + } + + private async drive(active: ActiveRun, adapter: AgentAdapter, ctx: RunContext, mission: Mission): Promise { + const { runId, controller } = active; + let completion: AdapterResult | { outcome: 'stopped'; summary: string }; + try { + const result = await adapter.run(ctx, mission); + completion = controller.signal.aborted ? { outcome: 'stopped', summary: active.stopReason ?? 'Stopped' } : result; + } catch (err) { + if (controller.signal.aborted) { + completion = { outcome: 'stopped', summary: active.stopReason ?? 'Stopped' }; + } else { + this.opts.onError?.(err); + completion = { outcome: 'failed', summary: `Adapter error: ${err instanceof Error ? err.message : String(err)}` }; + } + } + // Free the slot before recording completion, so listeners reacting to + // run.completed can start the next run. + if (this.active === active) this.active = null; + try { + this.recorder.append(runId, { type: 'run.completed', payload: completion, actorId: 'system' }); + } catch (err) { + this.opts.onError?.(err); + } + return this.summary(runId); + } + + private requireActive(runId: string): ActiveRun { + if (this.active?.runId === runId) return this.active; + if (!this.recorder.getRun(runId)) throw new UnknownRunError(runId); + throw new RunNotActiveError(runId); + } + + private summary(runId: string): RunSummary { + const run = this.recorder.getRun(runId); + if (!run) throw new UnknownRunError(runId); + return run; + } +} diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts new file mode 100644 index 0000000..aa746c1 --- /dev/null +++ b/packages/runtime/src/runtime.ts @@ -0,0 +1,78 @@ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import type { Hono } from 'hono'; +import type { AgentAdapter } from './adapter'; +import { collectEnvSecrets, findRepoRoot, resolveAllowedOrigins, resolveDataDir } from './config'; +import { createApp } from './http/app'; +import { Recorder } from './recorder'; +import { RunManager } from './run-manager'; +import { openDb, type Db } from './store/db'; + +export interface RuntimeOptions { + env?: NodeJS.ProcessEnv; + repoRoot?: string; + dataDir?: string; + /** Defaults to secrets collected from `env`. */ + secrets?: readonly string[]; + missionsDir?: string; + worldsDir?: string; + allowedOrigins?: readonly string[]; + adapters?: readonly AgentAdapter[]; + commandTimeoutMs?: number; + pingMs?: number; +} + +export interface Runtime { + repoRoot: string; + dataDir: string; + db: Db; + recorder: Recorder; + manager: RunManager; + app: Hono; + /** Runs found `running` at boot and marked interrupted. */ + recovered: string[]; + /** Stop the active run (recorded as stopped) and close the database. */ + close(): Promise; +} + +/** Wire storage, recovery, the run manager and the HTTP app together. */ +export function createRuntime(opts: RuntimeOptions = {}): Runtime { + const env = opts.env ?? process.env; + const repoRoot = opts.repoRoot ?? findRepoRoot(); + const dataDir = opts.dataDir ?? resolveDataDir(env, repoRoot); + mkdirSync(dataDir, { recursive: true }); + const db = openDb(path.join(dataDir, 'runloop.db')); + const recorder = new Recorder({ db, dataDir, secrets: opts.secrets ?? collectEnvSecrets(env) }); + const recovered = recorder.recoverInterrupted(); + const manager = new RunManager({ + recorder, + dataDir, + missionsDir: opts.missionsDir ?? path.join(repoRoot, 'examples'), + ...(opts.adapters ? { adapters: opts.adapters } : {}), + ...(opts.commandTimeoutMs !== undefined ? { commandTimeoutMs: opts.commandTimeoutMs } : {}), + onError: (err) => console.error('[runtime]', err), + }); + const app = createApp({ + recorder, + manager, + worldsDir: opts.worldsDir ?? path.join(repoRoot, 'worlds'), + allowedOrigins: opts.allowedOrigins ?? resolveAllowedOrigins(env), + ...(opts.pingMs !== undefined ? { pingMs: opts.pingMs } : {}), + }); + let closed = false; + return { + repoRoot, + dataDir, + db, + recorder, + manager, + app, + recovered, + async close() { + if (closed) return; + closed = true; + await manager.shutdown(); + db.close(); + }, + }; +} diff --git a/packages/runtime/src/sandbox/command.ts b/packages/runtime/src/sandbox/command.ts new file mode 100644 index 0000000..48c76d6 --- /dev/null +++ b/packages/runtime/src/sandbox/command.ts @@ -0,0 +1,121 @@ +import { spawn } from 'node:child_process'; +import { SandboxError } from './paths'; + +/** The only commands the sandbox will run, matched argv-exactly. */ +export const ALLOWED_COMMANDS: readonly (readonly string[])[] = [['node', '--test', '--test-reporter=tap']]; + +export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +export const MAX_OUTPUT_BYTES = 1024 * 1024; + +export interface CommandResult { + exitCode: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + timedOut: boolean; + aborted: boolean; + truncated: boolean; + durationMs: number; +} + +export interface RunCommandOptions { + cwd: string; + timeoutMs?: number; + signal?: AbortSignal; +} + +export function isAllowedCommand(argv: readonly string[]): boolean { + return ALLOWED_COMMANDS.some((allowed) => allowed.length === argv.length && allowed.every((a, i) => a === argv[i])); +} + +/** + * The child sees only PATH, HOME (= the workspace) and NO_COLOR: no + * NODE_OPTIONS, loaders or credentials from the runtime's environment. + */ +export function minimalEnv(cwd: string): NodeJS.ProcessEnv { + return { PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', HOME: cwd, NO_COLOR: '1' }; +} + +/** + * Run an allowlisted command in `cwd`. `node` is this runtime's own binary + * (process.execPath), spawned without a shell. On timeout or abort the whole + * process group is SIGKILLed (node --test forks one child per test file). + * Spawn failures reject; everything else resolves with what happened. + */ +export function runCommand(argv: readonly string[], opts: RunCommandOptions): Promise { + if (!isAllowedCommand(argv)) throw new SandboxError('not_allowed', `Command not allowed: ${argv.join(' ')}`); + const timeoutMs = opts.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + const started = performance.now(); + + return new Promise((resolve, reject) => { + if (opts.signal?.aborted) { + resolve({ exitCode: null, signal: null, stdout: '', stderr: '', timedOut: false, aborted: true, truncated: false, durationMs: 0 }); + return; + } + const child = spawn(process.execPath, argv.slice(1), { + cwd: opts.cwd, + env: minimalEnv(opts.cwd), + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + windowsHide: true, + }); + + const out = { stdout: [] as Buffer[], stderr: [] as Buffer[], size: 0, truncated: false }; + const collect = (key: 'stdout' | 'stderr') => (chunk: Buffer) => { + const room = MAX_OUTPUT_BYTES - out.size; + if (room <= 0) { + out.truncated = true; + return; + } + const part = chunk.byteLength > room ? chunk.subarray(0, room) : chunk; + if (part !== chunk) out.truncated = true; + out[key].push(part); + out.size += part.byteLength; + }; + child.stdout.on('data', collect('stdout')); + child.stderr.on('data', collect('stderr')); + + let timedOut = false; + let aborted = false; + const kill = () => { + try { + if (child.pid !== undefined && process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); + else child.kill('SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + }; + const timer = setTimeout(() => { + timedOut = true; + kill(); + }, timeoutMs); + const onAbort = () => { + aborted = true; + kill(); + }; + opts.signal?.addEventListener('abort', onAbort, { once: true }); + + const cleanup = () => { + clearTimeout(timer); + opts.signal?.removeEventListener('abort', onAbort); + }; + child.once('error', (err) => { + cleanup(); + reject(err); + }); + child.once('close', (exitCode, signal) => { + cleanup(); + resolve({ + exitCode, + signal, + stdout: Buffer.concat(out.stdout).toString('utf8'), + stderr: Buffer.concat(out.stderr).toString('utf8'), + timedOut, + aborted, + truncated: out.truncated, + durationMs: Math.round(performance.now() - started), + }); + }); + }); +} diff --git a/packages/runtime/src/sandbox/fs-tools.ts b/packages/runtime/src/sandbox/fs-tools.ts new file mode 100644 index 0000000..b0f1ae5 --- /dev/null +++ b/packages/runtime/src/sandbox/fs-tools.ts @@ -0,0 +1,74 @@ +import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { SandboxError, resolveInside, toRel } from './paths'; + +export const MAX_FILE_BYTES = 256 * 1024; +export const MAX_LIST_ENTRIES = 500; +const SKIP_DIRS = new Set(['node_modules', '.git']); + +/** The tool ran but reports a failure (e.g. file not found): tool.call_completed{status:error}. */ +export class ToolReportedError extends Error { + constructor( + readonly errorType: string, + message: string, + ) { + super(message); + } +} + +/** + * Recursive listing, sorted, directories suffixed with "/". Symlinks are + * listed but never followed. Capped at MAX_LIST_ENTRIES. + */ +export function listFiles(root: string, rel = '.'): { entries: string[]; truncated: boolean } { + const start = resolveInside(root, rel); + if (!statSync(start, { throwIfNoEntry: false })?.isDirectory()) { + throw new ToolReportedError('not_a_directory', `Not a directory: ${rel}`); + } + const entries: string[] = []; + let truncated = false; + const walk = (dir: string): void => { + const names = readdirSync(dir).sort(); + for (const name of names) { + if (entries.length >= MAX_LIST_ENTRIES) { + truncated = true; + return; + } + const abs = path.join(dir, name); + const st = lstatSync(abs); + if (st.isDirectory()) { + if (SKIP_DIRS.has(name)) continue; + entries.push(`${toRel(root, abs)}/`); + walk(abs); + } else { + entries.push(toRel(root, abs)); + } + } + }; + walk(start); + return { entries, truncated }; +} + +export function readWorkspaceFile(root: string, rel: string): { content: string; bytes: number } { + const abs = resolveInside(root, rel); + const st = statSync(abs, { throwIfNoEntry: false }); + if (!st) throw new ToolReportedError('not_found', `No such file: ${rel}`); + if (!st.isFile()) throw new ToolReportedError('not_a_file', `Not a regular file: ${rel}`); + if (st.size > MAX_FILE_BYTES) throw new SandboxError('too_large', `${rel} is ${st.size} bytes (limit ${MAX_FILE_BYTES})`); + const buf = readFileSync(abs); + return { content: buf.toString('utf8'), bytes: buf.byteLength }; +} + +export function writeWorkspaceFile(root: string, rel: string, content: string): { bytes: number } { + const bytes = Buffer.byteLength(content, 'utf8'); + if (bytes > MAX_FILE_BYTES) throw new SandboxError('too_large', `Refusing to write ${bytes} bytes (limit ${MAX_FILE_BYTES})`); + const abs = resolveInside(root, rel); + if (statSync(abs, { throwIfNoEntry: false })?.isDirectory()) { + throw new ToolReportedError('is_a_directory', `Is a directory: ${rel}`); + } + mkdirSync(path.dirname(abs), { recursive: true }); + // Re-check after mkdir: the parent now exists and may be reached via a symlink. + resolveInside(root, rel); + writeFileSync(abs, content, 'utf8'); + return { bytes }; +} diff --git a/packages/runtime/src/sandbox/paths.ts b/packages/runtime/src/sandbox/paths.ts new file mode 100644 index 0000000..1e3493e --- /dev/null +++ b/packages/runtime/src/sandbox/paths.ts @@ -0,0 +1,62 @@ +import { lstatSync, realpathSync } from 'node:fs'; +import path from 'node:path'; + +export type SandboxErrorCode = 'path_escape' | 'invalid_path' | 'too_large' | 'not_allowed'; + +/** A request the sandbox refuses to run (reported as tool.call_failed{denied}). */ +export class SandboxError extends Error { + constructor( + readonly code: SandboxErrorCode, + message: string, + ) { + super(message); + } +} + +function lexists(p: string): boolean { + try { + return lstatSync(p, { throwIfNoEntry: false }) !== undefined; + } catch { + return false; // e.g. ENOTDIR: a parent component is a file + } +} + +function isInside(root: string, target: string): boolean { + const rel = path.relative(root, target); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +/** + * Resolve a workspace-relative path to an absolute one that is guaranteed to + * stay inside `root`, including after following symlinks. For a path that does + * not exist yet (a write), the nearest existing ancestor is checked instead. + */ +export function resolveInside(root: string, rel: string): string { + if (typeof rel !== 'string' || rel.length === 0) throw new SandboxError('invalid_path', 'Path must be a non-empty string'); + if (rel.includes('\0')) throw new SandboxError('invalid_path', 'Path contains a NUL byte'); + if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel) || rel.startsWith('\\')) { + throw new SandboxError('path_escape', `Absolute paths are not allowed: ${rel}`); + } + const rootReal = realpathSync(root); + const target = path.resolve(rootReal, rel); + if (!isInside(rootReal, target)) throw new SandboxError('path_escape', `Path escapes the workspace: ${rel}`); + + // Follow symlinks on the deepest existing ancestor (or the target itself). + // lstat, not exists: a dangling symlink must be checked, not skipped. + let probe = target; + while (probe !== rootReal && !lexists(probe)) probe = path.dirname(probe); + let real: string; + try { + real = realpathSync(probe); + } catch { + // A dangling symlink: refuse rather than guess where it points. + throw new SandboxError('path_escape', `Cannot resolve path: ${rel}`); + } + if (!isInside(rootReal, real)) throw new SandboxError('path_escape', `Path escapes the workspace via a symlink: ${rel}`); + return target; +} + +/** Workspace-relative display form with forward slashes. */ +export function toRel(root: string, abs: string): string { + return path.relative(realpathSync(root), abs).split(path.sep).join('/') || '.'; +} diff --git a/packages/runtime/src/sandbox/tap.ts b/packages/runtime/src/sandbox/tap.ts new file mode 100644 index 0000000..738354b --- /dev/null +++ b/packages/runtime/src/sandbox/tap.ts @@ -0,0 +1,57 @@ +import type { TestMetrics } from '@runloop-world/events'; + +export interface TapSummary { + /** From the `# tests / # pass / # fail` footer; undefined if the footer is missing. */ + metrics?: TestMetrics; + /** Names of failing tests (suites excluded), in output order. */ + failingTests: string[]; +} + +const NOT_OK = /^(\s*)not ok \d+ - (.*?)(?:\s+#\s*(?:SKIP|TODO)\b.*)?$/i; + +function unescapeTap(name: string): string { + return name.replace(/\\#/g, '#').replace(/\\\\/g, '\\'); +} + +/** Parse node:test's TAP reporter output. Only reads what the runner printed. */ +export function parseTap(output: string): TapSummary { + const lines = output.split(/\r?\n/); + const footer = (key: string): number | undefined => { + const re = new RegExp(`^# ${key} (\\d+)\\s*$`); + for (let i = lines.length - 1; i >= 0; i--) { + const m = re.exec(lines[i]!); + if (m) return Number(m[1]); + } + return undefined; + }; + const tests = footer('tests'); + const passed = footer('pass'); + const failed = footer('fail'); + + const failingTests: string[] = []; + for (let i = 0; i < lines.length; i++) { + const m = NOT_OK.exec(lines[i]!); + if (!m) continue; + // A failing describe() block is reported as `not ok` with `type: 'suite'` in its YAML. + let isSuite = false; + const indent = m[1]!.length; + if (lines[i + 1]?.trim() === '---') { + for (let j = i + 2; j < lines.length; j++) { + const line = lines[j]!; + if (line.trim() === '...') break; + if (line.slice(0, indent + 2).trim() === '' && /^\s*type: 'suite'\s*$/.test(line)) isSuite = true; + } + } + if (!isSuite) failingTests.push(unescapeTap(m[2]!)); + } + + return { + ...(tests !== undefined && passed !== undefined && failed !== undefined ? { metrics: { tests, passed, failed } } : {}), + failingTests, + }; +} + +/** "12 tests: 10 passed, 2 failed" */ +export function formatTestSummary(m: TestMetrics): string { + return `${m.tests} tests: ${m.passed} passed, ${m.failed} failed`; +} diff --git a/packages/runtime/src/store/db.ts b/packages/runtime/src/store/db.ts new file mode 100644 index 0000000..dd26085 --- /dev/null +++ b/packages/runtime/src/store/db.ts @@ -0,0 +1,44 @@ +/** + * The only module that touches node:sqlite. Swapping to better-sqlite3 later + * means changing this file (the API used elsewhere is exec/prepare/run/get/all). + */ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { MIGRATIONS } from './migrations'; + +export type Db = DatabaseSync; + +/** Open (creating if needed) the database at `file` and bring its schema up to date. */ +export function openDb(file: string): Db { + if (file !== ':memory:') mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file, { timeout: 5000 }); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA synchronous = FULL'); + db.exec('PRAGMA foreign_keys = ON'); + migrate(db); + return db; +} + +export function migrate(db: Db): void { + const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; + for (let v = row.user_version; v < MIGRATIONS.length; v++) { + tx(db, () => { + db.exec(MIGRATIONS[v]!); + db.exec(`PRAGMA user_version = ${v + 1}`); + }); + } +} + +/** Run `fn` in one transaction: commit on return, roll back on throw. Not re-entrant. */ +export function tx(db: Db, fn: () => T): T { + db.exec('BEGIN IMMEDIATE'); + try { + const out = fn(); + db.exec('COMMIT'); + return out; + } catch (err) { + if (db.isTransaction) db.exec('ROLLBACK'); + throw err; + } +} diff --git a/packages/runtime/src/store/migrations.ts b/packages/runtime/src/store/migrations.ts new file mode 100644 index 0000000..d21f6f6 --- /dev/null +++ b/packages/runtime/src/store/migrations.ts @@ -0,0 +1,48 @@ +/** + * Schema migrations, applied in order. `PRAGMA user_version` records how many + * have run. Never edit a shipped migration; append a new one. + */ +export const MIGRATIONS: readonly string[] = [ + ` + CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, + mission_id TEXT NOT NULL, + world_id TEXT NOT NULL, + title TEXT NOT NULL, + adapter_label TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'stopped', 'interrupted')), + paused INTEGER NOT NULL DEFAULT 0, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL, + ended_at TEXT, + last_sequence INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE events ( + run_id TEXT NOT NULL REFERENCES runs (run_id), + sequence INTEGER NOT NULL, + event_id TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + timestamp TEXT NOT NULL, + actor_id TEXT, + task_id TEXT, + parent_event_id TEXT, + visibility TEXT NOT NULL, + envelope_json TEXT NOT NULL, + PRIMARY KEY (run_id, sequence) + ) WITHOUT ROWID; + + -- Artifact ids (art-N) are unique per run. Bytes live at artifacts//. + CREATE TABLE artifacts ( + run_id TEXT NOT NULL REFERENCES runs (run_id), + artifact_id TEXT NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + media_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (run_id, artifact_id) + ) WITHOUT ROWID; + `, +]; diff --git a/packages/runtime/src/tool-invoker.ts b/packages/runtime/src/tool-invoker.ts new file mode 100644 index 0000000..0f7e707 --- /dev/null +++ b/packages/runtime/src/tool-invoker.ts @@ -0,0 +1,270 @@ +import { realpathSync } from 'node:fs'; +import path from 'node:path'; +import type { ArtifactKind, TestMetrics, ToolFailureType } from '@runloop-world/events'; +import { abortError, isAbortError, type ToolCall, type ToolResult } from './adapter'; +import type { Recorder } from './recorder'; +import { runCommand } from './sandbox/command'; +import { ToolReportedError, listFiles, readWorkspaceFile, writeWorkspaceFile } from './sandbox/fs-tools'; +import { SandboxError } from './sandbox/paths'; +import { formatTestSummary, parseTap } from './sandbox/tap'; + +export interface ToolInvokerOptions { + recorder: Recorder; + runId: string; + workspaceDir: string; + signal: AbortSignal; + commandTimeoutMs?: number; +} + +interface ArtifactOut { + kind: ArtifactKind; + name: string; + mediaType: string; + content: string; + path?: string; + summary: string; +} + +/** What actually happened when the tool ran (becomes tool.call_completed). */ +interface Executed { + status: 'success' | 'error'; + outputSummary: string; + exitCode?: number; + metrics?: TestMetrics; + failingTests?: string[]; + errorType?: string; + artifacts?: ArtifactOut[]; + output?: string; +} + +class ToolFailure extends Error { + constructor( + readonly errorType: ToolFailureType, + message: string, + ) { + super(message); + } +} + +const MEDIA_TYPES: Record = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', + '.ts': 'text/typescript', + '.json': 'application/json', + '.md': 'text/markdown', +}; + +export function describeInput(call: ToolCall): string { + switch (call.tool) { + case 'list_files': + return call.path ?? '.'; + case 'read_file': + case 'write_file': + return call.path; + case 'run_command': + return call.argv.join(' '); + } +} + +/** + * Runs tools inside one run's workspace and records them. Every + * tool.call_started gets exactly one terminal event (completed or failed), + * linked to it by parentEventId, with any artifact.created events in between. + * Call ids (call-N) and artifact ids (art-N) are numbered per run. + */ +export class ToolInvoker { + private callN = 0; + private artifactN = 0; + private readonly workspaceReal: string; + + constructor(private readonly opts: ToolInvokerOptions) { + this.workspaceReal = realpathSync(opts.workspaceDir); + } + + async invoke(agentId: string, taskId: string, call: ToolCall): Promise { + const { recorder, runId, signal } = this.opts; + if (signal.aborted) throw abortError(); + const callId = `call-${++this.callN}`; + const started = recorder.append(runId, { + type: 'tool.call_started', + payload: { callId, agentId, taskId, tool: call.tool, inputSummary: this.scrubPaths(describeInput(call)) }, + }); + const link = { actorId: agentId, taskId, parentEventId: started.eventId }; + const t0 = performance.now(); + const elapsed = () => Math.round(performance.now() - t0); + + const fail = (errorType: ToolFailureType, message: string): ToolResult => { + const durationMs = elapsed(); + const msg = this.scrubPaths(message); + recorder.append(runId, { type: 'tool.call_failed', payload: { callId, errorType, message: msg, durationMs }, ...link }); + return { callId, status: 'failed', durationMs, outputSummary: msg, errorType, artifactIds: [] }; + }; + + let result: Executed; + try { + result = await this.execute(call); + } catch (err) { + if (isAbortError(err) || signal.aborted) { + fail('aborted', 'The run was stopped while the tool was running'); + throw abortError(); + } + const [errorType, message] = classify(err); + return fail(errorType, message); + } + + // Persist outputs, then the completion. If storing fails, the call still ends (as failed). + const artifactIds: string[] = []; + try { + for (const a of result.artifacts ?? []) { + const artifactId = `art-${++this.artifactN}`; + const { bytes, sha256 } = recorder.putArtifactContent(runId, artifactId, a.content); + recorder.append(runId, { + type: 'artifact.created', + payload: { + artifactId, + kind: a.kind, + name: a.name, + mediaType: a.mediaType, + bytes, + sha256, + ...(a.path ? { path: a.path } : {}), + producedByCallId: callId, + summary: a.summary, + }, + ...link, + }); + artifactIds.push(artifactId); + } + } catch (err) { + return fail('exception', `Could not store the tool output: ${err instanceof Error ? err.message : String(err)}`); + } + const durationMs = elapsed(); + const outputSummary = this.scrubPaths(result.outputSummary); + recorder.append(runId, { + type: 'tool.call_completed', + payload: { + callId, + status: result.status, + durationMs, + ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}), + outputSummary, + ...(result.metrics ? { metrics: result.metrics } : {}), + ...(result.failingTests?.length ? { failingTests: result.failingTests } : {}), + ...(artifactIds.length ? { outputArtifactIds: artifactIds } : {}), + ...(result.errorType ? { errorType: result.errorType } : {}), + }, + ...link, + }); + return { + callId, + status: result.status, + durationMs, + outputSummary, + ...(result.errorType ? { errorType: result.errorType } : {}), + ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}), + ...(result.metrics ? { metrics: result.metrics } : {}), + ...(result.failingTests ? { failingTests: result.failingTests } : {}), + artifactIds, + ...(result.output !== undefined ? { output: result.output } : {}), + }; + } + + private async execute(call: ToolCall): Promise { + const ws = this.opts.workspaceDir; + try { + switch (call.tool) { + case 'list_files': { + const { entries, truncated } = listFiles(ws, call.path ?? '.'); + return { + status: 'success', + outputSummary: `${entries.length} entries${truncated ? ' (truncated)' : ''}`, + output: entries.join('\n'), + }; + } + case 'read_file': { + const { content, bytes } = readWorkspaceFile(ws, call.path); + return { status: 'success', outputSummary: `read ${bytes} bytes`, output: content }; + } + case 'write_file': { + const { bytes } = writeWorkspaceFile(ws, call.path, call.content); + const rel = path.posix.normalize(call.path.split(path.sep).join('/')); + return { + status: 'success', + outputSummary: `wrote ${bytes} bytes`, + artifacts: [ + { + kind: 'file', + name: path.posix.basename(rel), + mediaType: MEDIA_TYPES[path.extname(rel)] ?? 'text/plain', + content: call.content, + path: rel, + summary: call.summary ?? `${rel} (${bytes} bytes)`, + }, + ], + }; + } + case 'run_command': + return await this.runCommand(call.argv); + } + } catch (err) { + if (err instanceof ToolReportedError) { + return { status: 'error', outputSummary: err.message, errorType: err.errorType }; + } + throw err; + } + } + + private async runCommand(argv: string[]): Promise { + let r; + try { + r = await runCommand(argv, { + cwd: this.opts.workspaceDir, + signal: this.opts.signal, + ...(this.opts.commandTimeoutMs !== undefined ? { timeoutMs: this.opts.commandTimeoutMs } : {}), + }); + } catch (err) { + if (err instanceof SandboxError) throw err; + throw new ToolFailure('spawn_error', err instanceof Error ? err.message : String(err)); + } + if (r.aborted) throw abortError(); + if (r.timedOut) throw new ToolFailure('timeout', `Timed out after ${r.durationMs} ms; the process was killed`); + + const output = this.scrubPaths(r.stderr ? `${r.stdout}\n# stderr:\n${r.stderr}` : r.stdout); + const tap = parseTap(r.stdout); + const ok = r.exitCode === 0; + const exitText = r.exitCode !== null ? `exit ${r.exitCode}` : `killed by ${r.signal ?? 'signal'}`; + const errorType = ok ? undefined : tap.metrics && tap.metrics.failed > 0 ? 'tests_failed' : 'exit_nonzero'; + return { + status: ok ? 'success' : 'error', + ...(r.exitCode !== null ? { exitCode: r.exitCode } : {}), + outputSummary: tap.metrics ? formatTestSummary(tap.metrics) : exitText, + ...(tap.metrics ? { metrics: tap.metrics } : {}), + ...(tap.failingTests.length ? { failingTests: tap.failingTests } : {}), + ...(errorType ? { errorType } : {}), + output, + artifacts: [ + { + kind: tap.metrics ? 'test_report' : 'log', + name: tap.metrics ? 'test-output.tap' : 'command-output.log', + mediaType: 'text/plain', + content: r.truncated ? `${output}\n# [output truncated]\n` : output, + summary: `${argv[0] === 'node' && argv[1] === '--test' ? 'node --test' : argv[0]} output (${exitText})`, + }, + ], + }; + } + + /** Replace the absolute workspace path (it appears in stack traces) with "". */ + private scrubPaths(text: string): string { + let out = text; + for (const p of new Set([this.workspaceReal, path.resolve(this.opts.workspaceDir)])) out = out.split(p).join(''); + return out; + } +} + +function classify(err: unknown): [ToolFailureType, string] { + if (err instanceof ToolFailure) return [err.errorType, err.message]; + if (err instanceof SandboxError) return ['denied', err.message]; + return ['exception', err instanceof Error ? err.message : String(err)]; +} diff --git a/packages/runtime/src/trace-printer.ts b/packages/runtime/src/trace-printer.ts new file mode 100644 index 0000000..b9ec630 --- /dev/null +++ b/packages/runtime/src/trace-printer.ts @@ -0,0 +1,91 @@ +import type { AnyEvent } from '@runloop-world/events'; + +function clock(ms: number): string { + const t = Math.max(0, ms); + const m = Math.floor(t / 60_000); + const s = Math.floor((t % 60_000) / 1000); + const d = Math.floor((t % 1000) / 100); + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${d}`; +} + +function clip(text: string, max = 96): string { + const one = text.replace(/\s+/g, ' ').trim(); + return one.length > max ? `${one.slice(0, max - 1)}…` : one; +} + +/** + * One text line per event, built from payload fields only, e.g. + * `#036 00:21.4 tester tool.call_completed run_command error 12 tests: 10 passed, 2 failed`. + * Keeps a little state (run start, call → tool, attempts) across events. + */ +export class TracePrinter { + private startMs: number | null = null; + private readonly tools = new Map(); + private readonly attempts = new Map(); + /** taskId → agent working on it, to name an actor for task.* events that carry none. */ + private readonly owners = new Map(); + + format(e: AnyEvent): string { + const ts = Date.parse(e.timestamp); + if (this.startMs === null || e.type === 'run.started') this.startMs = ts; + if (!('unknown' in e) && (e.type === 'task.assigned' || e.type === 'task.started')) { + this.owners.set(e.payload.taskId, e.payload.agentId); + } + const actor = e.actorId ?? (e.taskId ? this.owners.get(e.taskId) : undefined) ?? '-'; + const head = `#${String(e.sequence).padStart(3, '0')} ${clock(ts - this.startMs)} ${actor.padEnd(11)} ${e.type.padEnd(20)}`; + const detail = this.detail(e); + return detail ? `${head} ${detail}` : head.trimEnd(); + } + + private detail(e: AnyEvent): string { + if ('unknown' in e) return `(unreadable event: ${e.reason})`; + switch (e.type) { + case 'run.started': + return `"${e.payload.title}" · ${e.payload.adapter.label} · ${e.payload.workspace}`; + case 'run.paused': + return e.payload.reason ?? ''; + case 'run.resumed': + return ''; + case 'run.completed': + return `${e.payload.outcome.toUpperCase()}${e.payload.summary ? ` — ${e.payload.summary}` : ''}`; + case 'agent.registered': + return `${e.payload.name} (${e.payload.role})${e.payload.tools.length ? ` tools: ${e.payload.tools.join(', ')}` : ''}${e.payload.embodied ? '' : ' [not embodied]'}`; + case 'agent.state_changed': + return `${e.payload.agentId} → ${e.payload.state}${e.payload.reason ? ` (${e.payload.reason})` : ''}`; + case 'agent.message_sent': + return `→ ${e.payload.to}: ${clip(e.payload.text)}`; + case 'task.created': + return `${e.payload.taskId} ${e.payload.kind} "${e.payload.title}"${e.payload.dependsOn.length ? ` after ${e.payload.dependsOn.join(', ')}` : ''}`; + case 'task.assigned': + return `${e.payload.taskId} → ${e.payload.agentId}`; + case 'task.started': { + const n = (this.attempts.get(e.payload.taskId) ?? 0) + 1; + this.attempts.set(e.payload.taskId, n); + return `${e.payload.taskId} attempt ${n}`; + } + case 'task.blocked': + return `${e.payload.taskId} ${e.payload.reason}${e.payload.blockedBy?.length ? ` (waiting on ${e.payload.blockedBy.join(', ')})` : ''}`; + case 'task.completed': + return `${e.payload.taskId} ${clip(e.payload.summary)}`; + case 'task.failed': + return `${e.payload.taskId} ${clip(e.payload.reason)}`; + case 'tool.call_started': + this.tools.set(e.payload.callId, e.payload.tool); + return `${e.payload.tool} ${clip(e.payload.inputSummary, 60)}`; + case 'tool.call_completed': { + const tool = this.tools.get(e.payload.callId) ?? e.payload.callId; + return `${tool} ${e.payload.status} ${e.payload.outputSummary} (${Math.round(e.payload.durationMs)} ms)`; + } + case 'tool.call_failed': { + const tool = this.tools.get(e.payload.callId) ?? e.payload.callId; + return `${tool} FAILED ${e.payload.errorType}: ${clip(e.payload.message)}`; + } + case 'tool.permission_requested': + return `${e.payload.action} ${e.payload.scope}`; + case 'tool.permission_resolved': + return `${e.payload.permissionId} ${e.payload.decision} by ${e.payload.by}`; + case 'artifact.created': + return `${e.payload.artifactId} ${e.payload.kind} ${e.payload.path ?? e.payload.name} (${e.payload.bytes} B)`; + } + } +} diff --git a/packages/runtime/test/coding-mission.int.test.ts b/packages/runtime/test/coding-mission.int.test.ts new file mode 100644 index 0000000..ff7923d --- /dev/null +++ b/packages/runtime/test/coding-mission.int.test.ts @@ -0,0 +1,254 @@ +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { parseKnownEvent, type EventOf, type RunEvent } from '@runloop-world/events'; +import { CODING_MISSION, codingMissionFixture, codingMissionTypeSequence } from '@runloop-world/testkit'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createRuntime, type Runtime, type RuntimeOptions } from '../src/runtime'; +import { EXAMPLE_DIR, REPO_ROOT, hashTree, tempDir } from './helpers'; + +const M = CODING_MISSION; +const BUGGY = readFileSync(path.join(EXAMPLE_DIR, 'script', 'implement.buggy.js'), 'utf8'); +const FIXED = readFileSync(path.join(EXAMPLE_DIR, 'script', 'implement.fixed.js'), 'utf8'); + +let rt: Runtime | null = null; +afterEach(async () => { + await rt?.close(); + rt = null; +}); + +function runtime(opts: Partial = {}): Runtime { + rt = createRuntime({ repoRoot: REPO_ROOT, dataDir: tempDir('int'), env: {}, ...opts }); + return rt; +} + +/** A copy of examples/ with the coding mission's script files replaced. */ +function missionsWith(files: Record): string { + const dir = tempDir('missions'); + cpSync(EXAMPLE_DIR, path.join(dir, 'coding-mission'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) writeFileSync(path.join(dir, 'coding-mission', rel), content); + return dir; +} + +async function runMission(r: Runtime, speed = 0): Promise<{ runId: string; events: RunEvent[] }> { + const { runId } = r.manager.start({ missionId: 'coding-mission', speed }); + await r.manager.whenDone(runId); + return { runId, events: r.recorder.listAll(runId) }; +} + +const ofType = (events: RunEvent[], type: T) => + events.filter((e): e is Extract => e.type === type); + +function completionsOf(events: RunEvent[], tool: string) { + const callIds = new Set(ofType(events, 'tool.call_started').filter((e) => e.payload.tool === tool).map((e) => e.payload.callId)); + return ofType(events, 'tool.call_completed').filter((e) => callIds.has(e.payload.callId)); +} + +describe('coding mission (real tools, speed 0)', () => { + it('reproduces the canonical event sequence with a genuine failure, a fix and a passing rerun', { timeout: 60_000 }, async () => { + const examplesBefore = hashTree(EXAMPLE_DIR); + const r = runtime(); + const { runId, events } = await runMission(r); + + // Shape: exactly the canonical type sequence, contiguous, unique, valid. + expect(events.map((e) => e.type)).toEqual(codingMissionTypeSequence()); + expect(events.map((e) => e.sequence)).toEqual(events.map((_, i) => i + 1)); + expect(new Set(events.map((e) => e.eventId)).size).toBe(events.length); + for (const e of events) expect(parseKnownEvent(e).ok).toBe(true); + // Same envelope refs (actor, task, parent link) and payload fields as the canonical fixture. + const shape = (e: RunEvent, all: RunEvent[]) => [ + e.type, + e.actorId ?? null, + e.taskId ?? null, + all.find((x) => x.eventId === e.parentEventId)?.sequence ?? null, + e.visibility, + Object.keys(e.payload).sort(), + ]; + const fixture = codingMissionFixture(); + expect(events.map((e) => shape(e, events))).toEqual(fixture.map((e) => shape(e, fixture))); + + // Identity matches the testkit constants. + const started = events[0] as EventOf<'run.started'>; + expect(started.payload).toEqual({ + title: M.title, + missionId: M.missionId, + worldId: M.worldId, + seed: M.seed, + adapter: { ...M.adapter }, + workspace: `workspaces/${runId}`, + }); + const agents = ofType(events, 'agent.registered').map((e) => e.payload); + expect(agents.map((a) => [a.agentId, a.name, a.role, a.embodied, a.characterPreset])).toEqual( + Object.values(M.agents).map((a) => [a.agentId, a.name, a.role, a.agentId !== 'coordinator', a.preset]), + ); + for (const a of agents.slice(1)) expect(a.tools).toEqual(M.tools[a.agentId as keyof typeof M.tools]); + expect(ofType(events, 'task.created').map((e) => [e.payload.taskId, e.payload.kind, e.payload.plannedSteps])).toEqual( + Object.values(M.tasks).map((t) => [t.taskId, t.kind, t.plannedSteps]), + ); + + // Every call has exactly one terminal event, linked to its start; artifacts are linked too. + const startedCalls = ofType(events, 'tool.call_started'); + expect(startedCalls.map((e) => e.payload.callId)).toEqual(startedCalls.map((_, i) => `call-${i + 1}`)); + for (const s of startedCalls) { + const terminal = events.filter( + (e) => (e.type === 'tool.call_completed' || e.type === 'tool.call_failed') && e.payload.callId === s.payload.callId, + ); + expect(terminal).toHaveLength(1); + expect(terminal[0]!.parentEventId).toBe(s.eventId); + expect(terminal[0]!.actorId).toBe(s.payload.agentId); + } + const artifacts = ofType(events, 'artifact.created'); + expect(artifacts.map((a) => a.payload.artifactId)).toEqual(['art-1', 'art-2', 'art-3', 'art-4']); + for (const a of artifacts) { + const s = startedCalls.find((c) => c.payload.callId === a.payload.producedByCallId)!; + expect(a.parentEventId).toBe(s.eventId); + const stored = r.recorder.getArtifact(runId, a.payload.artifactId)!; + expect(stored.content.byteLength).toBe(a.payload.bytes); + expect(createHash('sha256').update(stored.content).digest('hex')).toBe(a.payload.sha256); + } + + // The first test run genuinely fails, 10 passed / 2 failed, with the real names. + const [first, rerun] = completionsOf(events, 'run_command'); + expect(first!.payload).toMatchObject({ + status: 'error', + exitCode: 1, + errorType: 'tests_failed', + outputSummary: '12 tests: 10 passed, 2 failed', + metrics: { tests: M.totalTests, passed: 10, failed: 2 }, + failingTests: [...M.failingTests], + }); + const firstReport = r.recorder.getArtifact(runId, first!.payload.outputArtifactIds![0]!)!.content.toString(); + expect(firstReport).toContain('# fail 2'); + for (const name of M.failingTests) expect(firstReport).toContain(` - ${name}\n`); + expect(firstReport).not.toContain(r.dataDir); // absolute host paths are scrubbed + + // The rerun passes. + expect(rerun!.payload).toMatchObject({ + status: 'success', + exitCode: 0, + outputSummary: '12 tests: 12 passed, 0 failed', + metrics: { tests: 12, passed: 12, failed: 0 }, + }); + expect(rerun!.payload.failingTests).toBeUndefined(); + expect(r.recorder.getArtifact(runId, rerun!.payload.outputArtifactIds![0]!)!.content.toString()).toContain('# fail 0'); + + // Messages carry real results. + const tellBuilder = ofType(events, 'agent.message_sent').find((e) => e.payload.fromAgentId === 'tester')!; + expect(tellBuilder.payload.text).toBe(`Failing: ${M.failingTests.join('; ')}`); + expect(ofType(events, 'task.blocked')[0]!.payload).toMatchObject({ taskId: 'T3', reason: '2 of 12 tests failing', blockedBy: ['T4'] }); + + // Outcome and side effects. + expect(events.at(-1)).toMatchObject({ type: 'run.completed', payload: { outcome: 'succeeded' } }); + expect(r.recorder.getRun(runId)).toMatchObject({ status: 'succeeded', lastSequence: events.length }); + const ws = path.join(r.dataDir, 'workspaces', runId); + expect(readFileSync(path.join(ws, 'src', 'task-store.js'), 'utf8')).toBe(FIXED); + expect(existsSync(path.join(ws, 'script'))).toBe(false); + expect(hashTree(EXAMPLE_DIR)).toBe(examplesBefore); + }); + + it('keeps every event after a restart and continues from it', { timeout: 60_000 }, async () => { + const r = runtime(); + const { runId, events } = await runMission(r); + const dataDir = r.dataDir; + await r.close(); + rt = createRuntime({ repoRoot: REPO_ROOT, dataDir, env: {} }); + expect(rt.recovered).toEqual([]); + expect(rt.recorder.listAll(runId)).toEqual(events); + expect(rt.recorder.listRuns()).toEqual([expect.objectContaining({ runId, status: 'succeeded' })]); + }); + + it('skips the fix when the first test run really passes', { timeout: 60_000 }, async () => { + const r = runtime({ missionsDir: missionsWith({ 'script/implement.buggy.js': FIXED }) }); + const { events } = await runMission(r); + const runs = completionsOf(events, 'run_command'); + expect(runs.map((e) => e.payload.status)).toEqual(['success']); + expect(ofType(events, 'task.created').map((e) => e.payload.taskId)).toEqual(['T1', 'T2', 'T3']); + expect(ofType(events, 'task.blocked')).toEqual([]); + expect(events.at(-1)).toMatchObject({ type: 'run.completed', payload: { outcome: 'succeeded' } }); + }); + + it('fails the run when the rerun still fails', { timeout: 60_000 }, async () => { + const r = runtime({ missionsDir: missionsWith({ 'script/implement.fixed.js': BUGGY }) }); + const { runId, events } = await runMission(r); + expect(completionsOf(events, 'run_command').map((e) => e.payload.status)).toEqual(['error', 'error']); + expect(ofType(events, 'task.failed')[0]!.payload).toMatchObject({ taskId: 'T3', errorType: 'tests_failed' }); + expect(ofType(events, 'agent.state_changed').at(-1)!.payload).toMatchObject({ agentId: 'tester', state: 'failed' }); + expect(events.at(-1)).toMatchObject({ type: 'run.completed', payload: { outcome: 'failed' } }); + expect(r.recorder.getRun(runId)?.status).toBe('failed'); + }); + + it('stop kills a running test command: tool.call_failed{aborted}, then run.completed{stopped}', { timeout: 60_000 }, async () => { + const missionsDir = missionsWith({ + 'repo/test/slow.test.js': `import { test } from 'node:test';\ntest('slow', () => new Promise((r) => setTimeout(r, 20000)));\n`, + }); + const r = runtime({ missionsDir }); + const { runId } = r.manager.start({ missionId: 'coding-mission', speed: 0 }); + r.recorder.subscribe(runId, (e) => { + if (e.type === 'tool.call_started' && e.payload.tool === 'run_command') setTimeout(() => r.manager.stop(runId), 300); + }); + const t0 = Date.now(); + await r.manager.whenDone(runId); + expect(Date.now() - t0).toBeLessThan(15_000); + const events = r.recorder.listAll(runId); + const startedCmd = ofType(events, 'tool.call_started').find((e) => e.payload.tool === 'run_command')!; + const failed = ofType(events, 'tool.call_failed'); + expect(failed).toHaveLength(1); + expect(failed[0]!.payload).toMatchObject({ callId: startedCmd.payload.callId, errorType: 'aborted' }); + expect(failed[0]!.parentEventId).toBe(startedCmd.eventId); + expect(events.slice(-2).map((e) => e.type)).toEqual(['tool.call_failed', 'run.completed']); + expect(events.at(-1)).toMatchObject({ payload: { outcome: 'stopped' } }); + expect(r.recorder.getRun(runId)?.status).toBe('stopped'); + expect(r.manager.activeRunId).toBeNull(); + }); + + it('pause holds the run at the next step; resume continues it to the same sequence', { timeout: 60_000 }, async () => { + const r = runtime(); + const { runId } = r.manager.start({ missionId: 'coding-mission', speed: 0 }); + let pausedAt = 0; + r.recorder.subscribe(runId, (e) => { + if (!pausedAt && e.type === 'agent.state_changed' && e.payload.state === 'working') { + pausedAt = e.sequence; + r.manager.pause(runId); + } + }); + await new Promise((res) => setTimeout(res, 400)); + expect(pausedAt).toBeGreaterThan(0); + const held = r.recorder.listAll(runId); + expect(held.at(-1)).toMatchObject({ type: 'run.paused', sequence: pausedAt + 1 }); + expect(r.recorder.getRun(runId)).toMatchObject({ status: 'running', paused: true }); + + r.manager.resume(runId); + await r.manager.whenDone(runId); + const events = r.recorder.listAll(runId); + expect(events[pausedAt + 1]).toMatchObject({ type: 'run.resumed' }); + expect(events.map((e) => e.type).filter((t) => t !== 'run.paused' && t !== 'run.resumed')).toEqual(codingMissionTypeSequence()); + expect(r.recorder.getRun(runId)).toMatchObject({ status: 'succeeded', paused: false }); + }); + + it('stop while paused ends the run as stopped', { timeout: 60_000 }, async () => { + const r = runtime(); + const { runId } = r.manager.start({ missionId: 'coding-mission', speed: 0 }); + r.manager.pause(runId); + await new Promise((res) => setTimeout(res, 100)); + r.manager.stop(runId); + const summary = await r.manager.whenDone(runId); + expect(summary).toMatchObject({ status: 'stopped', paused: false }); + const types = r.recorder.listAll(runId).map((e) => e.type); + expect(types).toEqual(['run.started', 'run.paused', 'run.completed']); + }); + + it('redacts an env secret from every recorded event and artifact', { timeout: 60_000 }, async () => { + // The secret's value shows up in real tool output (a failing test name). + const secret = 'listOpen reflects complete()'; + const r = runtime({ env: { DEMO_API_TOKEN: secret } }); + const { runId, events } = await runMission(r); + expect(events.map((e) => e.type)).toEqual(codingMissionTypeSequence()); + expect(JSON.stringify(events)).not.toContain(secret); + const first = completionsOf(events, 'run_command')[0]!; + expect(first.visibility).toBe('redacted'); + expect(first.payload.failingTests).toEqual([M.failingTests[0], '[REDACTED]']); + for (const a of ofType(events, 'artifact.created')) { + expect(r.recorder.getArtifact(runId, a.payload.artifactId)!.content.toString()).not.toContain(secret); + } + }); +}); diff --git a/packages/runtime/test/helpers.ts b/packages/runtime/test/helpers.ts new file mode 100644 index 0000000..53cbb7e --- /dev/null +++ b/packages/runtime/test/helpers.ts @@ -0,0 +1,116 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach } from 'vitest'; +import { EventBus } from '../src/bus'; +import { findRepoRoot } from '../src/config'; +import { Recorder, type RunManifest } from '../src/recorder'; +import { openDb } from '../src/store/db'; + +export const REPO_ROOT = findRepoRoot(); +export const EXAMPLE_DIR = path.join(REPO_ROOT, 'examples', 'coding-mission'); + +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +/** A fresh temp dir, removed after the current test. */ +export function tempDir(label = 'test'): string { + const d = mkdtempSync(path.join(os.tmpdir(), `runloop-${label}-`)); + dirs.push(d); + return d; +} + +export const MANIFEST: RunManifest = { + title: 'Test run', + missionId: 'test-mission', + worldId: 'research-campus', + seed: 1, + adapter: { id: 'test', kind: 'scripted', label: 'Test adapter' }, + workspace: 'workspaces/test', +}; + +/** A recorder on a file DB in `dataDir`; listener errors are swallowed quietly. */ +export function openRecorder(dataDir: string, opts: { secrets?: string[] } = {}) { + const file = path.join(dataDir, 'runloop.db'); + const db = openDb(file); + const recorder = new Recorder({ db, dataDir, bus: new EventBus(() => {}), ...opts }); + return { db, file, recorder }; +} + +/** sha256 over every file (path + bytes) under `dir`, for "unchanged" checks. */ +export function hashTree(dir: string): string { + const h = createHash('sha256'); + const walk = (d: string) => { + for (const name of readdirSync(d).sort()) { + const p = path.join(d, name); + if (statSync(p).isDirectory()) walk(p); + else h.update(path.relative(dir, p)).update('\0').update(readFileSync(p)).update('\0'); + } + }; + walk(dir); + return h.digest('hex'); +} + +export interface SseFrame { + id?: string; + event?: string; + data?: string; + comment?: string; +} + +/** Incrementally parse an SSE response body. */ +export function sseReader(res: Response) { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const frames: SseFrame[] = []; + let done = false; + + const parse = () => { + let i: number; + while ((i = buffer.indexOf('\n\n')) >= 0) { + const block = buffer.slice(0, i); + buffer = buffer.slice(i + 2); + const f: SseFrame = {}; + for (const line of block.split('\n')) { + if (line.startsWith(':')) f.comment = line.slice(1).trim(); + else { + const [k, ...rest] = line.split(':'); + const v = rest.join(':').replace(/^ /, ''); + if (k === 'id') f.id = v; + else if (k === 'event') f.event = v; + else if (k === 'data') f.data = f.data === undefined ? v : `${f.data}\n${v}`; + } + } + if (Object.keys(f).length) frames.push(f); + } + }; + + return { + frames, + get done() { + return done; + }, + /** Read until `pred(frames)` holds or the stream ends. */ + async until(pred: (frames: SseFrame[]) => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!pred(frames) && !done) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`SSE timeout; got ${JSON.stringify(frames.slice(-3))}`); + const r = await Promise.race([ + reader.read(), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), remaining)), + ]); + if (r === 'timeout') continue; + if (r.done) done = true; + else buffer += decoder.decode(r.value, { stream: true }); + parse(); + } + return frames; + }, + cancel: () => reader.cancel(), + }; +} diff --git a/packages/runtime/test/http.test.ts b/packages/runtime/test/http.test.ts new file mode 100644 index 0000000..87b1215 --- /dev/null +++ b/packages/runtime/test/http.test.ts @@ -0,0 +1,142 @@ +import { apiPaths, type EventsPageResponse, type RunDetailResponse } from '@runloop-world/events'; +import { parseWorldDefinition } from '@runloop-world/world-engine'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createRuntime, type Runtime } from '../src/runtime'; +import { MANIFEST, REPO_ROOT, openRecorder, tempDir } from './helpers'; + +const JSON_HEADERS = { 'content-type': 'application/json' }; +let rt: Runtime | null = null; + +afterEach(async () => { + await rt?.close(); + rt = null; +}); + +function runtime(dataDir = tempDir('http')): Runtime { + rt = createRuntime({ repoRoot: REPO_ROOT, dataDir, env: {}, pingMs: 50 }); + return rt; +} + +function post(path: string, body: unknown = {}, headers: Record = JSON_HEADERS) { + return rt!.app.request(path, { method: 'POST', headers, body: JSON.stringify(body) }); +} + +describe('HTTP API', () => { + it('serves health', async () => { + const { app } = runtime(); + const res = await app.request(apiPaths.health); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, version: expect.any(String), activeRunId: null }); + }); + + it('only answers for localhost hosts (any port)', async () => { + const { app } = runtime(); + expect((await app.request(apiPaths.health, { headers: { host: 'evil.example' } })).status).toBe(403); + expect((await app.request(apiPaths.health, { headers: { host: 'localhost.evil.example:4317' } })).status).toBe(403); + expect((await app.request(apiPaths.health, { headers: { host: '127.0.0.1:4317' } })).status).toBe(200); + expect((await app.request(apiPaths.health, { headers: { host: 'localhost:9999' } })).status).toBe(200); + }); + + it('sends CORS headers only to allowed origins', async () => { + const { app } = runtime(); + for (const origin of ['http://localhost:3000', 'http://127.0.0.1:3000']) { + const res = await app.request(apiPaths.runs, { headers: { origin } }); + expect(res.headers.get('access-control-allow-origin')).toBe(origin); + } + const evil = await app.request(apiPaths.runs, { headers: { origin: 'http://evil.example' } }); + expect(evil.headers.get('access-control-allow-origin')).toBeNull(); + const preflight = await app.request(apiPaths.runs, { + method: 'OPTIONS', + headers: { origin: 'http://localhost:3000', 'access-control-request-method': 'POST', 'access-control-request-headers': 'content-type' }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get('access-control-allow-methods')).toContain('POST'); + }); + + it('honours RUNLOOP_ALLOWED_ORIGINS', async () => { + rt = createRuntime({ repoRoot: REPO_ROOT, dataDir: tempDir('http'), env: { RUNLOOP_ALLOWED_ORIGINS: 'http://localhost:5173' } }); + const res = await rt.app.request(apiPaths.runs, { headers: { origin: 'http://localhost:5173' } }); + expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173'); + }); + + it('guards POSTs: JSON only (415), allowed Origin only (403), valid body (400), known mission (404)', async () => { + runtime(); + expect((await post(apiPaths.runs, { missionId: 'coding-mission' }, { 'content-type': 'text/plain' })).status).toBe(415); + expect((await rt!.app.request(apiPaths.runs, { method: 'POST' })).status).toBe(415); + expect((await post(apiPaths.runs, { missionId: 'coding-mission' }, { ...JSON_HEADERS, origin: 'http://evil.example' })).status).toBe(403); + expect((await post(apiPaths.runs, {})).status).toBe(400); + expect((await post(apiPaths.runs, { missionId: 'coding-mission', speed: -1 })).status).toBe(400); + expect((await rt!.app.request(apiPaths.runs, { method: 'POST', headers: JSON_HEADERS, body: '{nope' })).status).toBe(400); + expect((await post(apiPaths.runs, { missionId: 'no-such-mission' })).status).toBe(404); + expect((await post(apiPaths.runs, { missionId: '../etc' })).status).toBe(404); + expect(rt!.recorder.listRuns()).toEqual([]); + }); + + it('starts a run (202), rejects a second one (409), and serves run, events and artifacts', { timeout: 30_000 }, async () => { + const { app, manager } = runtime(); + const res = await post(apiPaths.runs, { missionId: 'coding-mission', speed: 0 }, { ...JSON_HEADERS, origin: 'http://localhost:3000' }); + expect(res.status).toBe(202); + const { runId } = (await res.json()) as { runId: string }; + expect((await post(apiPaths.runs, { missionId: 'coding-mission', speed: 0 })).status).toBe(409); + expect(((await (await app.request(apiPaths.health)).json()) as { activeRunId: string }).activeRunId).toBe(runId); + + await manager.whenDone(runId); + + const list = (await (await app.request(apiPaths.runs)).json()) as { runs: { runId: string; status: string }[] }; + expect(list.runs).toEqual([expect.objectContaining({ runId, status: 'succeeded', lastSequence: 58, paused: false })]); + + const detail = (await (await app.request(apiPaths.run(runId))).json()) as RunDetailResponse; + expect(detail.run).toMatchObject({ runId, missionId: 'coding-mission', worldId: 'research-campus' }); + expect(parseWorldDefinition(detail.world).id).toBe('research-campus'); + + const page1 = (await (await app.request(`${apiPaths.events(runId)}?after=0&limit=10`)).json()) as EventsPageResponse; + expect(page1.events.map((e) => (e as { sequence: number }).sequence)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(page1).toMatchObject({ nextAfter: 10, hasMore: true }); + const page2 = (await (await app.request(`${apiPaths.events(runId)}?after=50&limit=100`)).json()) as EventsPageResponse; + expect(page2.events).toHaveLength(8); + expect(page2).toMatchObject({ nextAfter: 58, hasMore: false }); + const empty = (await (await app.request(`${apiPaths.events(runId)}?after=58`)).json()) as EventsPageResponse; + expect(empty).toEqual({ events: [], nextAfter: 58, hasMore: false }); + expect((await app.request(`${apiPaths.events(runId)}?after=-1`)).status).toBe(400); + expect((await app.request(`${apiPaths.events(runId)}?limit=abc`)).status).toBe(400); + + const art = await app.request(apiPaths.artifact(runId, 'art-2')); + expect(art.status).toBe(200); + expect(art.headers.get('content-type')).toBe('text/plain; charset=utf-8'); + expect(art.headers.get('x-content-type-options')).toBe('nosniff'); + const tap = await art.text(); + expect(tap).toContain('# fail 2'); + const file = await app.request(apiPaths.artifact(runId, 'art-1')); + expect(file.headers.get('content-type')).toBe('text/javascript; charset=utf-8'); + + // The run is over: controls conflict. + for (const action of ['stop', 'pause', 'resume'] as const) { + expect((await post(apiPaths[action](runId))).status).toBe(409); + } + }); + + it('answers 404 for unknown runs, artifacts and routes', async () => { + const { app } = runtime(); + const ghost = '22222222-2222-4222-8222-222222222222'; + expect((await app.request(apiPaths.run(ghost))).status).toBe(404); + expect((await app.request(apiPaths.events(ghost))).status).toBe(404); + expect((await app.request(apiPaths.stream(ghost))).status).toBe(404); + expect((await app.request(apiPaths.artifact(ghost, 'art-1'))).status).toBe(404); + expect((await post(apiPaths.stop(ghost))).status).toBe(404); + expect((await post(apiPaths.pause(ghost))).status).toBe(404); + expect((await app.request('/api/nope')).status).toBe(404); + }); + + it('lists a run left running by a dead process as interrupted after boot', async () => { + const dir = tempDir('http'); + const crashed = openRecorder(dir); + const runId = '33333333-3333-4333-8333-333333333333'; + crashed.recorder.createRun({ runId, manifest: MANIFEST }); + crashed.db.close(); + + const { app, recovered } = runtime(dir); + expect(recovered).toEqual([runId]); + const list = (await (await app.request(apiPaths.runs)).json()) as { runs: { status: string }[] }; + expect(list.runs).toEqual([expect.objectContaining({ runId, status: 'interrupted', lastSequence: 2 })]); + }); +}); diff --git a/packages/runtime/test/recorder.test.ts b/packages/runtime/test/recorder.test.ts new file mode 100644 index 0000000..b89ad55 --- /dev/null +++ b/packages/runtime/test/recorder.test.ts @@ -0,0 +1,173 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, expect, it } from 'vitest'; +import { collectEnvSecrets } from '../src/config'; +import { EventValidationError, RunClosedError } from '../src/recorder'; +import { MANIFEST, openRecorder, tempDir } from './helpers'; + +const RUN = '11111111-1111-4111-8111-111111111111'; + +describe('Recorder', () => { + it('assigns contiguous sequences from 1 and derives actor/task refs from the payload', () => { + const { recorder } = openRecorder(tempDir()); + const started = recorder.createRun({ runId: RUN, manifest: MANIFEST }); + const a = recorder.append(RUN, { type: 'task.assigned', payload: { taskId: 'T1', agentId: 'builder' }, actorId: 'coordinator' }); + const b = recorder.append(RUN, { type: 'task.started', payload: { taskId: 'T1', agentId: 'builder' } }); + expect([started.sequence, a.sequence, b.sequence]).toEqual([1, 2, 3]); + expect(started.actorId).toBe('system'); + expect(a).toMatchObject({ actorId: 'coordinator', taskId: 'T1', visibility: 'public', schemaVersion: 1 }); + expect(b).toMatchObject({ actorId: 'builder', taskId: 'T1' }); + expect(recorder.list(RUN).map((e) => e.sequence)).toEqual([1, 2, 3]); + expect(recorder.getRun(RUN)).toMatchObject({ status: 'running', lastSequence: 3, paused: false }); + }); + + it('persists before publishing (a second connection sees each event inside the listener)', () => { + const { recorder, file } = openRecorder(tempDir()); + const second = new DatabaseSync(file, { readOnly: true }); + const seen: { seq: number; persisted: boolean; lastSequence: number }[] = []; + recorder.subscribe(RUN, (e) => { + const row = second.prepare('SELECT sequence FROM events WHERE run_id = ? AND sequence = ?').get(RUN, e.sequence); + const run = second.prepare('SELECT last_sequence FROM runs WHERE run_id = ?').get(RUN) as { last_sequence: number }; + seen.push({ seq: e.sequence, persisted: row !== undefined, lastSequence: run.last_sequence }); + }); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'working' } }); + recorder.append(RUN, { type: 'run.completed', payload: { outcome: 'succeeded' } }); + expect(seen).toEqual([ + { seq: 1, persisted: true, lastSequence: 1 }, + { seq: 2, persisted: true, lastSequence: 2 }, + { seq: 3, persisted: true, lastSequence: 3 }, + ]); + second.close(); + }); + + it('keeps appending when a subscriber throws', () => { + const { recorder } = openRecorder(tempDir()); + const got: number[] = []; + recorder.subscribe(RUN, () => { + throw new Error('boom'); + }); + recorder.subscribe(RUN, (e) => got.push(e.sequence)); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'idle' } }); + expect(got).toEqual([1, 2]); + }); + + it('rejects an invalid draft without consuming a sequence', () => { + const { recorder } = openRecorder(tempDir()); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + const bad = { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'dancing' } }; + expect(() => recorder.append(RUN, bad as never)).toThrow(EventValidationError); + expect(() => recorder.append(RUN, { type: 'no.such_type', payload: {} } as never)).toThrow(EventValidationError); + const next = recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'idle' } }); + expect(next.sequence).toBe(2); + expect(recorder.getRun(RUN)?.lastSequence).toBe(2); + }); + + it('redacts env secrets in payloads and artifacts, and marks the event redacted', () => { + const secret = 'hunter2-super-secret-value'; + const secrets = collectEnvSecrets({ MY_API_TOKEN: secret, HOME: '/home/someone/very/long', SHORT_KEY: 'abc' }); + expect(secrets).toEqual([secret]); + const dir = tempDir(); + const { recorder, db, file } = openRecorder(dir, { secrets }); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + const e = recorder.append(RUN, { + type: 'agent.message_sent', + payload: { messageId: 'msg-1', fromAgentId: 'builder', to: 'tester', text: `token is ${secret}, ok?` }, + }); + expect(e.visibility).toBe('redacted'); + expect(e.type === 'agent.message_sent' && e.payload.text).toBe('token is [REDACTED], ok?'); + const clean = recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'idle' } }); + expect(clean.visibility).toBe('public'); + + const { bytes } = recorder.putArtifactContent(RUN, 'art-1', `log line with ${secret}\n`); + expect(bytes).toBe(Buffer.byteLength('log line with [REDACTED]\n')); + + db.close(); + for (const f of [file, `${file}-wal`, `${dir}/artifacts/${RUN}/art-1`]) { + if (existsSync(f)) expect(readFileSync(f).includes(secret)).toBe(false); + } + }); + + it('continues the sequence after the database is reopened', () => { + const dir = tempDir(); + const first = openRecorder(dir); + first.recorder.createRun({ runId: RUN, manifest: MANIFEST }); + first.recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'working' } }); + const before = first.recorder.list(RUN); + first.db.close(); + + const second = openRecorder(dir); + expect(second.recorder.list(RUN)).toEqual(before); + const next = second.recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'idle' } }); + expect(next.sequence).toBe(3); + expect(second.recorder.listRuns().map((r) => r.runId)).toEqual([RUN]); + }); + + it('marks runs left running as interrupted, failing dangling tool calls first', () => { + const dir = tempDir(); + const crashed = openRecorder(dir); + crashed.recorder.createRun({ runId: RUN, manifest: MANIFEST }); + const done = crashed.recorder.append(RUN, { + type: 'tool.call_started', + payload: { callId: 'call-1', agentId: 'builder', taskId: 'T2', tool: 'read_file', inputSummary: 'a.js' }, + }); + crashed.recorder.append(RUN, { + type: 'tool.call_completed', + payload: { callId: 'call-1', status: 'success', durationMs: 1, outputSummary: 'read 1 bytes' }, + parentEventId: done.eventId, + }); + const dangling = crashed.recorder.append(RUN, { + type: 'tool.call_started', + payload: { callId: 'call-2', agentId: 'tester', taskId: 'T3', tool: 'run_command', inputSummary: 'node --test' }, + }); + crashed.db.close(); // the process "dies" here + + const rebooted = openRecorder(dir); + expect(rebooted.recorder.recoverInterrupted()).toEqual([RUN]); + const tail = rebooted.recorder.list(RUN, { after: 4 }); + expect(tail.map((e) => e.type)).toEqual(['tool.call_failed', 'run.completed']); + expect(tail[0]).toMatchObject({ + sequence: 5, + parentEventId: dangling.eventId, + actorId: 'tester', + taskId: 'T3', + payload: { callId: 'call-2', errorType: 'runtime_restarted' }, + }); + expect(tail[1]).toMatchObject({ payload: { outcome: 'interrupted' } }); + expect(rebooted.recorder.getRun(RUN)).toMatchObject({ status: 'interrupted', lastSequence: 6 }); + // Recovery is idempotent. + expect(rebooted.recorder.recoverInterrupted()).toEqual([]); + }); + + it('refuses events after run.completed', () => { + const { recorder } = openRecorder(tempDir()); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + recorder.append(RUN, { type: 'run.completed', payload: { outcome: 'stopped' } }); + expect(() => + recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'idle' } }), + ).toThrow(RunClosedError); + expect(recorder.getRun(RUN)).toMatchObject({ status: 'stopped', lastSequence: 2 }); + expect(recorder.getRun(RUN)?.endedAt).not.toBeNull(); + }); + + it('tracks paused state and indexes artifacts from artifact.created', () => { + const { recorder } = openRecorder(tempDir()); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + recorder.append(RUN, { type: 'run.paused', payload: {} }); + expect(recorder.getRun(RUN)?.paused).toBe(true); + recorder.append(RUN, { type: 'run.resumed', payload: {} }); + expect(recorder.getRun(RUN)?.paused).toBe(false); + + const { bytes, sha256 } = recorder.putArtifactContent(RUN, 'art-1', '# pass 1\n'); + expect(recorder.getArtifact(RUN, 'art-1')).toBeNull(); // not indexed until the event is recorded + recorder.append(RUN, { + type: 'artifact.created', + payload: { artifactId: 'art-1', kind: 'log', name: 'out.log', mediaType: 'text/plain', bytes, sha256, summary: 'log' }, + }); + const a = recorder.getArtifact(RUN, 'art-1'); + expect(a).toMatchObject({ name: 'out.log', mediaType: 'text/plain', bytes, sha256 }); + expect(a?.content.toString()).toBe('# pass 1\n'); + expect(recorder.getArtifact(RUN, '../runloop.db')).toBeNull(); + }); +}); diff --git a/packages/runtime/test/sandbox.test.ts b/packages/runtime/test/sandbox.test.ts new file mode 100644 index 0000000..f77f49d --- /dev/null +++ b/packages/runtime/test/sandbox.test.ts @@ -0,0 +1,212 @@ +import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { isAllowedCommand, runCommand } from '../src/sandbox/command'; +import { MAX_FILE_BYTES, ToolReportedError, listFiles, readWorkspaceFile, writeWorkspaceFile } from '../src/sandbox/fs-tools'; +import { SandboxError, resolveInside } from '../src/sandbox/paths'; +import { parseTap } from '../src/sandbox/tap'; +import { tempDir } from './helpers'; + +const TEST_ARGV = ['node', '--test', '--test-reporter=tap']; + +function workspace(): { ws: string; outside: string } { + const root = tempDir('sandbox'); + const ws = path.join(root, 'ws'); + const outside = path.join(root, 'outside'); + mkdirSync(path.join(ws, 'src'), { recursive: true }); + mkdirSync(outside); + writeFileSync(path.join(ws, 'src', 'a.js'), 'export const a = 1;\n'); + writeFileSync(path.join(outside, 'secret.txt'), 'top secret'); + return { ws, outside }; +} + +function denied(fn: () => unknown): SandboxError { + try { + fn(); + } catch (err) { + expect(err).toBeInstanceOf(SandboxError); + return err as SandboxError; + } + throw new Error('expected a SandboxError'); +} + +describe('resolveInside', () => { + it('accepts paths inside the workspace', () => { + const { ws } = workspace(); + expect(resolveInside(ws, 'src/a.js')).toBe(path.join(ws, 'src', 'a.js')); + expect(resolveInside(ws, './src/../src/new/file.js')).toBe(path.join(ws, 'src', 'new', 'file.js')); + }); + + it('rejects traversal, absolute paths, NUL bytes and empty paths', () => { + const { ws, outside } = workspace(); + expect(denied(() => resolveInside(ws, '../outside/secret.txt')).code).toBe('path_escape'); + expect(denied(() => resolveInside(ws, 'src/../../outside')).code).toBe('path_escape'); + expect(denied(() => resolveInside(ws, path.join(outside, 'secret.txt'))).code).toBe('path_escape'); + expect(denied(() => resolveInside(ws, 'src/a.js\0.txt')).code).toBe('invalid_path'); + expect(denied(() => resolveInside(ws, '')).code).toBe('invalid_path'); + }); + + it('rejects symlink escapes (file, directory and dangling links)', () => { + const { ws, outside } = workspace(); + symlinkSync(path.join(outside, 'secret.txt'), path.join(ws, 'link.txt')); + symlinkSync(outside, path.join(ws, 'linkdir')); + symlinkSync(path.join(outside, 'not-yet.txt'), path.join(ws, 'dangling.txt')); + expect(() => readWorkspaceFile(ws, 'link.txt')).toThrow(SandboxError); + expect(() => writeWorkspaceFile(ws, 'link.txt', 'x')).toThrow(SandboxError); + expect(() => readWorkspaceFile(ws, 'linkdir/secret.txt')).toThrow(SandboxError); + expect(() => writeWorkspaceFile(ws, 'linkdir/new/file.txt', 'x')).toThrow(SandboxError); + expect(() => writeWorkspaceFile(ws, 'dangling.txt', 'x')).toThrow(SandboxError); + expect(readFileSync(path.join(outside, 'secret.txt'), 'utf8')).toBe('top secret'); + }); + + it('allows a symlink that stays inside', () => { + const { ws } = workspace(); + symlinkSync(path.join(ws, 'src', 'a.js'), path.join(ws, 'alias.js')); + expect(readWorkspaceFile(ws, 'alias.js').content).toContain('export const a'); + }); +}); + +describe('fs tools', () => { + it('caps reads and writes at 256 KB', () => { + const { ws } = workspace(); + writeFileSync(path.join(ws, 'big.txt'), 'x'.repeat(MAX_FILE_BYTES + 1)); + expect(denied(() => readWorkspaceFile(ws, 'big.txt')).code).toBe('too_large'); + expect(denied(() => writeWorkspaceFile(ws, 'big2.txt', 'y'.repeat(MAX_FILE_BYTES + 1))).code).toBe('too_large'); + }); + + it('reports a missing file as a tool error, not a sandbox denial', () => { + const { ws } = workspace(); + expect(() => readWorkspaceFile(ws, 'nope.js')).toThrow(ToolReportedError); + }); + + it('lists recursively, sorted, skipping node_modules and .git', () => { + const { ws } = workspace(); + mkdirSync(path.join(ws, 'node_modules', 'x'), { recursive: true }); + mkdirSync(path.join(ws, '.git')); + writeFileSync(path.join(ws, 'README.md'), '#'); + expect(listFiles(ws).entries).toEqual(['README.md', 'src/', 'src/a.js']); + }); +}); + +describe('runCommand', () => { + afterEach(() => { + delete process.env.RUNLOOP_SANDBOX_TEST_TOKEN; + }); + + it('allows only the exact test argv', () => { + expect(isAllowedCommand(TEST_ARGV)).toBe(true); + for (const argv of [ + ['node', '-e', 'process.exit(0)'], + ['rm', '-rf', '/'], + ['node', '--test'], + [...TEST_ARGV, 'extra.js'], + ['bash', '-c', 'node --test --test-reporter=tap'], + ['/usr/bin/node', '--test', '--test-reporter=tap'], + ]) { + expect(isAllowedCommand(argv)).toBe(false); + expect(() => runCommand(argv, { cwd: '.' })).toThrow(SandboxError); + } + }); + + it('gives the child a minimal env: no secrets, no NODE_OPTIONS, HOME = workspace', { timeout: 20_000 }, async () => { + const { ws } = workspace(); + process.env.RUNLOOP_SANDBOX_TEST_TOKEN = 'leak-me-if-you-can-123'; + mkdirSync(path.join(ws, 'test')); + writeFileSync( + path.join(ws, 'test', 'env.test.mjs'), + `import { test } from 'node:test'; +import { writeFileSync } from 'node:fs'; +test('dump env', () => { writeFileSync(new URL('../env.json', import.meta.url), JSON.stringify(process.env)); }); +`, + ); + const r = await runCommand(TEST_ARGV, { cwd: ws }); + expect(r.exitCode).toBe(0); + const env = JSON.parse(readFileSync(path.join(ws, 'env.json'), 'utf8')) as Record; + expect(JSON.stringify(env)).not.toContain('leak-me-if-you-can-123'); + expect(env.RUNLOOP_SANDBOX_TEST_TOKEN).toBeUndefined(); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.HOME).toBe(ws); + expect(env.NO_COLOR).toBe('1'); + }); + + it('kills the whole process group on timeout', { timeout: 20_000 }, async () => { + const { ws } = workspace(); + mkdirSync(path.join(ws, 'test')); + writeFileSync( + path.join(ws, 'test', 'hang.test.mjs'), + `import { test } from 'node:test'; +import { writeFileSync } from 'node:fs'; +test('hang', () => { writeFileSync(new URL('../pid', import.meta.url), String(process.pid)); return new Promise(() => setInterval(() => {}, 1000)); }); +`, + ); + const r = await runCommand(TEST_ARGV, { cwd: ws, timeoutMs: 3000 }); + expect(r.timedOut).toBe(true); + expect(r.signal).toBe('SIGKILL'); + expect(r.durationMs).toBeLessThan(10_000); + // The test-file subprocess (a grandchild) must be gone too (or a zombie awaiting reaping). + const pid = Number(readFileSync(path.join(ws, 'pid'), 'utf8')); + await new Promise((r) => setTimeout(r, 200)); + let alive = true; + try { + process.kill(pid, 0); + alive = !/\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); + } catch { + alive = false; + } + expect(alive).toBe(false); + }); + + it('kills the child when aborted', { timeout: 20_000 }, async () => { + const { ws } = workspace(); + mkdirSync(path.join(ws, 'test')); + writeFileSync(path.join(ws, 'test', 'hang.test.mjs'), `import { test } from 'node:test';\ntest('hang', () => new Promise(() => setInterval(() => {}, 1000)));\n`); + const ac = new AbortController(); + setTimeout(() => ac.abort(), 300); + const r = await runCommand(TEST_ARGV, { cwd: ws, signal: ac.signal }); + expect(r.aborted).toBe(true); + expect(r.timedOut).toBe(false); + }); +}); + +describe('parseTap', () => { + it('reads the footer counts and leaf failure names (not failing suites)', () => { + const tap = [ + 'TAP version 13', + '# Subtest: ok one', + 'ok 1 - ok one', + ' ---', + " type: 'test'", + ' ...', + '# Subtest: group', + ' # Subtest: inner \\# hash', + ' not ok 1 - inner \\# hash', + ' ---', + " type: 'test'", + " error: 'nope'", + ' ...', + ' 1..1', + 'not ok 2 - group', + ' ---', + " type: 'suite'", + ' ...', + 'not ok 3 - top level', + ' ---', + " type: 'test'", + ' ...', + 'ok 4 - skipped one # SKIP', + '1..4', + '# tests 4', + '# suites 1', + '# pass 2', + '# fail 2', + ].join('\n'); + expect(parseTap(tap)).toEqual({ + metrics: { tests: 4, passed: 2, failed: 2 }, + failingTests: ['inner # hash', 'top level'], + }); + }); + + it('leaves metrics out when there is no footer', () => { + expect(parseTap('garbage\n')).toEqual({ failingTests: [] }); + }); +}); diff --git a/packages/runtime/test/sse.test.ts b/packages/runtime/test/sse.test.ts new file mode 100644 index 0000000..333b42f --- /dev/null +++ b/packages/runtime/test/sse.test.ts @@ -0,0 +1,100 @@ +import { apiPaths, type RunEvent } from '@runloop-world/events'; +import { Hono } from 'hono'; +import { afterEach, describe, expect, it } from 'vitest'; +import { streamRunEvents } from '../src/http/sse'; +import type { Recorder } from '../src/recorder'; +import { createRuntime, type Runtime } from '../src/runtime'; +import { MANIFEST, REPO_ROOT, sseReader, tempDir } from './helpers'; + +const RUN = '44444444-4444-4444-8444-444444444444'; +let rt: Runtime | null = null; + +afterEach(async () => { + await rt?.close(); + rt = null; +}); + +function setup(events: number): Runtime { + rt = createRuntime({ repoRoot: REPO_ROOT, dataDir: tempDir('sse'), env: {}, pingMs: 50 }); + rt.recorder.createRun({ runId: RUN, manifest: MANIFEST }); + for (let i = 1; i < events; i++) tick(rt.recorder); + return rt; +} + +function tick(recorder: Recorder): RunEvent { + return recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'working' } }); +} + +const ids = (frames: { id?: string }[]) => frames.filter((f) => f.id !== undefined).map((f) => Number(f.id)); + +describe('SSE stream', () => { + it('resumes after Last-Event-ID, then follows live events without duplicates or gaps, then ends', async () => { + const { app, recorder } = setup(10); + const res = await app.request(apiPaths.stream(RUN), { headers: { 'last-event-id': '4' } }); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + const sse = sseReader(res); + await sse.until((f) => ids(f).includes(10)); + tick(recorder); + tick(recorder); + await sse.until((f) => ids(f).includes(12)); + recorder.append(RUN, { type: 'run.completed', payload: { outcome: 'succeeded' } }); + const frames = await sse.until(() => false); + expect(sse.done).toBe(true); + expect(ids(frames)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 13]); + const messages = frames.filter((f) => f.id !== undefined); + expect(messages.every((f) => f.event === undefined)).toBe(true); // default "message" events + expect(JSON.parse(messages[0]!.data!)).toMatchObject({ sequence: 5, runId: RUN }); + const end = frames.at(-1)!; + expect(end.event).toBe('end'); + expect(JSON.parse(end.data!)).toEqual({ status: 'succeeded', lastSequence: 13 }); + }); + + it('accepts ?after and rejects a malformed Last-Event-ID', async () => { + const { app, recorder } = setup(5); + recorder.append(RUN, { type: 'run.completed', payload: { outcome: 'failed' } }); + const frames = await sseReader(await app.request(`${apiPaths.stream(RUN)}?after=3`)).until(() => false); + expect(ids(frames)).toEqual([4, 5, 6]); + expect(frames.at(-1)?.event).toBe('end'); + expect((await app.request(apiPaths.stream(RUN), { headers: { 'last-event-id': 'abc' } })).status).toBe(400); + }); + + it('ends immediately for a terminal run that the client already has', async () => { + const { app, recorder } = setup(3); + recorder.append(RUN, { type: 'run.completed', payload: { outcome: 'stopped' } }); + const frames = await sseReader(await app.request(apiPaths.stream(RUN), { headers: { 'last-event-id': '4' } })).until(() => false); + expect(ids(frames)).toEqual([]); + expect(JSON.parse(frames.at(-1)!.data!)).toEqual({ status: 'stopped', lastSequence: 4 }); + }); + + it('sends an event appended during the backfill exactly once', async () => { + const { recorder } = setup(5); + // Simulate the race: the event lands after subscribe() but before (or while) the DB is read, + // so it is both in the backfill page and in the live queue. + const racy = Object.create(recorder) as Recorder; + let injected = false; + racy.list = (runId, opts) => { + if (!injected) { + injected = true; + tick(recorder); + } + return recorder.list(runId, opts); + }; + const app = new Hono().get('/s', (c) => streamRunEvents(c, { recorder: racy, runId: RUN, after: 0, pingMs: 50 })); + const sse = sseReader(await app.request('/s')); + await sse.until((f) => ids(f).includes(6)); + tick(recorder); + await sse.until((f) => ids(f).includes(7)); + await new Promise((r) => setTimeout(r, 120)); // let any duplicate arrive + await sse.until(() => true); + expect(ids(sse.frames)).toEqual([1, 2, 3, 4, 5, 6, 7]); + await sse.cancel(); + }); + + it('keeps an idle stream alive with ": ping" comments', async () => { + const { app } = setup(1); + const sse = sseReader(await app.request(apiPaths.stream(RUN))); + const frames = await sse.until((f) => f.some((x) => x.comment === 'ping')); + expect(ids(frames)).toEqual([1]); + await sse.cancel(); + }); +}); diff --git a/packages/runtime/test/tool-invoker.test.ts b/packages/runtime/test/tool-invoker.test.ts new file mode 100644 index 0000000..0d495ad --- /dev/null +++ b/packages/runtime/test/tool-invoker.test.ts @@ -0,0 +1,72 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { isAbortError } from '../src/adapter'; +import { ToolInvoker } from '../src/tool-invoker'; +import { MANIFEST, openRecorder, tempDir } from './helpers'; + +const RUN = '55555555-5555-4555-8555-555555555555'; + +function setup(secrets: string[] = []) { + const dir = tempDir('invoker'); + const ws = path.join(dir, 'ws'); + mkdirSync(path.join(ws, 'src'), { recursive: true }); + writeFileSync(path.join(ws, 'src', 'a.js'), 'export const a = 1;\n'); + const { recorder } = openRecorder(dir, { secrets }); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + const ac = new AbortController(); + const invoker = new ToolInvoker({ recorder, runId: RUN, workspaceDir: ws, signal: ac.signal }); + return { dir, ws, recorder, invoker, ac }; +} + +describe('ToolInvoker', () => { + it('records started → completed for a read, and returns the content to the agent', async () => { + const { recorder, invoker } = setup(); + const r = await invoker.invoke('builder', 'T2', { tool: 'read_file', path: 'src/a.js' }); + expect(r).toMatchObject({ callId: 'call-1', status: 'success', outputSummary: 'read 20 bytes', output: 'export const a = 1;\n' }); + const [, started, done] = recorder.list(RUN); + expect(started).toMatchObject({ type: 'tool.call_started', actorId: 'builder', taskId: 'T2', payload: { tool: 'read_file', inputSummary: 'src/a.js' } }); + expect(done).toMatchObject({ type: 'tool.call_completed', parentEventId: started!.eventId, actorId: 'builder', taskId: 'T2' }); + expect(JSON.stringify(done)).not.toContain('export const'); // file content is not put in events + }); + + it('records a sandbox denial as tool.call_failed{denied}', async () => { + const { recorder, invoker } = setup(); + const r = await invoker.invoke('builder', 'T2', { tool: 'write_file', path: '../escape.js', content: 'x' }); + expect(r).toMatchObject({ status: 'failed', errorType: 'denied' }); + const cmd = await invoker.invoke('tester', 'T3', { tool: 'run_command', argv: ['node', '-e', '1'] }); + expect(cmd).toMatchObject({ callId: 'call-2', status: 'failed', errorType: 'denied' }); + const types = recorder.list(RUN).map((e) => e.type); + expect(types).toEqual(['run.started', 'tool.call_started', 'tool.call_failed', 'tool.call_started', 'tool.call_failed']); + }); + + it('records a missing file as a completed call with status error', async () => { + const { recorder, invoker } = setup(); + const r = await invoker.invoke('builder', 'T2', { tool: 'read_file', path: 'src/missing.js' }); + expect(r).toMatchObject({ status: 'error', errorType: 'not_found' }); + expect(recorder.list(RUN).at(-1)).toMatchObject({ type: 'tool.call_completed', payload: { status: 'error', errorType: 'not_found' } }); + }); + + it('stores written files as redacted artifacts linked to the call', async () => { + const secret = 'sk-live-0123456789abcdefXYZ'; + const { ws, recorder, invoker } = setup(); + const r = await invoker.invoke('builder', 'T2', { tool: 'write_file', path: 'src/b.js', content: `const key = "${secret}";\n`, summary: 'b' }); + expect(readFileSync(path.join(ws, 'src', 'b.js'), 'utf8')).toContain(secret); // the workspace file is the real one + const [, started, artifact, done] = recorder.list(RUN); + expect(artifact).toMatchObject({ + type: 'artifact.created', + parentEventId: started!.eventId, + payload: { artifactId: 'art-1', kind: 'file', name: 'b.js', path: 'src/b.js', mediaType: 'text/javascript', producedByCallId: 'call-1' }, + }); + expect(done).toMatchObject({ payload: { outputArtifactIds: ['art-1'] } }); + expect(r.artifactIds).toEqual(['art-1']); + expect(recorder.getArtifact(RUN, 'art-1')!.content.toString()).toBe('const key = "[REDACTED]";\n'); + }); + + it('refuses to start a call once the run is stopped', async () => { + const { recorder, invoker, ac } = setup(); + ac.abort(); + await expect(invoker.invoke('builder', 'T2', { tool: 'list_files' })).rejects.toSatisfy(isAbortError); + expect(recorder.list(RUN)).toHaveLength(1); + }); +}); diff --git a/packages/runtime/test/trace-printer.test.ts b/packages/runtime/test/trace-printer.test.ts new file mode 100644 index 0000000..959bff8 --- /dev/null +++ b/packages/runtime/test/trace-printer.test.ts @@ -0,0 +1,16 @@ +import { codingMissionFixture } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { TracePrinter } from '../src/trace-printer'; + +describe('TracePrinter', () => { + it('prints one line per event from payload fields', () => { + const p = new TracePrinter(); + const lines = codingMissionFixture().map((e) => p.format(e)); + expect(lines).toHaveLength(58); + expect(lines[0]).toMatch(/^#001 00:00\.0 system {6}run\.started {10}"Build a task store/); + const failed = lines.find((l) => l.includes('run_command error'))!; + expect(failed).toMatch(/^#036 \d\d:\d\d\.\d tester {6}tool\.call_completed run_command error 12 tests: 10 passed, 2 failed/); + expect(lines.find((l) => l.includes('task.started') && l.includes('T3 attempt 2'))).toBeDefined(); + expect(lines.at(-1)).toContain('run.completed SUCCEEDED'); + }); +}); diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 5802eaa..151933d 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -5,5 +5,7 @@ export default defineProject({ name: 'runtime', environment: 'node', include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + // node:sqlite is experimental on Node 22; hide its startup warning in test workers. + execArgv: ['--disable-warning=ExperimentalWarning'], }, }); From b696edb46f3aafcd7a96a4bb971e7eebfa2da0c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:42:13 +0000 Subject: [PATCH 10/18] Add Runloop Studio with the 3D world Next.js static-export Studio: runs sidebar, timeline/logs/state/metadata tabs, agent details with artifact viewer, replay bar with a time-based scrubber and live/replay modes, and keyboard controls. Live data comes over resumable SSE through the sequence buffer and the replay index. The React Three Fiber scene is data-driven from the world definition: a dark diorama board with cutaway rooms, glowing paths with message packets, stations with screens and incident beacons, and chibi robots whose position comes only from the world engine and whose eyes and badges show their real state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- apps/studio/next.config.ts | 4 + apps/studio/src/app/globals.css | 232 ++++++++ apps/studio/src/app/layout.tsx | 16 + apps/studio/src/app/page.tsx | 5 + .../src/components/AgentDetails.module.css | 203 +++++++ apps/studio/src/components/AgentDetails.tsx | 236 ++++++++ .../src/components/AgentFlow.module.css | 77 +++ apps/studio/src/components/AgentFlow.tsx | 41 ++ .../src/components/ArtifactViewer.module.css | 76 +++ apps/studio/src/components/ArtifactViewer.tsx | 115 ++++ .../studio/src/components/CharacterRoster.tsx | 43 ++ .../src/components/InfoPanels.module.css | 61 ++ .../src/components/LogsPanel.module.css | 125 ++++ apps/studio/src/components/LogsPanel.tsx | 172 ++++++ apps/studio/src/components/MetadataPanel.tsx | 89 +++ .../src/components/ReplayBar.module.css | 151 +++++ apps/studio/src/components/ReplayBar.tsx | 127 ++++ .../src/components/RunHeader.module.css | 70 +++ apps/studio/src/components/RunHeader.tsx | 97 +++ apps/studio/src/components/Sidebar.module.css | 151 +++++ apps/studio/src/components/Sidebar.tsx | 120 ++++ apps/studio/src/components/StatePanel.tsx | 47 ++ apps/studio/src/components/StatusBadge.tsx | 11 + apps/studio/src/components/Studio.module.css | 72 +++ apps/studio/src/components/Studio.tsx | 90 +++ .../src/components/TimelinePanel.module.css | 124 ++++ apps/studio/src/components/TimelinePanel.tsx | 115 ++++ .../src/components/WorldView.module.css | 101 ++++ apps/studio/src/components/WorldView.tsx | 181 ++++++ apps/studio/src/lib/api.ts | 83 +++ apps/studio/src/lib/config.ts | 8 + apps/studio/src/scene/Board.tsx | 415 +++++++++++++ apps/studio/src/scene/CameraRig.tsx | 169 ++++++ apps/studio/src/scene/Character.tsx | 442 ++++++++++++++ apps/studio/src/scene/Characters.tsx | 35 ++ apps/studio/src/scene/Effects.tsx | 406 +++++++++++++ apps/studio/src/scene/Icon.tsx | 28 + apps/studio/src/scene/Paths.tsx | 77 +++ apps/studio/src/scene/Props.tsx | 162 +++++ apps/studio/src/scene/Stations.tsx | 448 ++++++++++++++ apps/studio/src/scene/WorldCanvas.tsx | 113 ++++ apps/studio/src/scene/badges.ts | 77 +++ apps/studio/src/scene/context.tsx | 74 +++ apps/studio/src/scene/labels.tsx | 183 ++++++ apps/studio/src/scene/layout.test.ts | 99 ++++ apps/studio/src/scene/layout.ts | 264 +++++++++ apps/studio/src/scene/palette.ts | 37 ++ apps/studio/src/scene/scene.module.css | 144 +++++ apps/studio/src/scene/textures.ts | 146 +++++ apps/studio/src/state/connect.ts | 125 ++++ apps/studio/src/state/context.tsx | 21 + apps/studio/src/state/hooks.ts | 107 ++++ apps/studio/src/state/keyboard.test.ts | 31 + apps/studio/src/state/keyboard.ts | 34 ++ apps/studio/src/state/playback.test.ts | 44 ++ apps/studio/src/state/playback.ts | 40 ++ apps/studio/src/state/store.test.ts | 216 +++++++ apps/studio/src/state/store.ts | 554 ++++++++++++++++++ apps/studio/src/state/test-utils.ts | 49 ++ apps/studio/src/state/timeline.test.ts | 76 +++ apps/studio/src/state/timeline.ts | 202 +++++++ apps/studio/src/state/view.test.ts | 55 ++ apps/studio/src/state/view.ts | 118 ++++ 63 files changed, 8034 insertions(+) create mode 100644 apps/studio/src/app/globals.css create mode 100644 apps/studio/src/app/layout.tsx create mode 100644 apps/studio/src/app/page.tsx create mode 100644 apps/studio/src/components/AgentDetails.module.css create mode 100644 apps/studio/src/components/AgentDetails.tsx create mode 100644 apps/studio/src/components/AgentFlow.module.css create mode 100644 apps/studio/src/components/AgentFlow.tsx create mode 100644 apps/studio/src/components/ArtifactViewer.module.css create mode 100644 apps/studio/src/components/ArtifactViewer.tsx create mode 100644 apps/studio/src/components/CharacterRoster.tsx create mode 100644 apps/studio/src/components/InfoPanels.module.css create mode 100644 apps/studio/src/components/LogsPanel.module.css create mode 100644 apps/studio/src/components/LogsPanel.tsx create mode 100644 apps/studio/src/components/MetadataPanel.tsx create mode 100644 apps/studio/src/components/ReplayBar.module.css create mode 100644 apps/studio/src/components/ReplayBar.tsx create mode 100644 apps/studio/src/components/RunHeader.module.css create mode 100644 apps/studio/src/components/RunHeader.tsx create mode 100644 apps/studio/src/components/Sidebar.module.css create mode 100644 apps/studio/src/components/Sidebar.tsx create mode 100644 apps/studio/src/components/StatePanel.tsx create mode 100644 apps/studio/src/components/StatusBadge.tsx create mode 100644 apps/studio/src/components/Studio.module.css create mode 100644 apps/studio/src/components/Studio.tsx create mode 100644 apps/studio/src/components/TimelinePanel.module.css create mode 100644 apps/studio/src/components/TimelinePanel.tsx create mode 100644 apps/studio/src/components/WorldView.module.css create mode 100644 apps/studio/src/components/WorldView.tsx create mode 100644 apps/studio/src/lib/api.ts create mode 100644 apps/studio/src/lib/config.ts create mode 100644 apps/studio/src/scene/Board.tsx create mode 100644 apps/studio/src/scene/CameraRig.tsx create mode 100644 apps/studio/src/scene/Character.tsx create mode 100644 apps/studio/src/scene/Characters.tsx create mode 100644 apps/studio/src/scene/Effects.tsx create mode 100644 apps/studio/src/scene/Icon.tsx create mode 100644 apps/studio/src/scene/Paths.tsx create mode 100644 apps/studio/src/scene/Props.tsx create mode 100644 apps/studio/src/scene/Stations.tsx create mode 100644 apps/studio/src/scene/WorldCanvas.tsx create mode 100644 apps/studio/src/scene/badges.ts create mode 100644 apps/studio/src/scene/context.tsx create mode 100644 apps/studio/src/scene/labels.tsx create mode 100644 apps/studio/src/scene/layout.test.ts create mode 100644 apps/studio/src/scene/layout.ts create mode 100644 apps/studio/src/scene/palette.ts create mode 100644 apps/studio/src/scene/scene.module.css create mode 100644 apps/studio/src/scene/textures.ts create mode 100644 apps/studio/src/state/connect.ts create mode 100644 apps/studio/src/state/context.tsx create mode 100644 apps/studio/src/state/hooks.ts create mode 100644 apps/studio/src/state/keyboard.test.ts create mode 100644 apps/studio/src/state/keyboard.ts create mode 100644 apps/studio/src/state/playback.test.ts create mode 100644 apps/studio/src/state/playback.ts create mode 100644 apps/studio/src/state/store.test.ts create mode 100644 apps/studio/src/state/store.ts create mode 100644 apps/studio/src/state/test-utils.ts create mode 100644 apps/studio/src/state/timeline.test.ts create mode 100644 apps/studio/src/state/timeline.ts create mode 100644 apps/studio/src/state/view.test.ts create mode 100644 apps/studio/src/state/view.ts diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index e972194..240aaa5 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -6,6 +6,10 @@ const nextConfig: NextConfig = { // Lets a second dev server (e.g. a scene preview) run from the same folder. distDir: process.env.NEXT_DIST_DIR ?? '.next', reactStrictMode: true, + // Don't (re)generate an AGENTS.md file on `next dev`. + agentRules: false, + // The dev badge would cover the sidebar's runtime status. + devIndicators: false, transpilePackages: [ '@runloop-world/core', '@runloop-world/events', diff --git a/apps/studio/src/app/globals.css b/apps/studio/src/app/globals.css new file mode 100644 index 0000000..5e20506 --- /dev/null +++ b/apps/studio/src/app/globals.css @@ -0,0 +1,232 @@ +/* Design tokens: dark slate panels, cyan/purple/amber accents. System fonts only (no web-font fetches). */ +:root { + color-scheme: dark; + + --bg: #060a13; + --bg-elev: #0a1020; + --panel: #0e1526; + --panel-2: #121b30; + --panel-3: #18233c; + --border: #1d2840; + --border-strong: #2a3854; + + --text: #e2e8f0; + --text-strong: #f8fafc; + --muted: #94a3b8; + --faint: #64748b; + + --cyan: #22d3ee; + --purple: #a78bfa; + --amber: #f59e0b; + --accent: var(--cyan); + + --ok: #34d399; + --err: #f87171; + --warn: #fbbf24; + --info: #60a5fa; + --live: #22d3ee; + + /* Role colours (characters get theirs from the world's presets; these are fallbacks). */ + --role-research: #38bdf8; + --role-build: #f59e0b; + --role-test: #34d399; + --role-ops: #c084fc; + --role-system: #94a3b8; + + --radius: 8px; + --radius-sm: 5px; + --gap: 12px; + + --font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--cyan); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 13px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-radius: var(--radius-sm); +} + +code, +pre, +.mono { + font-family: var(--mono); + font-size: 12px; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 10px; + border: 2px solid var(--panel); +} +::-webkit-scrollbar-track { + background: transparent; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ── Shared primitives ─────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 30px; + padding: 0 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--panel-2); + color: var(--text); + font-weight: 500; + white-space: nowrap; + transition: background 120ms, border-color 120ms; +} +.btn:hover:not(:disabled) { + background: var(--panel-3); + border-color: #3a4a6a; +} +.btn[aria-pressed='true'] { + border-color: var(--cyan); + color: var(--text-strong); + background: rgba(34, 211, 238, 0.12); +} +.btn-primary { + background: linear-gradient(180deg, #0e7490, #0b5c74); + border-color: #22d3ee80; + color: #ecfeff; +} +.btn-primary:hover:not(:disabled) { + background: linear-gradient(180deg, #0891b2, #0e7490); + border-color: var(--cyan); +} +.btn-ghost { + background: transparent; + border-color: transparent; +} +.btn-ghost:hover:not(:disabled) { + background: var(--panel-3); + border-color: var(--border); +} +.btn-icon { + width: 30px; + padding: 0; +} + +/* Status badge: always icon + text; tone only adds colour. */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid currentColor; + font-size: 11px; + font-weight: 600; + line-height: 16px; + white-space: nowrap; + background: color-mix(in srgb, currentColor 12%, transparent); +} +.badge[data-tone='ok'] { color: var(--ok); } +.badge[data-tone='err'] { color: var(--err); } +.badge[data-tone='warn'] { color: var(--warn); } +.badge[data-tone='info'] { color: var(--info); } +.badge[data-tone='live'] { color: var(--live); } +.badge[data-tone='muted'] { color: var(--muted); } + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 6px; + border-radius: 4px; + background: var(--panel-3); + border: 1px solid var(--border); + color: var(--muted); + font-size: 11px; + line-height: 18px; + white-space: nowrap; +} + +.link { + background: none; + border: none; + padding: 0; + color: var(--cyan); + text-decoration: underline; + text-decoration-color: #22d3ee66; + text-underline-offset: 2px; +} +.link:hover { + text-decoration-color: var(--cyan); +} + +.dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--role, var(--role-system)); + box-shadow: 0 0 6px var(--role, transparent); + flex: none; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} diff --git a/apps/studio/src/app/layout.tsx b/apps/studio/src/app/layout.tsx new file mode 100644 index 0000000..55a8978 --- /dev/null +++ b/apps/studio/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Runloop Studio', + description: 'Watch real agent work, inspect the trace, replay what happened.', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/studio/src/app/page.tsx b/apps/studio/src/app/page.tsx new file mode 100644 index 0000000..8417944 --- /dev/null +++ b/apps/studio/src/app/page.tsx @@ -0,0 +1,5 @@ +import Studio from '../components/Studio'; + +export default function Page() { + return ; +} diff --git a/apps/studio/src/components/AgentDetails.module.css b/apps/studio/src/components/AgentDetails.module.css new file mode 100644 index 0000000..008316f --- /dev/null +++ b/apps/studio/src/components/AgentDetails.module.css @@ -0,0 +1,203 @@ +.details { + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.header { + display: flex; + align-items: center; + gap: 10px; +} +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 12px; + background: radial-gradient(circle at 35% 30%, #f8fafc, #cbd5e1 60%, #94a3b8); + color: #0f172a; + font-weight: 800; + font-size: 16px; + box-shadow: 0 0 0 2px var(--role, var(--cyan)), 0 0 14px color-mix(in srgb, var(--role, var(--cyan)) 50%, transparent); +} +.who { + flex: 1; + min-width: 0; +} +.name { + margin: 0; + font-size: 16px; + color: var(--text-strong); +} +.role { + color: var(--role, var(--muted)); + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} +.badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; +} + +.dl { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + gap: 6px 10px; + margin: 0; + padding: 10px; + border-radius: var(--radius); + background: var(--panel-2); + border: 1px solid var(--border); +} +.dl dt { + color: var(--faint); +} +.dl dd { + margin: 0; + overflow-wrap: anywhere; +} +.tools { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.section { + display: flex; + flex-direction: column; + gap: 6px; +} +.h3, +.overviewTitle { + margin: 4px 0 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} +.overviewTitle { + font-size: 12px; + color: var(--muted); +} + +.muted { + margin: 0; + color: var(--muted); + font-size: 12px; +} +.warn { + color: var(--warn); + font-size: 12px; +} + +.task { + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px; + border-radius: var(--radius); + background: var(--panel-2); + border: 1px solid var(--border); +} +.taskTop { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} +.taskTitle { + font-weight: 600; + color: var(--text-strong); +} + +.progress { + position: relative; + height: 6px; + border-radius: 3px; + background: var(--panel-3); + overflow: hidden; +} +.progress > span { + position: absolute; + inset: 0 auto 0 0; + border-radius: 3px; + background: linear-gradient(90deg, var(--role, var(--cyan)), var(--cyan)); + transition: width 300ms; +} +.progress[data-indeterminate='true'] > span { + width: 30% !important; + animation: slide 1.4s ease-in-out infinite; +} +@keyframes slide { + from { + left: -30%; + } + to { + left: 100%; + } +} + +.actions, +.artifacts { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.action, +.artifact { + display: flex; + gap: 8px; + width: 100%; + padding: 7px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--panel-2); + text-align: left; +} +.action:hover, +.artifact:hover { + border-color: var(--border-strong); + background: var(--panel-3); +} +.actionIcon { + flex: none; + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + background: var(--panel-3); +} +.actionIcon[data-tone='ok'] { color: var(--ok); } +.actionIcon[data-tone='err'] { color: var(--err); } +.actionIcon[data-tone='live'] { color: var(--cyan); } +.actionBody { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +.actionTitle { + color: var(--text-strong); + overflow-wrap: anywhere; +} +.metrics { + display: flex; + gap: 4px; +} +.metrics :global(.chip[data-tone='err']) { + color: var(--err); +} diff --git a/apps/studio/src/components/AgentDetails.tsx b/apps/studio/src/components/AgentDetails.tsx new file mode 100644 index 0000000..7073b30 --- /dev/null +++ b/apps/studio/src/components/AgentDetails.tsx @@ -0,0 +1,236 @@ +'use client'; +import { selectRecentActions, selectTaskProgress, type ExecState } from '@runloop-world/core'; +import { useMemo, type CSSProperties } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import { formatDuration } from '../state/timeline'; +import { agentLook, callStatusView, characterStatusView, KIND_LABEL, taskStatusView } from '../state/view'; +import styles from './AgentDetails.module.css'; +import StatusBadge from './StatusBadge'; + +/** Details of the selected agent at the playhead (or a task overview when none is selected). */ +export default function AgentDetails() { + const store = useStudioStore(); + const seq = useStudio((s) => s.playheadSeq); + const lastSequence = useStudio((s) => s.lastSequence); + const selected = useStudio((s) => s.selectedAgentId); + const world = useStudio((s) => s.world); + const run = useStudio((s) => s.run); + const state = useMemo(() => store.getState().stateAt(seq), [store, seq, lastSequence]); + const { exec } = state; + const agent = selected ? exec.agents[selected] : undefined; + + if (!selected) return ; + if (!agent) { + return ( +
+

This agent has not registered yet at the playhead.

+
+ ); + } + + const look = agentLook(exec, world, agent.id); + const character = state.world.characters[agent.id]; + const station = character?.stationId && world ? world.stationsById[character.stationId] : undefined; + const task = agent.currentTaskId ? exec.tasks[agent.currentTaskId] : undefined; + const progress = task ? selectTaskProgress(exec, task.id) : null; + const actions = selectRecentActions(exec, agent.id, 5); + const artifacts = exec.artifactOrder + .map((id) => exec.artifacts[id]!) + .filter((a) => a.agentId === agent.id) + .reverse(); + const s = store.getState(); + + return ( +
+
+ +
+

{agent.name}

+ {agent.role} +
+ +
+

+ {character && } + state: {agent.state} + {station && at {station.label}} +

+ {agent.stateReason &&

{agent.stateReason}

} + +
+
Model
+
{agent.model ?? exec.run.adapter?.label ?? run?.adapterLabel ?? '—'}
+
Tools
+
+ {agent.tools.length ? agent.tools.map((t) => {t}) : '—'} +
+
Workspace
+
{exec.run.workspace ? {exec.run.workspace} : '—'}
+
+ +
+

Current task

+ {task ? ( +
+
+ {task.title} + +
+ + {task.id} · {KIND_LABEL[task.kind]} · attempt {Math.max(1, task.attempts)} + + {task.blockedReason && task.status === 'blocked' && Blocked: {task.blockedReason}} +
+ +
+ + {task.plannedSteps !== null + ? `${Math.min(task.stepsDoneThisAttempt, task.plannedSteps)} of ${task.plannedSteps} planned steps` + : 'No step plan'} + +
+ ) : ( +

No task right now.

+ )} +
+ +
+

Recent actions

+ {actions.length === 0 ? ( +

No tool calls yet.

+ ) : ( +
    + {actions.map((c) => { + const view = callStatusView(c.status); + return ( +
  1. + +
  2. + ); + })} +
+ )} +
+ +
+

Artifacts

+ {artifacts.length === 0 ? ( +

None yet.

+ ) : ( +
    + {artifacts.map((a) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} + +function TaskOverview({ exec }: { exec: ExecState }) { + const store = useStudioStore(); + const open = exec.incidents.filter((i) => i.resolvedSeq === null); + return ( +
+

Tasks

+

Select a character in the world or the roster (or press ]) to see its details.

+ {exec.taskOrder.length === 0 ? ( +

No tasks yet.

+ ) : ( +
    + {exec.taskOrder.map((id) => { + const t = exec.tasks[id]!; + return ( +
  1. + +
  2. + ); + })} +
+ )} + {open.length > 0 && ( + <> +

Open incidents

+
    + {open.map((i) => ( +
  • + +
  • + ))} +
+ + )} +
+ ); +} diff --git a/apps/studio/src/components/AgentFlow.module.css b/apps/studio/src/components/AgentFlow.module.css new file mode 100644 index 0000000..25afae5 --- /dev/null +++ b/apps/studio/src/components/AgentFlow.module.css @@ -0,0 +1,77 @@ +.flow { + display: flex; + align-items: center; + gap: 14px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel); + min-height: 48px; +} +.heading { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + white-space: nowrap; +} +.empty { + margin: 0; + color: var(--muted); +} +.chips { + list-style: none; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; +} +.arrow { + color: var(--faint); +} +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: var(--panel-2); +} +.icon { + font-size: 12px; +} +.label { + font-weight: 600; + color: var(--text-strong); +} +.status { + font-size: 11px; + color: var(--muted); +} +.chip[data-tone='ok'] { + border-color: rgba(52, 211, 153, 0.5); +} +.chip[data-tone='ok'] .icon, +.chip[data-tone='ok'] .status { + color: var(--ok); +} +.chip[data-tone='live'] { + border-color: var(--cyan); + box-shadow: 0 0 10px rgba(34, 211, 238, 0.25); +} +.chip[data-tone='live'] .icon, +.chip[data-tone='live'] .status { + color: var(--cyan); +} +.chip[data-tone='err'] { + border-color: var(--err); +} +.chip[data-tone='err'] .icon, +.chip[data-tone='err'] .status { + color: var(--err); +} diff --git a/apps/studio/src/components/AgentFlow.tsx b/apps/studio/src/components/AgentFlow.tsx new file mode 100644 index 0000000..d0c57a9 --- /dev/null +++ b/apps/studio/src/components/AgentFlow.tsx @@ -0,0 +1,41 @@ +'use client'; +import { Fragment, useMemo } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import { flowChips } from '../state/view'; +import styles from './AgentFlow.module.css'; + +/** One chip per task kind at the playhead (core selectAgentFlow). */ +export default function AgentFlow() { + const store = useStudioStore(); + const seq = useStudio((s) => s.playheadSeq); + const lastSequence = useStudio((s) => s.lastSequence); + const chips = useMemo(() => flowChips(store.getState().stateAt(seq).exec), [store, seq, lastSequence]); + + return ( +
+

Agent flow

+ {chips.length === 0 ? ( +

Tasks appear here as the coordinator creates them.

+ ) : ( +
    + {chips.map((c, i) => ( + + {i > 0 && ( + + )} +
  1. + + {c.label} + {c.view.text} +
  2. +
    + ))} +
+ )} +
+ ); +} diff --git a/apps/studio/src/components/ArtifactViewer.module.css b/apps/studio/src/components/ArtifactViewer.module.css new file mode 100644 index 0000000..68b2be9 --- /dev/null +++ b/apps/studio/src/components/ArtifactViewer.module.css @@ -0,0 +1,76 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(2, 6, 23, 0.7); + backdrop-filter: blur(3px); +} +.dialog { + display: flex; + flex-direction: column; + gap: 10px; + width: min(860px, 100%); + max-height: 100%; + padding: 16px; + border-radius: 12px; + border: 1px solid var(--border-strong); + background: var(--panel); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(34, 211, 238, 0.08); +} +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.title { + margin: 0; + font-size: 15px; + color: var(--text-strong); +} +.summary { + margin: 4px 0 0; + color: var(--muted); +} +.meta { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 4px 12px; + margin: 0; + font-size: 12px; +} +.meta dt { + color: var(--faint); +} +.meta dd { + margin: 0; +} +.hash { + overflow-wrap: anywhere; + color: var(--muted); +} +.content { + min-height: 0; + overflow: auto; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: #050913; +} +.pre { + margin: 0; + padding: 12px; + white-space: pre; + color: #cbd5e1; +} +.note, +.error { + margin: 12px; + color: var(--muted); +} +.error { + color: var(--err); +} diff --git a/apps/studio/src/components/ArtifactViewer.tsx b/apps/studio/src/components/ArtifactViewer.tsx new file mode 100644 index 0000000..20d4d0d --- /dev/null +++ b/apps/studio/src/components/ArtifactViewer.tsx @@ -0,0 +1,115 @@ +'use client'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { getArtifactText } from '../lib/api'; +import { useStudio, useStudioStore } from '../state/context'; +import styles from './ArtifactViewer.module.css'; + +type Load = { status: 'loading' } | { status: 'ok'; text: string } | { status: 'error'; message: string }; + +/** Modal showing an artifact's metadata (from the trace) and its content (GET /api/runs/:id/artifacts/:aid). */ +export default function ArtifactViewer() { + const store = useStudioStore(); + const artifactId = useStudio((s) => s.viewArtifactId); + const runId = useStudio((s) => s.runId); + const lastSequence = useStudio((s) => s.lastSequence); + const artifact = useMemo( + () => (artifactId ? store.getState().stateAt(lastSequence).exec.artifacts[artifactId] : undefined), + [store, artifactId, lastSequence], + ); + const [load, setLoad] = useState({ status: 'loading' }); + const closeRef = useRef(null); + const opener = useRef(null); + + useEffect(() => { + if (!artifactId || !runId) return; + let cancelled = false; + setLoad({ status: 'loading' }); + getArtifactText(runId, artifactId).then( + (text) => !cancelled && setLoad({ status: 'ok', text }), + (err: unknown) => !cancelled && setLoad({ status: 'error', message: err instanceof Error ? err.message : String(err) }), + ); + return () => { + cancelled = true; + }; + }, [artifactId, runId]); + + useEffect(() => { + if (!artifactId) return; + opener.current = document.activeElement; + closeRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') store.getState().openArtifact(null); + }; + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('keydown', onKey); + (opener.current as HTMLElement | null)?.focus?.(); + }; + }, [artifactId, store]); + + if (!artifactId) return null; + const close = () => store.getState().openArtifact(null); + + return ( +
+
e.stopPropagation()} + > +
+
+

+ {artifact?.name ?? artifactId} +

+ {artifact &&

{artifact.summary}

} +
+ +
+ {artifact && ( +
+
Kind
+
{artifact.kind}
+
Type
+
{artifact.mediaType}
+
Size
+
{artifact.bytes} bytes
+ {artifact.path && ( + <> +
Path
+
+ {artifact.path} +
+ + )} +
SHA-256
+
+ {artifact.sha256} +
+ {artifact.producedByCallId && ( + <> +
Produced by
+
+ {artifact.producedByCallId} +
+ + )} +
+ )} +
+ {load.status === 'loading' &&

Loading…

} + {load.status === 'error' && ( +

+ ✗ Could not load the artifact: {load.message} +

+ )} + {load.status === 'ok' &&
{load.text}
} +
+
+
+ ); +} diff --git a/apps/studio/src/components/CharacterRoster.tsx b/apps/studio/src/components/CharacterRoster.tsx new file mode 100644 index 0000000..8074a56 --- /dev/null +++ b/apps/studio/src/components/CharacterRoster.tsx @@ -0,0 +1,43 @@ +'use client'; +import { selectEmbodiedAgents } from '@runloop-world/core'; +import { useMemo, type CSSProperties } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import { agentLook, characterStatusView } from '../state/view'; +import styles from './WorldView.module.css'; +import StatusBadge from './StatusBadge'; + +/** Keyboard-reachable list of characters over the canvas (mirrors clicking them in 3D). */ +export default function CharacterRoster() { + const store = useStudioStore(); + const seq = useStudio((s) => s.playheadSeq); + const lastSequence = useStudio((s) => s.lastSequence); + const world = useStudio((s) => s.world); + const selected = useStudio((s) => s.selectedAgentId); + const state = useMemo(() => store.getState().stateAt(seq), [store, seq, lastSequence]); + const agents = selectEmbodiedAgents(state.exec); + if (agents.length === 0) return null; + + return ( +
+ {agents.map((a) => { + const c = state.world.characters[a.id]; + const view = characterStatusView(state.world.paused ? 'paused' : (c?.activity ?? 'idle')); + const isSelected = selected === a.id; + return ( + + ); + })} +
+ ); +} diff --git a/apps/studio/src/components/InfoPanels.module.css b/apps/studio/src/components/InfoPanels.module.css new file mode 100644 index 0000000..0a23ab0 --- /dev/null +++ b/apps/studio/src/components/InfoPanels.module.css @@ -0,0 +1,61 @@ +.scroll { + overflow: auto; + flex: 1; + min-height: 0; + padding: 12px 14px 20px; +} + +.note { + margin: 0 0 12px; + color: var(--muted); +} + +.section { + margin-bottom: 16px; +} + +.h2 { + margin: 0 0 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + cursor: default; +} +summary.h2 { + cursor: pointer; +} + +.pre { + margin: 0; + padding: 10px; + border-radius: var(--radius-sm); + background: #050913; + border: 1px solid var(--border); + color: #cbd5e1; + overflow: auto; + max-height: 70vh; + white-space: pre; +} + +.dl { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 6px 12px; + margin: 0; +} +.dl dt { + color: var(--muted); +} +.dl dd { + margin: 0; + color: var(--text); + overflow-wrap: anywhere; +} + +.anomalies { + margin: 0; + padding-left: 18px; + color: var(--muted); +} diff --git a/apps/studio/src/components/LogsPanel.module.css b/apps/studio/src/components/LogsPanel.module.css new file mode 100644 index 0000000..d4cb60f --- /dev/null +++ b/apps/studio/src/components/LogsPanel.module.css @@ -0,0 +1,125 @@ +.logs { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.filter { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); +} +.filter select { + min-height: 28px; + max-width: 240px; + padding: 0 6px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--panel-2); +} + +.list { + list-style: none; + margin: 0; + padding: 4px 0; + overflow: auto; + flex: 1; + min-height: 0; +} + +.entry { + border-bottom: 1px solid rgba(29, 40, 64, 0.6); +} +.entry[data-future='true'] { + opacity: 0.4; +} +.entry[data-invalid='true'] { + border-left: 2px solid var(--err); +} + +.summary { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + border: none; + background: none; + text-align: left; + font-family: var(--mono); + font-size: 12px; + cursor: pointer; + list-style: none; +} +.summary::-webkit-details-marker { + display: none; +} +.summary:hover { + background: rgba(148, 163, 184, 0.06); +} +.caret { + width: 10px; + color: var(--faint); +} +.seq { + color: var(--faint); + min-width: 34px; +} +.clock { + color: var(--faint); +} +.type { + color: var(--cyan); +} +.meta { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.body { + padding: 0 12px 10px 30px; +} +.chain { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 4px 0; + font-size: 12px; +} +.chain > span:first-child { + color: var(--faint); +} +.error { + margin: 4px 0; + color: var(--err); + font-size: 12px; +} +.actions { + margin: 4px 0; +} +.json { + margin: 6px 0 0; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: #050913; + border: 1px solid var(--border); + color: #cbd5e1; + overflow: auto; + max-height: 360px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/apps/studio/src/components/LogsPanel.tsx b/apps/studio/src/components/LogsPanel.tsx new file mode 100644 index 0000000..c153841 --- /dev/null +++ b/apps/studio/src/components/LogsPanel.tsx @@ -0,0 +1,172 @@ +'use client'; +import { formatClock } from '@runloop-world/core'; +import { isUnknownEvent, type AnyEvent } from '@runloop-world/events'; +import { useMemo, useState } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import styles from './LogsPanel.module.css'; + +const ALL = '*'; +const INVALID = '!invalid'; + +/** Artifact ids an event points at (artifact.created and tool results). */ +function artifactIdsOf(e: AnyEvent): string[] { + if (isUnknownEvent(e)) return []; + if (e.type === 'artifact.created') return [e.payload.artifactId]; + if (e.type === 'tool.call_completed') return e.payload.outputArtifactIds ?? []; + if (e.type === 'task.completed') return e.payload.artifactIds; + return []; +} + +export default function LogsPanel() { + const store = useStudioStore(); + const lastSequence = useStudio((s) => s.lastSequence); + const playheadSeq = useStudio((s) => s.playheadSeq); + const raw = useStudio((s) => s.raw); + const [filter, setFilter] = useState(ALL); + const [open, setOpen] = useState>(new Set()); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + + const events = useMemo(() => [...store.getState().events()], [store, lastSequence]); + const byId = useMemo(() => new Map(events.map((e) => [e.eventId, e])), [events]); + const types = useMemo(() => [...new Set(events.map((e) => e.type))].sort(), [events]); + const exec = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]); + + const shown = filter === ALL ? events : filter === INVALID ? [] : events.filter((e) => e.type === filter); + const showRaw = filter === ALL || filter === INVALID; + + const toggle = (seq: number, force?: boolean) => + setOpen((prev) => { + const next = new Set(prev); + if (force ?? !next.has(seq)) next.add(seq); + else next.delete(seq); + return next; + }); + + const jumpTo = (e: AnyEvent) => { + setFilter(ALL); + toggle(e.sequence, true); + requestAnimationFrame(() => document.getElementById(`log-${e.sequence}`)?.scrollIntoView({ block: 'center' })); + }; + + const copyJsonl = async () => { + const s = store.getState(); + const lines = [...events.map((e) => JSON.stringify(s.rawEvent(e.sequence) ?? e)), ...raw.map((r) => JSON.stringify(r.raw))]; + try { + await navigator.clipboard.writeText(lines.join('\n') + '\n'); + setCopyState('copied'); + } catch { + setCopyState('failed'); + } + setTimeout(() => setCopyState('idle'), 2000); + }; + + return ( +
+
+ + +
+ +
    + {shown.map((e) => { + const unknown = isUnknownEvent(e); + const parents: AnyEvent[] = []; + for (let p = e.parentEventId ? byId.get(e.parentEventId) : undefined; p && parents.length < 10; ) { + parents.push(p); + p = p.parentEventId ? byId.get(p.parentEventId) : undefined; + } + const artifacts = artifactIdsOf(e); + const isOpen = open.has(e.sequence); + return ( +
  1. playheadSeq || undefined}> + + {isOpen && ( +
    + {parents.length > 0 && ( +

    + Caused by + {parents.map((p) => ( + + ))} +

    + )} + {artifacts.length > 0 && ( +

    + Artifacts + {artifacts.map((id) => ( + + ))} +

    + )} + {unknown && e.error &&

    {e.error}

    } +
    + +
    +
    {JSON.stringify(store.getState().rawEvent(e.sequence) ?? e, null, 2)}
    +
    + )} +
  2. + ); + })} + {showRaw && + raw.map((r) => ( +
  3. +
    + + {r.sequence !== null ? `#${r.sequence}` : '#?'} + + ✗ invalid event + + {r.error} + +
    +

    {r.error}

    +
    {typeof r.raw === 'string' ? r.raw : JSON.stringify(r.raw, null, 2)}
    +
    +
    +
  4. + ))} +
+
+ ); +} diff --git a/apps/studio/src/components/MetadataPanel.tsx b/apps/studio/src/components/MetadataPanel.tsx new file mode 100644 index 0000000..2e04712 --- /dev/null +++ b/apps/studio/src/components/MetadataPanel.tsx @@ -0,0 +1,89 @@ +'use client'; +import { isUnknownEvent } from '@runloop-world/events'; +import { useMemo, type ReactNode } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import { formatDuration } from '../state/timeline'; +import styles from './InfoPanels.module.css'; + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( + <> +
{label}
+
{children ?? '—'}
+ + ); +} + +/** Run facts: ids, seed, schema version, world, adapter. */ +export default function MetadataPanel() { + const store = useStudioStore(); + const run = useStudio((s) => s.run); + const world = useStudio((s) => s.world); + const lastSequence = useStudio((s) => s.lastSequence); + const durationMs = useStudio((s) => s.durationMs); + const raw = useStudio((s) => s.raw); + const exec = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]); + const first = useMemo(() => store.getState().events()[0], [store, lastSequence]); + const versions = useMemo( + () => [...new Set(store.getState().events().map((e) => e.schemaVersion))].sort().join(', '), + [store, lastSequence], + ); + const unknownCount = useMemo(() => store.getState().events().filter(isUnknownEvent).length, [store, lastSequence]); + + if (!run) return

No run selected.

; + const r = exec.run; + return ( +
+
+

Run

+
+ + {run.runId} + + {run.title} + + {run.missionId} + + {run.status}{run.paused ? ' (paused)' : ''} + {r.startedAt ?? run.createdAt} + {r.endedAt ?? run.endedAt} + {formatDuration(durationMs)} + {r.summary} + {first ? {r.seed} : null} + {r.workspace ? {r.workspace} : null} +
+
+
+

Adapter

+
+ {r.adapter?.label ?? run.adapterLabel} + {r.adapter?.kind} + {r.adapter ? {r.adapter.id} : null} +
+
+
+

Trace

+
+ {versions || null} + {lastSequence} + {unknownCount} + {raw.length} + {exec.anomalies.length} + {exec.incidents.length} +
+
+
+

World

+
+ + {world?.def.id ?? run.worldId} + + {world?.def.name} + {world ? `${world.width} × ${world.height} cells` : null} + {world ? `${world.def.tickHz} Hz, ${world.def.ticksPerCell} ticks per cell` : null} + {world?.def.stations.length} +
+
+
+ ); +} diff --git a/apps/studio/src/components/ReplayBar.module.css b/apps/studio/src/components/ReplayBar.module.css new file mode 100644 index 0000000..4b8ca68 --- /dev/null +++ b/apps/studio/src/components/ReplayBar.module.css @@ -0,0 +1,151 @@ +.wrap { + container-type: inline-size; +} + +.bar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel); + flex-wrap: wrap; +} + +.transport, +.speeds { + display: inline-flex; + gap: 4px; +} +.play { + width: 36px; + border-color: #22d3ee80; + color: var(--cyan); +} +.speed { + min-height: 26px; + padding: 0 7px; + font-size: 12px; +} + +.scrubber { + position: relative; + flex: 1; + min-width: 160px; + height: 28px; + display: flex; + align-items: center; +} + +.range { + --pct: 0%; + width: 100%; + margin: 0; + appearance: none; + height: 6px; + border-radius: 3px; + background: linear-gradient(90deg, var(--cyan) 0 var(--pct), var(--panel-3) var(--pct) 100%); + cursor: pointer; +} +.range::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #ecfeff; + border: 2px solid var(--cyan); + box-shadow: 0 0 8px var(--cyan); +} +.range::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #ecfeff; + border: 2px solid var(--cyan); +} +.range:disabled { + cursor: default; + opacity: 0.5; +} + +.markers { + position: absolute; + inset: 0; + pointer-events: none; +} +.marker { + pointer-events: auto; + position: absolute; + top: -3px; + transform: translateX(-50%); + width: 14px; + height: 14px; + padding: 0; + border-radius: 50%; + border: 1px solid var(--err); + background: #3f1219; + color: var(--err); + font-size: 10px; + font-weight: 700; + line-height: 12px; +} +.marker[data-resolved='true'] { + border-color: var(--faint); + background: var(--panel-3); + color: var(--muted); +} + +.clock { + font-family: var(--mono); + font-size: 12px; + color: var(--text); + min-width: 96px; + text-align: center; +} + +.live { + display: inline-flex; + align-items: center; + gap: 6px; +} +.newChip { + padding: 3px 10px; + border-radius: 999px; + border: 1px solid var(--cyan); + background: rgba(34, 211, 238, 0.12); + color: var(--cyan); + font-size: 12px; + font-weight: 600; +} +.liveBtn { + min-width: 84px; +} +.liveDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--faint); +} +.liveDot[data-on='true'] { + background: var(--err); + box-shadow: 0 0 8px var(--err); + animation: pulse 1.4s ease-in-out infinite; +} + +/* Narrow world column: the scrubber gets its own full-width row. */ +@container (max-width: 780px) { + .scrubber { + order: -1; + flex-basis: 100%; + } + .clock { + margin-left: auto; + } +} + +@keyframes pulse { + 50% { + opacity: 0.35; + } +} diff --git a/apps/studio/src/components/ReplayBar.tsx b/apps/studio/src/components/ReplayBar.tsx new file mode 100644 index 0000000..287f6fe --- /dev/null +++ b/apps/studio/src/components/ReplayBar.tsx @@ -0,0 +1,127 @@ +'use client'; +import { formatClock } from '@runloop-world/core'; +import { useMemo, type CSSProperties } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import { useTicker } from '../state/hooks'; +import { SPEEDS } from '../state/store'; +import { formatClockPair } from '../state/view'; +import styles from './ReplayBar.module.css'; + +/** Transport controls: prev / play / next, speed, time scrubber, live toggle. */ +export default function ReplayBar() { + const store = useStudioStore(); + const cursor = useStudio((s) => s.cursor); + const runId = useStudio((s) => s.runId); + const ended = useStudio((s) => s.ended); + const settleMs = useStudio((s) => s.settleMs); + const lastSequence = useStudio((s) => s.lastSequence); + const newSinceReplay = useStudio((s) => s.newSinceReplay); + const speed = useStudio((s) => s.speed); + const s = store.getState(); + + const live = cursor.mode === 'live'; + const t = s.playheadMs(); + const end = s.endMs(); + const settled = ended && t >= settleMs; + // The live clock moves with wall time, not with store updates. + useTicker(live && runId !== null && !settled, 250); + + const disabled = runId === null || lastSequence === 0; + const playing = !disabled && (live ? !settled : cursor.playing); + + const incidents = useMemo(() => { + const st = store.getState(); + return st.stateAt(lastSequence).exec.incidents.map((i) => ({ ...i, tMs: st.timeOfSeq(i.seq) })); + }, [store, lastSequence]); + + return ( +
+
+
+ + + +
+ +
+ {SPEEDS.map((v) => ( + + ))} +
+ +
+ s.seek(Number(e.target.value))} + aria-label="Playhead time" + aria-valuetext={formatClockPair(t, end)} + style={{ '--pct': `${end > 0 ? (t / end) * 100 : 0}%` } as CSSProperties} + /> +
+ {incidents.map((i) => ( + + ))} +
+
+ + + {formatClockPair(t, end)} + + +
+ {!live && newSinceReplay > 0 && ( + + )} + +
+
+
+ ); +} diff --git a/apps/studio/src/components/RunHeader.module.css b/apps/studio/src/components/RunHeader.module.css new file mode 100644 index 0000000..3c4e0f4 --- /dev/null +++ b/apps/studio/src/components/RunHeader.module.css @@ -0,0 +1,70 @@ +.header { + grid-column: 2 / -1; + grid-row: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px 0; + border-bottom: 1px solid var(--border); + background: rgba(10, 16, 32, 0.85); + min-width: 0; +} + +.title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.no { + font-weight: 700; + font-size: 15px; + color: var(--text-strong); + white-space: nowrap; +} +.h1 { + margin: 0; + font-size: 15px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.flags { + display: flex; + gap: 6px; + margin-left: auto; + flex: none; +} + +.tabs { + display: flex; + gap: 2px; +} +.tab { + position: relative; + padding: 7px 12px 9px; + border: none; + background: none; + color: var(--muted); + font-weight: 500; +} +.tab:hover { + color: var(--text); +} +.tab[aria-selected='true'] { + color: var(--text-strong); +} +.tab[aria-selected='true']::after { + content: ''; + position: absolute; + left: 8px; + right: 8px; + bottom: -1px; + height: 2px; + border-radius: 2px; + background: var(--cyan); + box-shadow: 0 0 8px var(--cyan); +} diff --git a/apps/studio/src/components/RunHeader.tsx b/apps/studio/src/components/RunHeader.tsx new file mode 100644 index 0000000..705891e --- /dev/null +++ b/apps/studio/src/components/RunHeader.tsx @@ -0,0 +1,97 @@ +'use client'; +import { useRef, type KeyboardEvent } from 'react'; +import { useStudio } from '../state/context'; +import type { ConnStatus, Tab } from '../state/store'; +import { runStatusView, type StatusView } from '../state/view'; +import styles from './RunHeader.module.css'; +import StatusBadge from './StatusBadge'; + +const TABS: { id: Tab; label: string }[] = [ + { id: 'timeline', label: 'Timeline' }, + { id: 'logs', label: 'Logs' }, + { id: 'state', label: 'State' }, + { id: 'metadata', label: 'Metadata' }, +]; + +const CONN: Record = { + idle: null, + loading: { icon: '…', text: 'loading', tone: 'muted' }, + streaming: { icon: '●', text: 'streaming', tone: 'live' }, + reconnecting: { icon: '↻', text: 'reconnecting', tone: 'warn' }, + complete: null, + error: { icon: '✗', text: 'disconnected', tone: 'err' }, +}; + +export default function RunHeader() { + const run = useStudio((s) => s.run); + const runs = useStudio((s) => s.runs); + const runId = useStudio((s) => s.runId); + const tab = useStudio((s) => s.tab); + const setTab = useStudio((s) => s.setTab); + const conn = useStudio((s) => s.conn); + const loadError = useStudio((s) => s.loadError); + const warnings = useStudio((s) => s.warnings); + const gap = useStudio((s) => s.gap); + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const index = runs.findIndex((r) => r.runId === runId); + const number = index >= 0 ? runs.length - index : null; + const connView = CONN[conn]; + + const onTabKey = (e: KeyboardEvent) => { + const i = TABS.findIndex((t) => t.id === tab); + const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + if (!d) return; + e.preventDefault(); + const next = (i + d + TABS.length) % TABS.length; + setTab(TABS[next]!.id); + tabRefs.current[next]?.focus(); + }; + + return ( +
+
+ {run ? ( + <> + Run {number !== null ? `#${number}` : ''} + +

+ {run.title} +

+ + ) : ( +

{!runId ? 'No run selected' : loadError ? 'Run unavailable' : 'Loading run…'}

+ )} + + {connView && } + {gap && gap.from ? `–${gap.to}` : ''}`, tone: 'warn' }} />} + {warnings.length > 0 && ( + + 1 ? 's' : ''}`, tone: 'warn' }} /> + + )} + +
+
+ {TABS.map((t, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/studio/src/components/Sidebar.module.css b/apps/studio/src/components/Sidebar.module.css new file mode 100644 index 0000000..24b4fbe --- /dev/null +++ b/apps/studio/src/components/Sidebar.module.css @@ -0,0 +1,151 @@ +.sidebar { + grid-column: 1; + grid-row: 1 / -1; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + padding: 14px 12px; + border-right: 1px solid var(--border); + background: linear-gradient(180deg, #0b1222, #080d19); + overflow: hidden; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 2px 4px 10px; + font-size: 15px; + color: var(--text-strong); +} +.logo { + width: 26px; + height: 26px; + border-radius: 8px; + background: + radial-gradient(circle at 50% 50%, transparent 5px, var(--cyan) 6px, var(--cyan) 7.5px, transparent 8.5px), + linear-gradient(135deg, #1e1b4b, #0c4a6e); + box-shadow: 0 0 14px rgba(34, 211, 238, 0.35); +} +.sub { + margin-left: 6px; + color: var(--muted); + font-weight: 400; +} + +.start { + width: 100%; + min-height: 34px; +} + +.error { + margin: 0; + color: var(--err); + font-size: 12px; +} + +.heading { + margin: 8px 4px 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} + +.empty { + margin: 0 4px; + color: var(--muted); +} + +.runs { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + overflow: auto; + min-height: 0; + flex: 1; +} + +.run { + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; + text-align: left; + padding: 8px 9px; + border-radius: var(--radius); + border: 1px solid transparent; + background: transparent; +} +.run:hover { + background: var(--panel-2); +} +.run[aria-current='true'] { + background: var(--panel-2); + border-color: var(--border-strong); + box-shadow: inset 2px 0 0 var(--cyan); +} +.runTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} +.runNo { + font-weight: 600; + color: var(--text-strong); +} +.runTitle { + color: var(--text); + font-size: 12px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.runMeta { + color: var(--faint); + font-size: 11px; +} + +.runtime { + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.status { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 4px; +} +.meta { + color: var(--faint); + font-size: 11px; +} +.offline { + margin: 0 4px; + color: var(--muted); + font-size: 12px; +} +.offline p { + margin: 6px 0; +} +.cmd { + display: block; + padding: 6px 8px; + border-radius: var(--radius-sm); + background: #020617; + border: 1px solid var(--border-strong); + color: var(--amber); + user-select: all; +} +.endpoint { + margin: 6px 4px 0; + color: var(--faint); +} diff --git a/apps/studio/src/components/Sidebar.tsx b/apps/studio/src/components/Sidebar.tsx new file mode 100644 index 0000000..27c01ce --- /dev/null +++ b/apps/studio/src/components/Sidebar.tsx @@ -0,0 +1,120 @@ +'use client'; +import { useState } from 'react'; +import { ApiError, startRun } from '../lib/api'; +import { API_BASE, DEMO_MISSION_ID, START_RUNTIME_COMMAND } from '../lib/config'; +import { useStudio, useStudioStore } from '../state/context'; +import { selectRun } from '../state/hooks'; +import { runStatusView } from '../state/view'; +import styles from './Sidebar.module.css'; +import StatusBadge from './StatusBadge'; + +export default function Sidebar() { + const store = useStudioStore(); + const runs = useStudio((s) => s.runs); + const runId = useStudio((s) => s.runId); + const runtime = useStudio((s) => s.runtime); + const [starting, setStarting] = useState(false); + const [startError, setStartError] = useState(null); + + const offline = runtime.status !== 'online'; + const active = runtime.activeRunId; + + const onStart = async () => { + setStarting(true); + setStartError(null); + try { + const { runId: id } = await startRun(DEMO_MISSION_ID); + selectRun(store, id); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && active) { + setStartError('A run is already in progress.'); + selectRun(store, active); + } else { + setStartError(err instanceof Error ? err.message : String(err)); + } + } finally { + setStarting(false); + } + }; + + return ( + + ); +} diff --git a/apps/studio/src/components/StatePanel.tsx b/apps/studio/src/components/StatePanel.tsx new file mode 100644 index 0000000..8dcb657 --- /dev/null +++ b/apps/studio/src/components/StatePanel.tsx @@ -0,0 +1,47 @@ +'use client'; +import { formatClock } from '@runloop-world/core'; +import { useMemo } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import styles from './InfoPanels.module.css'; + +/** Raw Layer-1 and Layer-2 state at the playhead. */ +export default function StatePanel() { + const store = useStudioStore(); + const seq = useStudio((s) => s.playheadSeq); + const lastSequence = useStudio((s) => s.lastSequence); + const state = useMemo(() => store.getState().stateAt(seq), [store, seq, lastSequence]); + const exec = useMemo(() => JSON.stringify(state.exec, null, 2), [state]); + const world = useMemo(() => JSON.stringify(state.world, null, 2), [state]); + + return ( +
+

+ State after event #{seq} ({formatClock(store.getState().timeOfSeq(seq))}). Move the playhead to inspect another moment. +

+ {state.exec.anomalies.length > 0 && ( +
+

+ + ⚠ {state.exec.anomalies.length} anomal{state.exec.anomalies.length === 1 ? 'y' : 'ies'} + +

+
    + {state.exec.anomalies.map((a, i) => ( +
  • + #{a.seq} {a.code} {a.detail} +
  • + ))} +
+
+ )} +
+ Execution state (Layer 1) +
{exec}
+
+
+ World state (Layer 2) +
{world}
+
+
+ ); +} diff --git a/apps/studio/src/components/StatusBadge.tsx b/apps/studio/src/components/StatusBadge.tsx new file mode 100644 index 0000000..1456e41 --- /dev/null +++ b/apps/studio/src/components/StatusBadge.tsx @@ -0,0 +1,11 @@ +import type { StatusView } from '../state/view'; + +/** Icon + text status pill (colour comes from the tone and is never the only signal). */ +export default function StatusBadge({ view, className }: { view: StatusView; className?: string }) { + return ( + + + {view.text} + + ); +} diff --git a/apps/studio/src/components/Studio.module.css b/apps/studio/src/components/Studio.module.css new file mode 100644 index 0000000..882772d --- /dev/null +++ b/apps/studio/src/components/Studio.module.css @@ -0,0 +1,72 @@ +.studio { + --sidebar-w: 216px; + --panel-w: minmax(320px, 360px); + --details-w: 320px; + display: grid; + grid-template-columns: var(--sidebar-w) var(--panel-w) minmax(0, 1fr) var(--details-w); + grid-template-rows: auto minmax(0, 1fr); + height: 100vh; + min-height: 600px; + background: + radial-gradient(1200px 600px at 60% 30%, rgba(34, 211, 238, 0.05), transparent 60%), + radial-gradient(900px 500px at 80% 90%, rgba(167, 139, 250, 0.05), transparent 60%), + var(--bg); +} + +.studio[data-wide='true'] { + --panel-w: minmax(400px, 30vw); +} + +.panel { + grid-column: 2; + grid-row: 2; + min-height: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + background: var(--panel); +} + +.world { + grid-column: 3; + grid-row: 2; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; +} + +.details { + grid-column: 4; + grid-row: 2; + min-height: 0; + overflow: auto; + border-left: 1px solid var(--border); + background: var(--panel); +} + +@media (max-width: 1180px) { + .studio { + --details-w: 280px; + --sidebar-w: 190px; + } +} + +@media (max-width: 980px) { + .studio { + display: flex; + flex-direction: column; + height: auto; + } + .panel, + .details { + max-height: 60vh; + border: none; + border-top: 1px solid var(--border); + } + .world { + min-height: 70vh; + } +} diff --git a/apps/studio/src/components/Studio.tsx b/apps/studio/src/components/Studio.tsx new file mode 100644 index 0000000..74ca42e --- /dev/null +++ b/apps/studio/src/components/Studio.tsx @@ -0,0 +1,90 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import { connectRun } from '../state/connect'; +import { StudioProvider, useStudio } from '../state/context'; +import { + runIdFromUrl, + selectRun, + useKeyboardShortcuts, + usePlaybackDriver, + useReducedMotion, + useRuntimePolling, +} from '../state/hooks'; +import { createStudioStore, type StudioStore } from '../state/store'; +import AgentDetails from './AgentDetails'; +import AgentFlow from './AgentFlow'; +import ArtifactViewer from './ArtifactViewer'; +import LogsPanel from './LogsPanel'; +import MetadataPanel from './MetadataPanel'; +import ReplayBar from './ReplayBar'; +import RunHeader from './RunHeader'; +import Sidebar from './Sidebar'; +import StatePanel from './StatePanel'; +import styles from './Studio.module.css'; +import TimelinePanel from './TimelinePanel'; +import WorldView from './WorldView'; + +export default function Studio() { + const [store] = useState(() => createStudioStore()); + return ( + + + + ); +} + +function Shell({ store }: { store: StudioStore }) { + const runId = useStudio((s) => s.runId); + const tab = useStudio((s) => s.tab); + const retry = useReconnectNonce(store); + + useEffect(() => { + const fromUrl = runIdFromUrl(); + if (fromUrl) selectRun(store, fromUrl); + }, [store]); + useRuntimePolling(store); + usePlaybackDriver(store); + useReducedMotion(store); + useKeyboardShortcuts(store); + useEffect(() => (runId ? connectRun(store, runId) : undefined), [store, runId, retry]); + + return ( +
+ + +
+ {tab === 'timeline' && } + {tab === 'logs' && } + {tab === 'state' && } + {tab === 'metadata' && } +
+
+ + + +
+ + +
+ ); +} + +/** Bumps when the runtime comes back online after the open run failed to load or stream. */ +function useReconnectNonce(store: StudioStore): number { + const [nonce, setNonce] = useState(0); + const prev = useRef(store.getState().runtime.status); + useEffect( + () => + store.subscribe((s) => { + const was = prev.current; + prev.current = s.runtime.status; + if (was === 'offline' && s.runtime.status === 'online' && s.runId && (s.loadError || s.conn === 'error')) { + setNonce((n) => n + 1); + } + }), + [store], + ); + return nonce; +} diff --git a/apps/studio/src/components/TimelinePanel.module.css b/apps/studio/src/components/TimelinePanel.module.css new file mode 100644 index 0000000..d8d1908 --- /dev/null +++ b/apps/studio/src/components/TimelinePanel.module.css @@ -0,0 +1,124 @@ +.list { + list-style: none; + margin: 0; + padding: 6px 0; + overflow: auto; + flex: 1; + min-height: 0; +} + +.empty { + margin: 16px; + color: var(--muted); +} + +.row { + position: relative; + border-left: 2px solid transparent; + transition: opacity 150ms; +} +.row[data-status='error'] { + border-left-color: var(--err); +} +.row[data-current='true'] { + background: linear-gradient(90deg, rgba(34, 211, 238, 0.1), transparent 80%); + border-left-color: var(--cyan); +} +.row[data-future='true'] { + opacity: 0.38; +} + +.main { + display: grid; + grid-template-columns: 40px 20px minmax(0, 1fr); + align-items: start; + gap: 6px; + width: 100%; + padding: 7px 12px 7px 8px; + border: none; + background: none; + text-align: left; +} +.main:hover { + background: rgba(148, 163, 184, 0.06); +} + +.time { + font-family: var(--mono); + font-size: 11px; + color: var(--faint); + padding-top: 1px; +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 11px; + background: var(--panel-3); + color: var(--muted); +} +.icon[data-tone='ok'] { color: var(--ok); background: rgba(52, 211, 153, 0.14); } +.icon[data-tone='err'] { color: var(--err); background: rgba(248, 113, 113, 0.16); } +.icon[data-tone='warn'] { color: var(--warn); background: rgba(251, 191, 36, 0.14); } +.icon[data-tone='live'] { + color: var(--cyan); + background: rgba(34, 211, 238, 0.14); + animation: spin 1.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.head { + display: flex; + align-items: center; + gap: 6px; +} +.actor { + font-size: 11px; + font-weight: 600; + color: var(--role, var(--muted)); +} +.title { + color: var(--text-strong); + overflow-wrap: anywhere; +} +.detail { + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} +.facts { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 2px; +} +.facts :global(.chip[data-tone='ok']) { + color: var(--ok); +} +.facts :global(.chip[data-tone='err']) { + color: var(--err); + border-color: rgba(248, 113, 113, 0.4); +} + +.artifacts { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 0 12px 8px 74px; + font-size: 12px; +} diff --git a/apps/studio/src/components/TimelinePanel.tsx b/apps/studio/src/components/TimelinePanel.tsx new file mode 100644 index 0000000..f73dbd7 --- /dev/null +++ b/apps/studio/src/components/TimelinePanel.tsx @@ -0,0 +1,115 @@ +'use client'; +import { formatClock } from '@runloop-world/core'; +import { useEffect, useMemo, useRef, type CSSProperties } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import { buildTimelineRows, formatDuration, rowStatusAt, STATUS_LABEL, type RowStatus, type TimelineRow } from '../state/timeline'; +import { agentLook } from '../state/view'; +import styles from './TimelinePanel.module.css'; + +const STATUS_ICON: Partial> = { running: '⚙', ok: '✓', error: '✗', warn: '⚠' }; +const STATUS_TONE: Record = { running: 'live', ok: 'ok', error: 'err', warn: 'warn', info: 'muted' }; + +export default function TimelinePanel() { + const store = useStudioStore(); + const lastSequence = useStudio((s) => s.lastSequence); + const playheadSeq = useStudio((s) => s.playheadSeq); + const world = useStudio((s) => s.world); + const follow = useStudio((s) => s.cursor.mode === 'live' || s.cursor.playing); + const listRef = useRef(null); + + // Rows depend only on the received events; the playhead only changes how they look. + const rows = useMemo(() => { + const s = store.getState(); + return buildTimelineRows(s.events(), s.timeOfSeq); + }, [store, lastSequence]); + const exec = useMemo(() => store.getState().stateAt(lastSequence).exec, [store, lastSequence]); + + let current = -1; + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i]!.seq <= playheadSeq) { + current = i; + break; + } + } + + useEffect(() => { + if (!follow || current < 0) return; + listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }); + }, [current, follow]); + + const onRow = (row: TimelineRow) => { + const s = store.getState(); + s.seekToSeq(row.endSeq ?? row.seq); + if (agentLook(exec, world, row.actorId).embodied) s.selectAgent(row.actorId); + }; + + if (rows.length === 0) { + return

{lastSequence === 0 ? 'No events yet.' : ''}

; + } + + return ( +
    + {rows.map((row, i) => { + const status = rowStatusAt(row, playheadSeq); + const look = agentLook(exec, world, row.actorId); + const future = row.seq > playheadSeq; + const icon = row.callId !== null ? (STATUS_ICON[status] ?? row.icon) : row.icon; + return ( +
  1. + + {row.artifactIds.length > 0 && (row.endSeq === null || row.endSeq <= playheadSeq) && ( + + {row.artifactIds.map((id) => ( + + ))} + + )} +
  2. + ); + })} +
+ ); +} diff --git a/apps/studio/src/components/WorldView.module.css b/apps/studio/src/components/WorldView.module.css new file mode 100644 index 0000000..897af55 --- /dev/null +++ b/apps/studio/src/components/WorldView.module.css @@ -0,0 +1,101 @@ +.view { + position: relative; + flex: 1; + min-height: 280px; + border-radius: 12px; + border: 1px solid var(--border); + background: + radial-gradient(70% 60% at 50% 45%, rgba(30, 41, 79, 0.55), transparent 70%), + #070b16; + overflow: hidden; + box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.5); +} + +.canvas { + position: absolute; + inset: 0; +} + +.message { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + margin: 0; + padding: 24px; + text-align: center; + color: var(--muted); +} +.message p { + margin: 4px 0; + max-width: 460px; +} +.message code { + padding: 1px 6px; + border-radius: 4px; + background: #020617; + border: 1px solid var(--border-strong); + color: var(--amber); +} + +.toolbar { + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} +.toolbar :global(.btn) { + min-height: 28px; + background: rgba(14, 21, 38, 0.85); + backdrop-filter: blur(6px); +} +.segmented { + display: inline-flex; +} +.segmented :global(.btn) { + border-radius: 0; +} +.segmented :global(.btn):first-child { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} +.segmented :global(.btn):last-child { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + margin-left: -1px; +} + +.roster { + position: absolute; + left: 10px; + bottom: 10px; + display: flex; + flex-wrap: wrap; + gap: 6px; + max-width: calc(100% - 20px); +} +.character { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 9px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: rgba(10, 16, 32, 0.85); + backdrop-filter: blur(6px); +} +.character:hover { + border-color: #3a4a6a; +} +.character[aria-pressed='true'] { + border-color: var(--role, var(--cyan)); + box-shadow: 0 0 0 1px var(--role, var(--cyan)), 0 0 12px color-mix(in srgb, var(--role, var(--cyan)) 40%, transparent); +} +.characterName { + font-weight: 600; + color: var(--text-strong); +} diff --git a/apps/studio/src/components/WorldView.tsx b/apps/studio/src/components/WorldView.tsx new file mode 100644 index 0000000..bf8b1a7 --- /dev/null +++ b/apps/studio/src/components/WorldView.tsx @@ -0,0 +1,181 @@ +'use client'; +import dynamic from 'next/dynamic'; +import { Component, useCallback, useEffect, useState, type ReactNode } from 'react'; +import { START_RUNTIME_COMMAND } from '../lib/config'; +import { useStudio } from '../state/context'; +import CharacterRoster from './CharacterRoster'; +import styles from './WorldView.module.css'; + +const WorldCanvas = dynamic(() => import('../scene/WorldCanvas'), { + ssr: false, + loading: () =>

Loading the 3D world…

, +}); + +function webglAvailable(): boolean { + try { + const canvas = document.createElement('canvas'); + return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')); + } catch { + return false; + } +} + +/** The 3D world plus its DOM overlays (view toggles, character roster, fallbacks). */ +export default function WorldView() { + const world = useStudio((s) => s.world); + const runId = useStudio((s) => s.runId); + const loadError = useStudio((s) => s.loadError); + const runtimeStatus = useStudio((s) => s.runtime.status); + const getFrame = useStudio((s) => s.getFrame); + const subscribeEffects = useStudio((s) => s.subscribeEffects); + const selectedAgentId = useStudio((s) => s.selectedAgentId); + const selectedStationId = useStudio((s) => s.selectedStationId); + const selectAgent = useStudio((s) => s.selectAgent); + const selectStation = useStudio((s) => s.selectStation); + const cameraMode = useStudio((s) => s.cameraMode); + const setCameraMode = useStudio((s) => s.setCameraMode); + const follow = useStudio((s) => s.follow); + const toggleFollow = useStudio((s) => s.toggleFollow); + const quality = useStudio((s) => s.quality); + const setQuality = useStudio((s) => s.setQuality); + const reducedMotion = useStudio((s) => s.reducedMotion); + + const [webgl, setWebgl] = useState<'checking' | 'ok' | 'unavailable'>('checking'); + const [ready, setReady] = useState(false); + useEffect(() => setWebgl(webglAvailable() ? 'ok' : 'unavailable'), []); + const onReady = useCallback(() => setReady(true), []); + + let body: ReactNode; + if (loadError) { + body = ( +
+

+ Could not load this run: {loadError} +

+ {runtimeStatus === 'offline' && ( +

+ Start the local runtime with {START_RUNTIME_COMMAND}; the Studio reconnects automatically. +

+ )} +
+ ); + } else if (!runId) { + body = ( +
+ {runtimeStatus === 'offline' ? ( +

+ The local runtime is offline. Start it with {START_RUNTIME_COMMAND}, then press{' '} + Run demo mission. +

+ ) : ( +

+ No run yet. Press Run demo mission to watch agents work. +

+ )} +
+ ); + } else if (!world) { + body =

Loading run…

; + } else if (webgl === 'unavailable') { + body = ( +
+

+ WebGL is not available in this browser, so the 3D world cannot be shown. +

+

The Timeline, Logs and State tabs still show everything that happened.

+
+ ); + } else if (webgl === 'ok') { + body = ( + + + + ); + } + + return ( +
+
{body}
+ + {world && ( + <> +
+
+ + +
+ + +
+ + + )} +
+ ); +} + +/** If the scene throws, keep the rest of the Studio usable. */ +class SceneBoundary extends Component<{ children: ReactNode }, { error: string | null }> { + override state = { error: null as string | null }; + + static getDerivedStateFromError(err: unknown) { + return { error: err instanceof Error ? err.message : String(err) }; + } + + override componentDidCatch(err: unknown) { + console.error('[studio] 3D scene failed', err); + } + + override render() { + if (this.state.error) { + return ( +
+

+ The 3D world failed to render: {this.state.error} +

+

The Timeline, Logs and State tabs still show everything that happened.

+

+ +

+
+ ); + } + return this.props.children; + } +} diff --git a/apps/studio/src/lib/api.ts b/apps/studio/src/lib/api.ts new file mode 100644 index 0000000..6c752b8 --- /dev/null +++ b/apps/studio/src/lib/api.ts @@ -0,0 +1,83 @@ +import { + apiPaths, + type EventsPageResponse, + type HealthResponse, + type ListRunsResponse, + type RunDetailResponse, + type RunSummary, + type StartRunResponse, +} from '@runloop-world/events'; +import { API_BASE } from './config'; + +/** An HTTP error with the runtime's `{ error }` message when it sent one. */ +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +export const apiUrl = (path: string) => `${API_BASE}${path}`; + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(apiUrl(path), { cache: 'no-store', ...init }); + if (!res.ok) throw new ApiError(res.status, await errorMessage(res)); + return (await res.json()) as T; +} + +async function errorMessage(res: Response): Promise { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === 'string') return body.error; + } catch { + // not JSON + } + return `HTTP ${res.status}`; +} + +export function getHealth(signal?: AbortSignal): Promise { + return request(apiPaths.health, signal ? { signal } : undefined); +} + +export async function listRuns(): Promise { + return (await request(apiPaths.runs)).runs; +} + +export function getRun(runId: string): Promise { + return request(apiPaths.run(runId)); +} + +export function getEventsPage(runId: string, after: number, limit = 500): Promise { + return request(`${apiPaths.events(runId)}?after=${after}&limit=${limit}`); +} + +/** Every event after `after`, following `hasMore` page by page. Events are raw JSON (parse them). */ +export async function getAllEvents(runId: string, after = 0): Promise { + const out: unknown[] = []; + for (let cursor = after; ; ) { + const page = await getEventsPage(runId, cursor); + out.push(...page.events); + if (!page.hasMore || page.nextAfter <= cursor) return out; + cursor = page.nextAfter; + } +} + +export function startRun(missionId: string): Promise { + return request(apiPaths.runs, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ missionId }), + }); +} + +/** Artifact content as text (the viewer only shows text; binary shows as replacement chars). */ +export async function getArtifactText(runId: string, artifactId: string): Promise { + const res = await fetch(apiUrl(apiPaths.artifact(runId, artifactId)), { cache: 'no-store' }); + if (!res.ok) throw new ApiError(res.status, await errorMessage(res)); + return res.text(); +} + +export const streamUrl = (runId: string, after: number) => apiUrl(`${apiPaths.stream(runId)}?after=${after}`); diff --git a/apps/studio/src/lib/config.ts b/apps/studio/src/lib/config.ts new file mode 100644 index 0000000..c2c3145 --- /dev/null +++ b/apps/studio/src/lib/config.ts @@ -0,0 +1,8 @@ +/** Base URL of the local runtime API (no trailing slash). */ +export const API_BASE = (process.env.NEXT_PUBLIC_RUNLOOP_API ?? 'http://127.0.0.1:4317').replace(/\/+$/, ''); + +/** Mission started by the "Run demo mission" button. */ +export const DEMO_MISSION_ID = 'coding-mission'; + +/** Command shown when the runtime is offline. */ +export const START_RUNTIME_COMMAND = 'pnpm dev'; diff --git a/apps/studio/src/scene/Board.tsx b/apps/studio/src/scene/Board.tsx new file mode 100644 index 0000000..d42a919 --- /dev/null +++ b/apps/studio/src/scene/Board.tsx @@ -0,0 +1,415 @@ +'use client'; +import { useFrame } from '@react-three/fiber'; +import { memo, useLayoutEffect, useMemo, useRef } from 'react'; +import type { Zone } from '@runloop-world/world-engine'; +import { AdditiveBlending, Color, Object3D, Vector3, type InstancedMesh, type Mesh, type MeshBasicMaterial } from 'three'; +import { useScene } from './context'; +import { useLabel } from './labels'; +import { + BACK_WALL_H, + CURB_H, + FLOOR_Y, + ROOM_Y, + cellNoise, + classifyWalls, + floorTiles, + isWall, + toScene, +} from './layout'; +import { PALETTE, glow, mix } from './palette'; +import { glowTexture } from './textures'; + +const tmp = new Object3D(); +const tmpColor = new Color(); + +/** The diorama: base slab with amber rim, floor tiles, raised rooms, cutaway walls and the hub core. */ +export const Board = memo(function Board() { + const { world } = useScene(); + const w = world.width + 1.6; + const d = world.height + 1.6; + return ( + + {/* Base slab, slightly larger than the grid, with a thin glowing amber rim. */} + + + + + + + + + + + + + + + + + + ); +}); + +function Rim({ w, d, y, thickness, color, intensity }: { w: number; d: number; y: number; thickness: number; color: string; intensity: number }) { + const c = useMemo(() => glow(color, intensity), [color, intensity]); + const t = thickness; + const bars: [number, number, number, number][] = [ + [0, -d / 2, w, t], + [0, d / 2, w, t], + [-w / 2, 0, t, d], + [w / 2, 0, t, d], + ]; + return ( + + {bars.map(([x, z, sx, sz], i) => ( + + + + + ))} + + ); +} + +/** Every floor cell as one instanced tile with a subtle per-tile shade. */ +function Tiles() { + const { world } = useScene(); + const tiles = useMemo(() => floorTiles(world), [world]); + const ref = useRef(null); + useLayoutEffect(() => { + const mesh = ref.current; + if (!mesh) return; + tiles.forEach((t, i) => { + const [x, , z] = toScene(world, t.cell[0], t.cell[1]); + const h = t.zone && t.zone.kind !== 'hub' ? 0.05 : t.top; + tmp.position.set(x, t.top - h / 2, z); + tmp.scale.set(0.93, h, 0.93); + tmp.updateMatrix(); + mesh.setMatrixAt(i, tmp.matrix); + const n = cellNoise(t.cell[0], t.cell[1]); + if (t.zone && t.zone.kind !== 'hub') tmpColor.copy(mix(PALETTE.tile, t.zone.color, 0.1 + n * 0.05)); + else if (t.zone) tmpColor.copy(mix(PALETTE.corridorTile, t.zone.color, 0.06 + n * 0.04)); + else tmpColor.copy(mix(PALETTE.corridorTile, '#33415a', n * 0.35)); + mesh.setColorAt(i, tmpColor); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + mesh.computeBoundingSphere(); + }, [tiles, world]); + return ( + + + + + ); +} + +/** + * Small glowing floor studs: a dotted amber border on the slab margin and a + * sparse scatter of cyan/purple lights in corridor tile corners. + */ +function Studs({ w, d }: { w: number; d: number }) { + const { world } = useScene(); + const ref = useRef(null); + const studs = useMemo(() => { + const out: { x: number; z: number; y: number; color: Color }[] = []; + const amber = glow(PALETTE.amber, 1.6); + const step = 1; + for (let x = -w / 2 + 0.4; x <= w / 2 - 0.4 + 1e-6; x += step) { + out.push({ x, z: -d / 2 + 0.4, y: 0.004, color: amber }, { x, z: d / 2 - 0.4, y: 0.004, color: amber }); + } + for (let z = -d / 2 + 0.4 + step; z <= d / 2 - 0.4 - step + 1e-6; z += step) { + out.push({ x: -w / 2 + 0.4, z, y: 0.004, color: amber }, { x: w / 2 - 0.4, z, y: 0.004, color: amber }); + } + for (const t of floorTiles(world)) { + if (t.zone) continue; + const n = cellNoise(t.cell[0] * 3 + 1, t.cell[1] * 5 + 2); + if (n > 0.14) continue; + const [x, , z] = toScene(world, t.cell[0] + 0.5, t.cell[1] + 0.5); + out.push({ x, z, y: t.top + 0.002, color: glow(n < 0.07 ? PALETTE.cyan : PALETTE.purple, 1.4) }); + } + return out; + }, [world, w, d]); + useLayoutEffect(() => { + const mesh = ref.current; + if (!mesh) return; + studs.forEach((s, i) => { + tmp.position.set(s.x, s.y, s.z); + tmp.rotation.set(-Math.PI / 2, 0, 0); + tmp.scale.set(1, 1, 1); + tmp.updateMatrix(); + mesh.setMatrixAt(i, tmp.matrix); + mesh.setColorAt(i, s.color); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + mesh.computeBoundingSphere(); + }, [studs]); + return ( + + + + + ); +} + +/** Each room is a slightly raised, tinted platform with a soft pool of light in its colour. */ +function RoomPlatforms() { + const { world } = useScene(); + const rooms = world.def.zones.filter((z) => z.kind !== 'hub'); + const tex = glowTexture(); + return ( + + {rooms.map((z) => { + const [rx, ry, rw, rh] = z.rect; + const [cx, , cz] = toScene(world, rx + (rw - 1) / 2, ry + (rh - 1) / 2); + return ( + + + + + + + + + + + ); + })} + + ); +} + +/** + * Walls: back (north/west) room walls are tall, front (south/east) walls are + * low curbs so the inside stays visible. A glowing line runs along every wall + * top, and back walls carry glowing panels on their inner face. + */ +function Walls() { + const { world } = useScene(); + const walls = useMemo(() => classifyWalls(world), [world]); + const blocks = useRef(null); + const lines = useRef(null); + const panels = useRef(null); + + const layout = useMemo(() => { + const lineInst: { pos: [number, number, number]; scale: [number, number, number]; color: string }[] = []; + const panelInst: { pos: [number, number, number]; rotY: number; color: string; wide: boolean }[] = []; + for (const wc of walls) { + const [gx, gy] = wc.cell; + const top = wc.kind === 'back' ? ROOM_Y + BACK_WALL_H : ROOM_Y + CURB_H; + const color = wc.zone?.color ?? PALETTE.amber; + const [x, , z] = toScene(world, gx, gy); + // Half-lines from the centre towards each neighbouring wall cell (joins into one outline). + let joined = false; + for (const [dx, dy] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ] as const) { + if (!isWall(world, gx + dx, gy + dy)) continue; + joined = true; + lineInst.push({ + pos: [x + dx * 0.25, top + 0.012, z + dy * 0.25], + scale: [dx ? 0.56 : 0.06, 0.02, dy ? 0.56 : 0.06], + color, + }); + } + if (!joined) lineInst.push({ pos: [x, top + 0.012, z], scale: [0.3, 0.02, 0.3], color }); + // Panels on the inner face of back walls (towards room cells that are not walls). + if (wc.kind === 'back' && wc.zone) { + const [rx, ry, rw, rh] = wc.zone.rect; + for (const [dx, dy] of [ + [0, 1], + [1, 0], + ] as const) { + const nx = gx + dx; + const ny = gy + dy; + const inside = nx > rx && ny > ry && nx < rx + rw - 1 && ny < ry + rh - 1; + if (!inside || isWall(world, nx, ny)) continue; + const along = dx ? gy : gx; + if (along % 2 !== 0) continue; + panelInst.push({ + pos: [x + dx * 0.505, ROOM_Y + 0.78, z + dy * 0.505], + rotY: dx ? Math.PI / 2 : 0, + color, + wide: along % 4 === 0, + }); + } + } + } + return { lineInst, panelInst }; + }, [walls, world]); + + useLayoutEffect(() => { + const b = blocks.current; + if (b) { + walls.forEach((wc, i) => { + const [x, , z] = toScene(world, wc.cell[0], wc.cell[1]); + const h = wc.kind === 'back' ? ROOM_Y + BACK_WALL_H : ROOM_Y + CURB_H; + tmp.position.set(x, h / 2, z); + tmp.scale.set(1, h, 1); + tmp.rotation.set(0, 0, 0); + tmp.updateMatrix(); + b.setMatrixAt(i, tmp.matrix); + const n = cellNoise(wc.cell[0] + 7, wc.cell[1] + 3); + tmpColor.copy(mix(wc.kind === 'back' ? PALETTE.wall : PALETTE.curb, '#3a4a66', n * 0.25)); + b.setColorAt(i, tmpColor); + }); + b.instanceMatrix.needsUpdate = true; + if (b.instanceColor) b.instanceColor.needsUpdate = true; + b.computeBoundingSphere(); + } + const l = lines.current; + if (l) { + layout.lineInst.forEach((li, i) => { + tmp.position.set(...li.pos); + tmp.scale.set(...li.scale); + tmp.rotation.set(0, 0, 0); + tmp.updateMatrix(); + l.setMatrixAt(i, tmp.matrix); + l.setColorAt(i, glow(li.color, 1.6)); + }); + l.instanceMatrix.needsUpdate = true; + if (l.instanceColor) l.instanceColor.needsUpdate = true; + l.computeBoundingSphere(); + } + const p = panels.current; + if (p) { + layout.panelInst.forEach((pi, i) => { + tmp.position.set(...pi.pos); + tmp.rotation.set(0, pi.rotY, 0); + tmp.scale.set(pi.wide ? 0.78 : 0.5, pi.wide ? 0.42 : 0.62, 1); + tmp.updateMatrix(); + p.setMatrixAt(i, tmp.matrix); + p.setColorAt(i, mix('#0b1220', pi.color, pi.wide ? 0.55 : 0.32)); + }); + p.instanceMatrix.needsUpdate = true; + if (p.instanceColor) p.instanceColor.needsUpdate = true; + p.computeBoundingSphere(); + } + }, [walls, world, layout]); + + return ( + + + + + + + + + + {layout.panelInst.length > 0 && ( + + + + + )} + + ); +} + +/** The hub: a pedestal on the core cells with a pulsing ring and a glowing core column. */ +function HubCore() { + const { world, reducedMotion } = useScene(); + const hub = world.def.hub; + const hubZone = world.def.zones.find((z) => z.kind === 'hub'); + const color = hubZone?.color ?? PALETTE.amber; + const [x, , z] = toScene(world, hub.center[0], hub.center[1]); + const xs = hub.core.map((c) => c[0]); + const ys = hub.core.map((c) => c[1]); + const span = hub.core.length ? Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) + 1 : 1.5; + const r = span * 0.46; + const ring = useRef(null); + const floatRing = useRef(null); + const core = useRef(null); + const ringColor = useMemo(() => glow(color, 2), [color]); + const coreColor = useMemo(() => glow(PALETTE.cyan, 1.6), []); + const tex = glowTexture(); + + useFrame(({ clock }) => { + if (reducedMotion) return; + const t = clock.elapsedTime; + const pulse = 0.75 + 0.25 * Math.sin(t * 2.2); + ring.current?.color.copy(ringColor).multiplyScalar(pulse); + core.current?.color.copy(coreColor).multiplyScalar(0.85 + 0.2 * Math.sin(t * 3.1)); + if (floatRing.current) { + floatRing.current.rotation.z = t * 0.6; + floatRing.current.position.y = 1.45 + Math.sin(t * 1.4) * 0.05; + } + }); + + return ( + + {/* Light pool + outer floor ring around the hub. */} + + + + + + + + + {/* Pedestal */} + + + + + + + + + + + + + {/* Core column + crystal */} + + + + + + + + + + + + + + ); +} + +/** Room names as small signs above each room's back corner. */ +function ZoneLabels() { + const { world } = useScene(); + return ( + <> + {world.def.zones + .filter((z) => z.kind !== 'hub') + .map((z) => ( + + ))} + + ); +} + +function ZoneLabel({ zone }: { zone: Zone }) { + const { world } = useScene(); + const anchor = useMemo(() => { + const [rx, ry] = zone.rect; + return new Vector3(...toScene(world, rx + 1.2, ry, ROOM_Y + BACK_WALL_H + 0.55)); + }, [world, zone]); + useLabel(`zone:${zone.id}`, { kind: 'zone', text: zone.label, color: zone.color }, anchor); + return null; +} diff --git a/apps/studio/src/scene/CameraRig.tsx b/apps/studio/src/scene/CameraRig.tsx new file mode 100644 index 0000000..6416c6c --- /dev/null +++ b/apps/studio/src/scene/CameraRig.tsx @@ -0,0 +1,169 @@ +'use client'; +import { MapControls } from '@react-three/drei'; +import { useFrame, useThree } from '@react-three/fiber'; +import type { CompiledWorld } from '@runloop-world/world-engine'; +import { useCallback, useEffect, useLayoutEffect, useRef, type ComponentRef } from 'react'; +import { MathUtils, Vector3, type OrthographicCamera } from 'three'; +import { poseAt, useScene } from './context'; +import { cellTop, heightAt, toScene } from './layout'; +import type { CameraMode } from './types'; + +/** View directions (from target towards the camera). Iso looks from the south-east, so north/west walls are at the back. */ +const ISO = new Vector3(1, 1.2, 1).normalize(); +const TOP = new Vector3(0, 1, 0.002).normalize(); +/** The same views as spherical angles, so transitions tilt and turn smoothly (no spin near the pole). */ +const ANGLES = { + iso: { azimuth: Math.atan2(ISO.x, ISO.z), polar: Math.acos(ISO.y) }, + top: { azimuth: 0, polar: Math.acos(TOP.y) }, +} as const; +const fromAngles = (out: Vector3, a: { azimuth: number; polar: number }) => + out.set(Math.sin(a.polar) * Math.sin(a.azimuth), Math.cos(a.polar), Math.sin(a.polar) * Math.cos(a.azimuth)); +const DIST = 80; +const UP = new Vector3(0, 1, 0); + +/** Zoom and target that frame the whole board for a view direction. */ +export function frameBoard(world: CompiledWorld, dir: Vector3, w: number, h: number): { zoom: number; center: Vector3 } { + const fwd = dir.clone().negate(); + const right = new Vector3().crossVectors(fwd, UP).normalize(); + const up = new Vector3().crossVectors(right, fwd).normalize(); + const hx = world.width / 2 + 0.8; + const hz = world.height / 2 + 0.8; + let r0 = Infinity; + let r1 = -Infinity; + let u0 = Infinity; + let u1 = -Infinity; + const p = new Vector3(); + for (const x of [-hx, hx]) + for (const z of [-hz, hz]) + for (const y of [-0.7, 2.2]) { + p.set(x, y, z); + const r = p.dot(right); + const u = p.dot(up); + r0 = Math.min(r0, r); + r1 = Math.max(r1, r); + u0 = Math.min(u0, u); + u1 = Math.max(u1, u); + } + const zoom = Math.min(w / ((r1 - r0) * 1.04), h / ((u1 - u0) * 1.08)); + const center = right.multiplyScalar((r0 + r1) / 2).add(up.multiplyScalar((u0 + u1) / 2)); + center.y = Math.max(0, center.y); + return { zoom: Math.max(4, zoom), center }; +} + +/** + * Orthographic camera: isometric or top-down, pan/zoom with MapControls + * (rotation disabled), smooth transitions (instant under reduced motion), + * follow the selected character, focus the selected station. + */ +export function CameraRig({ + cameraMode, + follow, + selectedAgentId, + selectedStationId, +}: { + cameraMode: CameraMode; + follow: boolean; + selectedAgentId: string | null; + selectedStationId: string | null; +}) { + const { world, getFrame, reducedMotion } = useScene(); + const controls = useRef>(null); + const camera = useThree((s) => s.camera) as OrthographicCamera; + const size = useThree((s) => s.size); + const angles = useRef({ ...ANGLES[cameraMode] }); + const dir = useRef(fromAngles(new Vector3(), angles.current)); + const goalTarget = useRef(null); + const goalZoom = useRef(null); + const placed = useRef(false); + const tmp = useRef(new Vector3()).current; + + // First layout: frame the board instantly. + useLayoutEffect(() => { + const c = controls.current; + if (!c || placed.current || size.width === 0) return; + placed.current = true; + const f = frameBoard(world, dir.current, size.width, size.height); + camera.zoom = f.zoom; + camera.updateProjectionMatrix(); + c.target.copy(f.center); + camera.position.copy(f.center).addScaledVector(dir.current, DIST); + camera.lookAt(f.center); + }, [camera, size.width, size.height, world]); + + // Re-frame on mode change or resize. + const firstFrame = useRef(true); + useEffect(() => { + if (firstFrame.current) { + firstFrame.current = false; + return; + } + const f = frameBoard(world, cameraMode === 'top' ? TOP : ISO, size.width, size.height); + goalZoom.current = f.zoom; + if (!(follow && selectedAgentId)) goalTarget.current = f.center; + // Only on mode/size changes (not when the selection changes). + }, [cameraMode, size.width, size.height, world]); + + // Following someone: move in a little closer (never zooms out). + useEffect(() => { + if (!follow || !selectedAgentId) return; + const fit = frameBoard(world, cameraMode === 'top' ? TOP : ISO, size.width, size.height).zoom; + goalZoom.current = Math.max(camera.zoom, fit * 1.7); + // Only when follow is switched on or the followed agent changes. + }, [follow, selectedAgentId]); + + // Focus the selected station. + useEffect(() => { + if (!selectedStationId) return; + const s = world.stationsById[selectedStationId]; + if (!s) return; + goalTarget.current = new Vector3(...toScene(world, s.cell[0], s.cell[1], cellTop(world, s.cell[0], s.cell[1]) + 0.5)); + }, [selectedStationId, world]); + + // The user took over: drop any pending camera animation. (Stable, so drei does not reconnect the controls.) + const onUserStart = useCallback(() => { + goalTarget.current = null; + goalZoom.current = null; + }, []); + + useFrame((_, dt) => { + const c = controls.current; + if (!c) return; + const k = reducedMotion ? 1 : 1 - Math.exp(-dt * 6); + const want = ANGLES[cameraMode]; + angles.current.azimuth = MathUtils.lerp(angles.current.azimuth, want.azimuth, k); + angles.current.polar = MathUtils.lerp(angles.current.polar, want.polar, k); + fromAngles(dir.current, angles.current); + + if (follow && selectedAgentId) { + const pose = poseAt(world, getFrame(), selectedAgentId); + if (pose) { + tmp.set(...toScene(world, pose.x, pose.y, heightAt(world, pose.x, pose.y) + 0.5)); + c.target.lerp(tmp, reducedMotion ? 1 : 1 - Math.exp(-dt * 4)); + } + } else if (goalTarget.current) { + c.target.lerp(goalTarget.current, k); + if (c.target.distanceTo(goalTarget.current) < 0.005) goalTarget.current = null; + } + if (goalZoom.current !== null) { + camera.zoom = MathUtils.lerp(camera.zoom, goalZoom.current, k); + camera.updateProjectionMatrix(); + if (Math.abs(camera.zoom - goalZoom.current) < 0.01) goalZoom.current = null; + } + camera.position.copy(c.target).addScaledVector(dir.current, DIST); + camera.lookAt(c.target); + }); + + return ( + + ); +} diff --git a/apps/studio/src/scene/Character.tsx b/apps/studio/src/scene/Character.tsx new file mode 100644 index 0000000..9d8e18c --- /dev/null +++ b/apps/studio/src/scene/Character.tsx @@ -0,0 +1,442 @@ +'use client'; +import { RoundedBox } from '@react-three/drei'; +import { useFrame, type ThreeEvent } from '@react-three/fiber'; +import type { PresentationState } from '@runloop-world/world-engine'; +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Color, MathUtils, MeshBasicMaterial, type Group, type Mesh, type Vector3 } from 'three'; +import { badgeDetail, presetIcon } from './badges'; +import { poseAt, useScene } from './context'; +import { useLabel } from './labels'; +import { heightAt, toScene, yawForHeading } from './layout'; +import { PALETTE, glow, mix } from './palette'; +import { blobShadowTexture } from './textures'; + +/** Model scale: a chibi robot ~1.9 units tall to the antenna tip (1 cell = 1 unit). */ +const SCALE = 1.75; +const BADGE_Y = 1.95; + +interface View { + state: PresentationState; + detail: string; +} + +/** + * One character. Where it is and what state it shows come only from Layer 2 + * (presentationAt at the frame's tick); bobbing, typing and blinking are + * Layer-3 decoration on top. Re-renders only when the badge text changes. + */ +export const Character = memo(function Character({ + agentId, + selected, + onSelect, +}: { + agentId: string; + selected: boolean; + onSelect: (id: string | null) => void; +}) { + const { world, getFrame, reducedMotion } = useScene(); + const info = useMemo(() => { + const frame = getFrame(); + const c = frame.state.world.characters[agentId]; + const preset = (c && world.presetsById[c.presetId]) || world.presetsById[world.def.defaultPreset]!; + const name = frame.state.exec.agents[agentId]?.name ?? agentId; + // Per-agent phase so blinks and bobs are not in lockstep. + let phase = 0; + for (const ch of agentId) phase = (phase * 31 + ch.charCodeAt(0)) % 997; + return { preset, name, phase: (phase / 997) * Math.PI * 2 }; + }, [agentId, getFrame, world]); + const color = info.preset.color; + + const [view, setView] = useState(() => { + const frame = getFrame(); + const pose = poseAt(world, frame, agentId); + const state = pose?.state ?? 'idle'; + return { state, detail: badgeDetail(state, agentId, frame.state, world) }; + }); + const viewRef = useRef(view); + viewRef.current = view; + + const root = useRef(null); + const bounce = useRef(null); + const body = useRef(null); + const head = useRef(null); + const eyes = useRef(null); + const armL = useRef(null); + const armR = useRef(null); + const legL = useRef(null); + const legR = useRef(null); + const shadow = useRef(null); + const ring = useRef(null); + const dots = useRef<(Mesh | null)[]>([]); + const yaw = useRef(null); + + // One shared material for every eye shape; its colour follows the state. + const eyeMat = useMemo(() => new MeshBasicMaterial({ toneMapped: false }), []); + useEffect(() => () => eyeMat.dispose(), [eyeMat]); + useLayoutEffect(() => { + eyeMat.color.copy( + view.state === 'failed' + ? glow(PALETTE.err, 2.2) + : view.state === 'awaiting_approval' + ? glow(PALETTE.warn, 2) + : view.state === 'paused' + ? new Color(color).multiplyScalar(0.35) + : glow(mix(color, '#ffffff', 0.35), 1.9), + ); + }, [eyeMat, view.state, color]); + + useFrame(({ clock }, dt) => { + const frame = getFrame(); + const pose = poseAt(world, frame, agentId); + const g = root.current; + if (!pose || !g) return; + + // Layer 2: position + heading. + const [x, , z] = toScene(world, pose.x, pose.y); + g.position.set(x, heightAt(world, pose.x, pose.y), z); + const targetYaw = yawForHeading(pose.heading); + if (yaw.current === null || reducedMotion) yaw.current = targetYaw; + else { + const diff = Math.atan2(Math.sin(targetYaw - yaw.current), Math.cos(targetYaw - yaw.current)); + yaw.current += diff * (1 - Math.exp(-dt * 14)); + } + g.rotation.y = yaw.current; + + // React only hears about badge changes. + const detail = badgeDetail(pose.state, agentId, frame.state, world); + if (pose.state !== viewRef.current.state || detail !== viewRef.current.detail) { + viewRef.current = { state: pose.state, detail }; + setView(viewRef.current); + } + + // Layer 3 decoration. + const t = clock.elapsedTime + info.phase; + const k = reducedMotion ? 1 : 1 - Math.exp(-dt * 16); + const walking = pose.moving && frame.advancing; + animate(pose.state, walking, t, k, reducedMotion); + if (ring.current) ring.current.rotation.z = reducedMotion ? 0 : clock.elapsedTime * 0.8; + }); + + function animate(state: PresentationState, walking: boolean, t: number, k: number, still: boolean) { + const b = body.current; + const h = head.current; + const aL = armL.current; + const aR = armR.current; + const lL = legL.current; + const lR = legR.current; + const bo = bounce.current; + if (!b || !h || !aL || !aR || !lL || !lR || !bo) return; + // Targets (defaults = relaxed standing pose). + let bodyY = 0; + let bodyTiltZ = 0; + let bodyTiltX = 0; + let headX = 0; + let headY = 0; + let headZ = 0; + let armLX = 0; + let armRX = 0; + let legLX = 0; + let legRX = 0; + let hop = 0; + let blink = 1; + const loop = !still; + + if (walking && loop) { + const c = t * 11; + legLX = Math.sin(c) * 0.6; + legRX = -legLX; + armLX = -Math.sin(c) * 0.55; + armRX = -armLX; + bodyY = Math.abs(Math.sin(c)) * 0.035; + bodyTiltZ = Math.sin(c) * 0.04; + bodyTiltX = 0.06; + } else { + switch (state) { + case 'working': + armLX = -1.05 + (loop ? Math.sin(t * 16) * 0.2 : 0); + armRX = -1.05 + (loop ? Math.sin(t * 16 + Math.PI) * 0.2 : 0); + headX = 0.14 + (loop ? Math.sin(t * 4) * 0.035 : 0); + bodyTiltX = 0.07; + break; + case 'succeeded': + hop = loop ? Math.max(0, Math.sin(t * 7)) * 0.13 : 0; + armLX = -2.5 + (loop ? Math.sin(t * 7) * 0.25 : 0); + armRX = armLX; + headX = -0.12; + break; + case 'failed': + bodyTiltZ = 0.22; + headX = 0.32; + headZ = 0.14; + armLX = 0.12; + armRX = 0.12; + break; + case 'waiting': + armLX = -0.45; + armRX = -0.45; + headZ = loop ? Math.sin(t * 1.2) * 0.08 : 0.06; + break; + case 'awaiting_approval': + headZ = 0.2 + (loop ? Math.sin(t * 1.5) * 0.04 : 0); + armRX = -2.6; + break; + case 'paused': + headX = 0.08; + break; + default: + if (loop) { + bodyY = Math.sin(t * 2) * 0.008; + headY = Math.sin(t * 0.6) * 0.18; + blink = (t % 3.7) < 0.12 ? 0.12 : 1; + } + } + } + const L = MathUtils.lerp; + b.position.y = L(b.position.y, bodyY, k); + b.rotation.z = L(b.rotation.z, bodyTiltZ, k); + b.rotation.x = L(b.rotation.x, bodyTiltX, k); + h.rotation.x = L(h.rotation.x, headX, k); + h.rotation.y = L(h.rotation.y, headY, k); + h.rotation.z = L(h.rotation.z, headZ, k); + aL.rotation.x = L(aL.rotation.x, armLX, k); + aR.rotation.x = L(aR.rotation.x, armRX, k); + lL.rotation.x = L(lL.rotation.x, legLX, k); + lR.rotation.x = L(lR.rotation.x, legRX, k); + bo.position.y = hop; + if (shadow.current) shadow.current.scale.setScalar(1 - hop * 1.6); + if (eyes.current) eyes.current.scale.y = blink; + dots.current.forEach((d, i) => { + if (d) d.scale.setScalar(loop ? 0.7 + 0.5 * Math.max(0, Math.sin(t * 5 - i * 0.9)) : 1); + }); + } + + // Badge: follows the Layer-2 position (evaluated by the label projector each frame). + const anchor = useMemo( + () => (out: Vector3) => { + const pose = poseAt(world, getFrame(), agentId); + if (!pose) return null; + const [x, , z] = toScene(world, pose.x, pose.y); + return out.set(x, heightAt(world, pose.x, pose.y) + BADGE_Y, z); + }, + [world, getFrame, agentId], + ); + useLabel( + `agent:${agentId}`, + { + kind: 'agent', + agentId, + name: info.name, + color, + icon: presetIcon(info.preset.icon), + state: view.state, + detail: view.detail, + selected, + }, + anchor, + ); + + const select = (e: ThreeEvent) => { + e.stopPropagation(); + onSelect(agentId); + }; + const shell = PALETTE.robotShell; + const bodyColor = useMemo(() => mix(shell, color, 0.55), [shell, color]); + + return ( + + + + + + {selected && ( + + + + + )} + { + e.stopPropagation(); + document.body.style.cursor = 'pointer'; + }} + onPointerOut={() => { + document.body.style.cursor = ''; + }} + > + {/* Invisible, generous hit box for clicking. */} + + + + + {/* Legs */} + {[-1, 1].map((side) => ( + + + + + + + ))} + + + + + + + + + {/* Arms */} + {[-1, 1].map((side) => ( + + + + + + + + + + + + + ))} + + + + + {/* Head */} + + + + + {/* Wrap-around visor: pokes through the head's rounded front corners so it reads from the side too. */} + + + + + + + {[-1, 1].map((side) => ( + + + + + ))} + + + + + + + + + + + + + + ); +}); + +/** Eye shapes per presentation state (all emissive, on the dark visor). */ +function Eyes({ + state, + mat, + dots, +}: { + state: PresentationState; + mat: MeshBasicMaterial; + dots: React.RefObject<(Mesh | null)[]>; +}) { + switch (state) { + case 'working': + // Focus slits. + return ( + <> + {[-1, 1].map((s) => ( + + + + ))} + + ); + case 'succeeded': + // Happy arcs ^ ^. + return ( + <> + {[-1, 1].map((s) => ( + + + + ))} + + ); + case 'failed': + // Red X X. + return ( + <> + {[-1, 1].map((s) => + [1, -1].map((r) => ( + + + + )), + )} + + ); + case 'waiting': + // "..." + return ( + <> + {[-1, 0, 1].map((s, i) => ( + { + dots.current[i] = m; + }} + position={[s * 0.085, -0.01, 0]} + material={mat} + > + + + ))} + + ); + case 'awaiting_approval': + // "?" + return ( + + + + + + + + + + + + ); + default: + // idle / moving / paused: round pill eyes (dim when paused). + return ( + <> + {[-1, 1].map((s) => ( + + + + ))} + + ); + } +} diff --git a/apps/studio/src/scene/Characters.tsx b/apps/studio/src/scene/Characters.tsx new file mode 100644 index 0000000..fcdf431 --- /dev/null +++ b/apps/studio/src/scene/Characters.tsx @@ -0,0 +1,35 @@ +'use client'; +import { useFrame } from '@react-three/fiber'; +import { useRef, useState } from 'react'; +import { Character } from './Character'; +import { useScene } from './context'; + +/** Mounts one per Layer-2 character; re-renders only when that list changes. */ +export function Characters({ + selectedAgentId, + onSelectAgent, +}: { + selectedAgentId: string | null; + onSelectAgent: (id: string | null) => void; +}) { + const { getFrame } = useScene(); + const [ids, setIds] = useState(() => getFrame().state.world.characterOrder); + const key = useRef(ids.join('|')); + + useFrame(() => { + const order = getFrame().state.world.characterOrder; + const k = order.join('|'); + if (k !== key.current) { + key.current = k; + setIds(order); + } + }); + + return ( + + {ids.map((id) => ( + + ))} + + ); +} diff --git a/apps/studio/src/scene/Effects.tsx b/apps/studio/src/scene/Effects.tsx new file mode 100644 index 0000000..6b48253 --- /dev/null +++ b/apps/studio/src/scene/Effects.tsx @@ -0,0 +1,406 @@ +'use client'; +import { useFrame } from '@react-three/fiber'; +import type { Incident } from '@runloop-world/core'; +import { isUnknownEvent, type AnyEvent } from '@runloop-world/events'; +import { bfs, type Cell } from '@runloop-world/world-engine'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AdditiveBlending, Color, Vector3, type Group, type Mesh, type MeshBasicMaterial } from 'three'; +import { poseAt, stationStatuses, useScene, type SceneCtx } from './context'; +import { useLabel } from './labels'; +import { cellTop, heightAt, hubRing, incidentStationId, pathPoints, toScene } from './layout'; +import { PALETTE, glow } from './palette'; +import { pointAlong, polylineLength, ribbonGeometry } from './textures'; +import type { SceneFrame } from './types'; + +type P3 = [number, number, number]; + +type Fx = + | { id: number; kind: 'packet'; pts: P3[]; color: string } + | { id: number; kind: 'burst'; pos: P3; ok: boolean } + | { id: number; kind: 'artifact'; pos: P3; color: string }; + +let nextId = 1; + +const ARTIFACT_COLOR: Record = { + file: PALETTE.cyan, + diff: PALETTE.purple, + test_report: PALETTE.amber, + log: '#94a3b8', +}; + +/** + * Transient Layer-3 effects for events that just became visible (message + * packets, tool success/error bursts, artifact pops) plus persistent incident + * beacons derived from Layer-1 incidents. + */ +export function Effects() { + const ctx = useScene(); + const [items, setItems] = useState([]); + + useEffect( + () => + ctx.subscribeEffects((events) => { + const frame = ctx.getFrame(); + const add: Fx[] = []; + for (const e of events) add.push(...effectsFor(e, frame, ctx)); + if (add.length) setItems((prev) => [...prev, ...add].slice(-24)); + }), + [ctx], + ); + + const done = useCallback((id: number) => setItems((prev) => prev.filter((f) => f.id !== id)), []); + + return ( + + {items.map((f) => + f.kind === 'packet' ? ( + + ) : f.kind === 'burst' ? ( + + ) : ( + + ), + )} + + + ); +} + +/** Where an agent's work shows: above its station if it holds one, else above its head. */ +function anchorFor(ctx: SceneCtx, frame: SceneFrame, agentId: string | null | undefined): P3 | null { + if (!agentId) return null; + const c = frame.state.world.characters[agentId]; + if (!c) return null; + const station = c.stationId ? ctx.world.stationsById[c.stationId] : undefined; + if (station) { + const [x, , z] = toScene(ctx.world, station.cell[0], station.cell[1]); + // High enough to clear the badge of a character standing in front of the station. + return [x, cellTop(ctx.world, station.cell[0], station.cell[1]) + 2.6, z]; + } + const pose = poseAt(ctx.world, frame, agentId); + if (!pose) return null; + const [x, , z] = toScene(ctx.world, pose.x, pose.y); + return [x, heightAt(ctx.world, pose.x, pose.y) + 2.9, z]; +} + +function effectsFor(e: AnyEvent, frame: SceneFrame, ctx: SceneCtx): Fx[] { + if (isUnknownEvent(e)) return []; + const exec = frame.state.exec; + switch (e.type) { + case 'tool.call_completed': + case 'tool.call_failed': { + const call = exec.toolCalls[e.payload.callId]; + const pos = anchorFor(ctx, frame, call?.agentId); + const ok = e.type === 'tool.call_completed' && e.payload.status === 'success'; + return pos ? [{ id: nextId++, kind: 'burst', pos, ok }] : []; + } + case 'artifact.created': { + const by = e.payload.producedByCallId ? exec.toolCalls[e.payload.producedByCallId]?.agentId : e.actorId; + const pos = anchorFor(ctx, frame, by); + if (!pos) return []; + return [{ id: nextId++, kind: 'artifact', pos: [pos[0], pos[1] - 0.35, pos[2]], color: ARTIFACT_COLOR[e.payload.kind] ?? PALETTE.cyan }]; + } + case 'agent.message_sent': { + const { world } = ctx; + const from = poseAt(world, frame, e.payload.fromAgentId); + const start: Cell | undefined = from ? from.cell : hubRing(world)[0]; + if (!start) return []; + const color = world.presetsById[frame.state.world.characters[e.payload.fromAgentId]?.presetId ?? '']?.color ?? PALETTE.cyan; + const targets = + e.payload.to === 'all' + ? frame.state.world.characterOrder.filter((id) => id !== e.payload.fromAgentId) + : [e.payload.to]; + const out: Fx[] = []; + for (const id of targets) { + const to = poseAt(world, frame, id); + if (!to) continue; + const cells = bfs(world, start, to.cell) ?? [start, to.cell]; + out.push({ id: nextId++, kind: 'packet', pts: pathPoints(world, cells, 1.05), color }); + } + return out; + } + default: + return []; + } +} + +const SPEED = 7; // cells per second + +/** A glowing packet that travels the BFS path, leaving a fading trail (static line under reduced motion). */ +function Packet({ fx, onDone }: { fx: Extract; onDone: (id: number) => void }) { + const { reducedMotion } = useScene(); + const len = useMemo(() => polylineLength(fx.pts), [fx.pts]); + const trail = useMemo(() => ribbonGeometry(fx.pts, 0.1, { uPerUnit: 1 / Math.max(len, 0.001), extend: true }), [fx.pts, len]); + useEffect(() => () => trail.dispose(), [trail]); + const head = useRef(null); + const trailMat = useRef(null); + const start = useRef(null); + const travel = Math.min(3, Math.max(0.6, len / SPEED)); + const total = reducedMotion ? 1.6 : travel + 0.6; + const v = useMemo(() => new Vector3(), []); + const c = useMemo(() => glow(fx.color, 2.4), [fx.color]); + + useFrame(({ clock }) => { + start.current ??= clock.elapsedTime; + const age = clock.elapsedTime - start.current; + if (age > total) { + onDone(fx.id); + return; + } + if (reducedMotion) { + if (head.current) head.current.visible = false; + return; + } + const s = Math.min(1, age / travel) * len; + pointAlong(fx.pts, s, v); + if (head.current) { + head.current.position.copy(v); + head.current.visible = age <= travel; + } + if (trailMat.current) trailMat.current.opacity = age <= travel ? 0.65 : 0.65 * (1 - (age - travel) / 0.6); + }); + + return ( + + + + + + + + + + + + + + + + ); +} + +/** Success: an expanding green ring with a check mark. Error: a red double pulse with an X. */ +function Burst({ fx, onDone }: { fx: Extract; onDone: (id: number) => void }) { + const { reducedMotion } = useScene(); + const group = useRef(null); + const ringA = useRef(null); + const ringB = useRef(null); + const mats = useRef([]); + const start = useRef(null); + const color = useMemo(() => glow(fx.ok ? PALETTE.ok : PALETTE.err, 2.4), [fx.ok]); + const total = fx.ok ? 1.3 : 1.6; + + useFrame(({ clock }) => { + start.current ??= clock.elapsedTime; + const age = clock.elapsedTime - start.current; + if (age > total) { + onDone(fx.id); + return; + } + if (reducedMotion) return; + const p = age / total; + if (group.current) group.current.position.y = fx.pos[1] + p * 0.35; + const pulse = (q: number) => 0.4 + q * 1.4; + ringA.current?.scale.setScalar(pulse(Math.min(1, age / 0.7))); + if (ringB.current) { + const q = Math.max(0, Math.min(1, (age - 0.35) / 0.7)); + ringB.current.scale.setScalar(pulse(q)); + ringB.current.visible = !fx.ok && age > 0.35; + } + for (const m of mats.current) m.opacity = 1 - p * p; + }); + + const mat = (i: number) => (m: MeshBasicMaterial | null) => { + if (m) mats.current[i] = m; + }; + return ( + + + + + + + + + + {/* Glyph: check or X, as two bars (billboarded loosely by facing the iso camera). */} + + {fx.ok ? ( + <> + + + + + + + + + + ) : ( + [1, -1].map((r, i) => ( + + + + + )) + )} + + + ); +} + +/** A small glowing cube pops out above the station, spins, and fades. */ +function ArtifactPop({ fx, onDone }: { fx: Extract; onDone: (id: number) => void }) { + const { reducedMotion } = useScene(); + const g = useRef(null); + const mat = useRef(null); + const start = useRef(null); + const color = useMemo(() => glow(fx.color, 2.2), [fx.color]); + const total = 2.4; + + useFrame(({ clock }) => { + start.current ??= clock.elapsedTime; + const age = clock.elapsedTime - start.current; + if (age > total) { + onDone(fx.id); + return; + } + if (reducedMotion || !g.current) return; + const pop = age < 0.35 ? easeOutBack(age / 0.35) : 1; + g.current.scale.setScalar(Math.max(0.001, pop)); + g.current.position.y = fx.pos[1] + Math.min(1, age / total) * 0.5; + g.current.rotation.y = age * 2.2; + g.current.rotation.x = age * 1.3; + if (mat.current) mat.current.opacity = age > total - 0.6 ? (total - age) / 0.6 : 1; + }); + + return ( + + + + + + + + + + + ); +} + +function easeOutBack(t: number): number { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); +} + +/* ── Incident beacons ──────────────────────────────────────────────────── */ + +interface Beacon { + inc: Incident; + stationId: string; + index: number; +} + +/** Persistent markers for exec.incidents at the station where they happened (grey once resolved). */ +function IncidentBeacons() { + const ctx = useScene(); + const [beacons, setBeacons] = useState([]); + const sig = useRef(''); + + useFrame(() => { + const frame = ctx.getFrame(); + // Cheap check first: statuses are cached per RunState. + stationStatuses(ctx, frame.state); + const incs = frame.state.exec.incidents; + let s = ''; + for (const i of incs) s += `${i.eventId}:${i.resolvedSeq === null ? 0 : 1};`; + if (s === sig.current) return; + sig.current = s; + const perStation = new Map(); + const next: Beacon[] = []; + for (const inc of incs) { + if (inc.kind === 'run_failed') continue; + const stationId = incidentStationId(ctx.world, frame.state, inc, ctx.incidentStations); + if (!stationId) continue; + const index = perStation.get(stationId) ?? 0; + perStation.set(stationId, index + 1); + next.push({ inc, stationId, index }); + } + setBeacons(next); + }); + + return ( + + {beacons.map((b) => ( + + ))} + + ); +} + +const INCIDENT_LABEL: Record = { + tool_error: 'Tool error', + tool_failed: 'Tool failed', + task_failed: 'Task failed', + run_failed: 'Run failed', +}; + +function BeaconMarker({ beacon }: { beacon: Beacon }) { + const { world, reducedMotion } = useScene(); + const station = world.stationsById[beacon.stationId]!; + const open = beacon.inc.resolvedSeq === null; + const [x, , z] = toScene(world, station.cell[0], station.cell[1]); + const base = cellTop(world, station.cell[0], station.cell[1]); + const y = base + 2.95 + beacon.index * 0.6; + const color = useMemo(() => (open ? glow(PALETTE.err, 2.4) : new Color(PALETTE.resolved)), [open]); + const gem = useRef(null); + const beam = useRef(null); + const anchor = useMemo(() => new Vector3(x, y + 0.75, z), [x, y, z]); + const inc = beacon.inc; + useLabel( + `incident:${inc.eventId}`, + { + kind: 'incident', + open, + title: inc.message, + text: open ? `${INCIDENT_LABEL[inc.kind]} · ${clip(inc.message, 26)}` : `Resolved · ${INCIDENT_LABEL[inc.kind]}`, + }, + anchor, + ); + + useFrame(({ clock }) => { + if (!gem.current || reducedMotion || !open) return; + const t = clock.elapsedTime; + gem.current.rotation.y = t * 1.4; + gem.current.position.y = Math.sin(t * 2.2) * 0.05; + if (beam.current) beam.current.opacity = 0.22 + 0.12 * Math.sin(t * 5); + }); + + return ( + + {open && ( + <> + + + + + + + + + + )} + + + + + + + + + + ); +} + +const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); diff --git a/apps/studio/src/scene/Icon.tsx b/apps/studio/src/scene/Icon.tsx new file mode 100644 index 0000000..d51945a --- /dev/null +++ b/apps/studio/src/scene/Icon.tsx @@ -0,0 +1,28 @@ +import type { IconName } from './badges'; +import styles from './scene.module.css'; + +/** Tiny inline stroke icons (no icon font or emoji, so they render everywhere). */ +const PATHS: Record = { + search: 'M10.5 4a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13zM15.5 15.5L20 20', + hammer: 'M13 6l5 5M4 20l9-9M11 4l3-1l7 7l-1 3l-3 0l-6-6z', + flask: 'M9 3h6M10 3v6l-5.2 9.2A1.5 1.5 0 0 0 6.1 21h11.8a1.5 1.5 0 0 0 1.3-2.8L14 9V3M7.5 15h9', + rocket: 'M12 2.5c3 2.5 4.2 6.5 3.2 11.5H8.8C7.8 9 9 5 12 2.5zM8.8 14L6 17.5l3.2.5M15.2 14l2.8 3.5l-3.2.5M10.5 18.5L12 21.5l1.5-3M12 8v1.5', + bot: 'M6 8h12v10H6zM12 4v4M9.5 13h0M14.5 13h0M4 12v3M20 12v3', + dot: 'M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6z', + walk: 'M5 12h12M13 7l5 5l-5 5', + gear: 'M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6zM12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1', + hourglass: 'M7 3h10M7 21h10M8 3c0 5 8 5 8 9s-8 4-8 9M16 3c0 5-8 5-8 9s8 4 8 9', + question: 'M9 9a3 3 0 1 1 4.5 2.6c-1 .6-1.5 1.2-1.5 2.4M12 18v.5', + check: 'M5 12.5l4.5 4.5L19 7', + cross: 'M6 6l12 12M18 6L6 18', + pause: 'M9 5v14M15 5v14', + alert: 'M12 3L2.5 20h19zM12 9.5v5M12 17.5v.5', +}; + +export function Icon({ name, color }: { name: IconName; color?: string }) { + return ( + + + + ); +} diff --git a/apps/studio/src/scene/Paths.tsx b/apps/studio/src/scene/Paths.tsx new file mode 100644 index 0000000..52e2d93 --- /dev/null +++ b/apps/studio/src/scene/Paths.tsx @@ -0,0 +1,77 @@ +'use client'; +import { useFrame } from '@react-three/fiber'; +import { memo, useEffect, useMemo } from 'react'; +import { DoubleSide } from 'three'; +import { useScene } from './context'; +import { glowPaths, pathPoints } from './layout'; +import { PALETTE, glow } from './palette'; +import { chevronTexture, ribbonGeometry } from './textures'; + +/** + * Glowing conveyor lines from the hub to each room door (and on to its + * stations). Purely decorative Layer-3 geometry derived from world-engine BFS. + */ +export const Paths = memo(function Paths() { + const { world, reducedMotion } = useScene(); + const tex = useMemo(() => chevronTexture(), []); + + const ribbons = useMemo(() => { + return glowPaths(world).map((p, i) => { + const lift = 0.004 + (i % 4) * 0.0015; + const pts = pathPoints(world, p.cells, lift); + const ptsMid = pathPoints(world, p.cells, lift + 0.002); + const ptsTop = pathPoints(world, p.cells, lift + 0.004); + const wide = p.branch ? 0.2 : 0.32; + return { + key: p.key, + branch: p.branch, + color: p.color, + track: ribbonGeometry(pts, wide, { extend: true }), + line: ribbonGeometry(ptsMid, p.branch ? 0.045 : 0.06, { extend: true }), + chevrons: ribbonGeometry(ptsTop, wide * 0.62, { uPerUnit: 2.2 }), + }; + }); + }, [world]); + + useEffect( + () => () => { + for (const r of ribbons) { + r.track.dispose(); + r.line.dispose(); + r.chevrons.dispose(); + } + }, + [ribbons], + ); + + useFrame((_, dt) => { + if (reducedMotion) return; + tex.offset.x = (tex.offset.x - dt * 0.9) % 1; + }); + + return ( + + {ribbons.map((r) => ( + + + + + + + + + + + + ))} + + ); +}); diff --git a/apps/studio/src/scene/Props.tsx b/apps/studio/src/scene/Props.tsx new file mode 100644 index 0000000..f87000e --- /dev/null +++ b/apps/studio/src/scene/Props.tsx @@ -0,0 +1,162 @@ +'use client'; +import type { Prop } from '@runloop-world/world-engine'; +import { memo } from 'react'; +import { AdditiveBlending } from 'three'; +import { useScene } from './context'; +import { cellTop, isWall, toScene, yawFor } from './layout'; +import { PALETTE, glow } from './palette'; +import { blobShadowTexture, glowTexture } from './textures'; + +/** Decorative props from the world definition, built from simple primitives. */ +export const Props = memo(function Props() { + const { world } = useScene(); + return ( + + {world.def.props.map((p, i) => { + const [x, , z] = toScene(world, p.cell[0], p.cell[1]); + const [fx, fy] = propFront(world, p); + return ( + + + + + + + + ); + })} + + ); +}); + +/** Face away from adjacent walls, towards open floor (S, E, N, W preference). */ +function propFront(world: ReturnType['world'], p: Prop): [number, number] { + const [px, py] = p.cell; + for (const [dx, dy] of [ + [0, 1], + [1, 0], + [0, -1], + [-1, 0], + ] as const) { + if (isWall(world, px - dx, py - dy) && !isWall(world, px + dx, py + dy)) return [dx, dy]; + } + return [0, 1]; +} + +function PropModel({ kind, seed }: { kind: Prop['kind']; seed: number }) { + const accent = seed % 2 ? PALETTE.purple : PALETTE.cyan; + switch (kind) { + case 'rack': + return ( + + + + + + + + + + {[0.3, 0.52, 0.74, 0.96].map((y, i) => ( + + + + + + + + + + + + + + + ))} + + ); + case 'console': + return ( + + + + + + + + + + + + + + + ); + case 'crate': + return ( + + + + + + + + + + + + + + + ); + case 'plant': + return ( + + + + + + + + + + {[ + [0, 0.5, 0, 0.2], + [0.1, 0.42, 0.06, 0.15], + [-0.09, 0.45, -0.05, 0.14], + [0.02, 0.66, -0.02, 0.12], + ].map(([px, py, pz, r], i) => ( + + + + + ))} + + ); + case 'lamp': + return ( + + + + + + + + + + + + + + + + + + + + + + + ); + } +} diff --git a/apps/studio/src/scene/Stations.tsx b/apps/studio/src/scene/Stations.tsx new file mode 100644 index 0000000..93cf712 --- /dev/null +++ b/apps/studio/src/scene/Stations.tsx @@ -0,0 +1,448 @@ +'use client'; +import { useFrame, type ThreeEvent } from '@react-three/fiber'; +import type { Station } from '@runloop-world/world-engine'; +import { memo, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Color, DoubleSide, FrontSide, Vector3, type Group, type MeshBasicMaterial, type Object3D } from 'three'; +import { stationStatuses, useScene } from './context'; +import { useLabel } from './labels'; +import { cellTop, stationColor, stationFront, toScene, yawFor } from './layout'; +import { PALETTE, glow } from './palette'; +import { blobShadowTexture } from './textures'; + +/** Animated parts a station model registers so the live status can drive them. */ +interface Rig { + screens: MeshBasicMaterial[]; + /** Test rig gauge lights: [ok, busy, error]. */ + gauges: MeshBasicMaterial[]; + arm: Object3D | null; + forearm: Object3D | null; + rocket: Object3D | null; +} + +const ERR = new Color(PALETTE.err); +const GAUGE = [glow(PALETTE.ok, 2), glow(PALETTE.warn, 2), glow(PALETTE.err, 2.2)]; +const tmp = new Color(); + +export function Stations({ + selectedStationId, + onSelectStation, +}: { + selectedStationId: string | null; + onSelectStation: (id: string | null) => void; +}) { + const { world } = useScene(); + return ( + + {world.def.stations.map((s) => ( + + ))} + + ); +} + +const StationNode = memo(function StationNode({ + station, + selected, + onSelect, +}: { + station: Station; + selected: boolean; + onSelect: (id: string | null) => void; +}) { + const ctx = useScene(); + const { world, getFrame, reducedMotion } = ctx; + const [hover, setHover] = useState(false); + const color = stationColor(world, station); + const zoneColor = useMemo(() => new Color(color), [color]); + const padColor = useMemo(() => glow(color, 1.1), [color]); + const rig = useRef({ screens: [], gauges: [], arm: null, forearm: null, rocket: null }).current; + const ring = useRef(null); + const [fx, fy] = stationFront(station); + const top = cellTop(world, station.cell[0], station.cell[1]); + const [x, , z] = toScene(world, station.cell[0], station.cell[1]); + const labelAnchor = useMemo(() => new Vector3(x, top + 2.4, z), [x, top, z]); + useLabel(`station:${station.id}`, selected || hover ? { kind: 'station', text: station.label, color } : null, labelAnchor); + + useFrame(({ clock }) => { + const frame = getFrame(); + const st = stationStatuses(ctx, frame.state).get(station.id); + if (!st) return; + const t = clock.elapsedTime; + const occupied = st.occupant !== null; + const open = st.openIncidents.length > 0; + let k = occupied ? 1.6 : 0.95; + if (st.busy) k = reducedMotion ? 2 : 1.3 + 0.9 * (0.5 + 0.5 * Math.sin(t * 11)); + tmp.copy(zoneColor); + if (open) tmp.lerp(ERR, 0.75); + for (const m of rig.screens) m.color.copy(tmp).multiplyScalar(k); + + if (rig.gauges.length === 3) { + const lit = [occupied && !open && !st.busy, st.busy, open]; + rig.gauges.forEach((m, i) => { + const blink = i === 1 && st.busy && !reducedMotion ? 0.5 + 0.5 * Math.sin(t * 14) : 1; + m.color.copy(GAUGE[i]!).multiplyScalar(lit[i] ? blink : 0.12); + }); + } + const animate = occupied && !reducedMotion && !frame.state.world.paused; + if (rig.arm) rig.arm.rotation.y = animate ? Math.sin(t * 1.7) * 0.7 : 0.4; + if (rig.forearm) rig.forearm.rotation.z = animate ? -0.9 + Math.sin(t * 3.1) * 0.35 : -0.9; + if (rig.rocket) { + rig.rocket.position.y = 0.12 + (st.busy && !reducedMotion ? 0.06 + Math.sin(t * 9) * 0.02 : 0); + rig.rocket.rotation.y = animate ? t * 0.8 : 0; + } + if (ring.current && !reducedMotion) ring.current.scale.setScalar(1 + 0.04 * Math.sin(t * 4)); + }); + + const select = (e: ThreeEvent) => { + e.stopPropagation(); + onSelect(station.id); + }; + + return ( + + { + e.stopPropagation(); + setHover(true); + document.body.style.cursor = 'pointer'; + }} + onPointerOut={() => { + setHover(false); + document.body.style.cursor = ''; + }} + > + + + + + + + {/* Floor pad outline in the zone colour. */} + + + + + {selected && ( + + + + + + + )} + + ); +}); + +/* ── Models (front = +z, origin on the floor at the cell centre) ─────────── */ + +function Metal({ color = PALETTE.metal, rough = 0.45 }: { color?: string; rough?: number }) { + return ; +} + +function Screen({ rig, color, children, double }: { rig: Rig; color: string; children: ReactNode; double?: boolean }) { + return ( + + {children} + { + if (m && !rig.screens.includes(m)) rig.screens.push(m); + }} + color={glow(color, 1.2)} + toneMapped={false} + /> + + ); +} + +function StationModel({ kind, color, rig }: { kind: Station['kind']; color: string; rig: Rig }) { + switch (kind) { + case 'library': + return ; + case 'build': + return ; + case 'test': + return ; + case 'gate': + return ; + case 'ops': + return ; + } +} + +/** A thin glowing strip on a model's back face, so machines facing away from the camera still read as live. */ +function BackStrip({ rig, color, y, z, w }: { rig: Rig; color: string; y: number; z: number; w: number }) { + return ( + + + + + + ); +} + +/** Library: a tall terminal with a big screen and a keyboard ledge. */ +function Terminal({ color, rig }: { color: string; rig: Rig }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[-0.2, 0, 0.2].map((dx, i) => ( + + + + + ))} + + ); +} + +/** Build: a workbench with a monitor and a robotic arm. */ +function Workbench({ color, rig }: { color: string; rig: Rig }) { + return ( + + + + + + {[ + [-0.38, -0.25], + [0.38, -0.25], + [-0.38, 0.25], + [0.38, 0.25], + ].map(([lx, lz], i) => ( + + + + + ))} + + + + + {/* Monitor */} + + + + + + + + + + + + + + + + + {/* Part on the bench */} + + + + + {/* Robotic arm */} + + + + + + void (rig.arm = o)} position={[0, 0.08, 0]}> + + + + + + void (rig.forearm = o)} position={[0, 0.34, 0]} rotation={[0, 0, -0.9]}> + + + + + + + + + + + + + + + + + + ); +} + +/** Test: a rig with a status screen, gauge lights and sample tubes. */ +function TestRig({ color, rig }: { color: string; rig: Rig }) { + return ( + + + + + + + + + + + + + + + + + + + + + {[-0.22, 0, 0.22].map((gx, i) => ( + + + { + if (m && !rig.gauges.includes(m)) rig.gauges[i] = m; + }} + color={GAUGE[i]} + toneMapped={false} + /> + + ))} + {[-0.25, 0.25].map((tx, i) => ( + + + + + + + + + + + ))} + + ); +} + +/** Gate: an arch with a glowing inner frame. */ +function Gate({ color, rig }: { color: string; rig: Rig }) { + return ( + + + + + + {[-0.4, 0.4].map((px) => ( + + + + + ))} + + + + + + {[ + [-0.31, 0.72, 0.03, 1.28], + [0.31, 0.72, 0.03, 1.28], + [0, 1.375, 0.65, 0.03], + ].map(([sx, sy, w, h], i) => ( + + + + + + ))} + + + + + + + + + + + + ); +} + +/** Ops: a round launch pad with a small rocket and a console. */ +function LaunchPad({ color, rig }: { color: string; rig: Rig }) { + return ( + + + + + + + + + + void (rig.rocket = o)} position={[0, 0.12, -0.02]}> + + + + + + + + + + + + + {[0, (2 * Math.PI) / 3, (4 * Math.PI) / 3].map((a) => ( + + + + + ))} + + + + + + + + + + + + + + ); +} diff --git a/apps/studio/src/scene/WorldCanvas.tsx b/apps/studio/src/scene/WorldCanvas.tsx new file mode 100644 index 0000000..c05a17c --- /dev/null +++ b/apps/studio/src/scene/WorldCanvas.tsx @@ -0,0 +1,113 @@ +'use client'; +import { Environment, Lightformer } from '@react-three/drei'; +import { Canvas, useFrame } from '@react-three/fiber'; +import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; +import { memo, useMemo, useRef, useState } from 'react'; +import { Board } from './Board'; +import { CameraRig } from './CameraRig'; +import { Characters } from './Characters'; +import { SceneContext, type SceneCtx } from './context'; +import { Effects } from './Effects'; +import { LabelLayer, LabelProjector, LabelStore } from './labels'; +import { Paths } from './Paths'; +import { Props } from './Props'; +import { Stations } from './Stations'; +import type { SceneProps } from './types'; +import styles from './scene.module.css'; + +/** + * The 3D world (Layer 3). Reads Layer 1 + 2 state through `getFrame()` every + * frame and never mutates it. Rendered by the shell via next/dynamic (ssr:false). + */ +export default function WorldCanvas(props: SceneProps) { + const { + world, + getFrame, + subscribeEffects, + selectedAgentId, + selectedStationId, + onSelectAgent, + onSelectStation, + cameraMode, + follow, + quality, + reducedMotion, + onReady, + } = props; + const wrap = useRef(null); + + const [labels] = useState(() => new LabelStore()); + const incidentStations = useMemo(() => new Map(), [world]); + const ctx = useMemo( + () => ({ world, getFrame, subscribeEffects, reducedMotion, incidentStations, labels }), + [world, getFrame, subscribeEffects, reducedMotion, incidentStations, labels], + ); + const bloom = quality === 'high' && !reducedMotion; + + return ( +
+ { + onSelectAgent(null); + onSelectStation(null); + }} + fallback={
WebGL is not available, so the 3D world cannot be shown. The timeline and logs still show everything that happened.
} + > + + + + + + + + + + + + + {bloom && ( + + + + + )} +
+ +
+ ); +} + +/** Memoized: re-rendering would re-render its cube map (and PMREM) every time. */ +const Lights = memo(function Lights() { + return ( + <> + + + + + {/* Local reflections for glossy surfaces (no network: lightformers render on the GPU). */} + + + + + + + + ); +}); + +/** Marks the wrapper data-world-ready="true" and calls onReady once the first frame has rendered. */ +function Ready({ wrap, onReady }: { wrap: React.RefObject; onReady?: () => void }) { + const frames = useRef(0); + useFrame(() => { + frames.current += 1; + if (frames.current !== 2) return; + wrap.current?.setAttribute('data-world-ready', 'true'); + onReady?.(); + }); + return null; +} diff --git a/apps/studio/src/scene/badges.ts b/apps/studio/src/scene/badges.ts new file mode 100644 index 0000000..451fd23 --- /dev/null +++ b/apps/studio/src/scene/badges.ts @@ -0,0 +1,77 @@ +import type { RunState } from '@runloop-world/replay'; +import type { CompiledWorld, PresentationState } from '@runloop-world/world-engine'; + +export type IconName = + | 'search' + | 'hammer' + | 'flask' + | 'rocket' + | 'bot' + | 'dot' + | 'walk' + | 'gear' + | 'hourglass' + | 'question' + | 'check' + | 'cross' + | 'pause' + | 'alert'; + +export type Tone = 'neutral' | 'active' | 'ok' | 'err' | 'warn' | 'muted'; + +export const TONE_COLOR: Record = { + neutral: '#94a3b8', + active: '#7dd3fc', + ok: '#6ee7b7', + err: '#fda4af', + warn: '#fcd34d', + muted: '#64748b', +}; + +/** Every presentation state has an icon AND a text label (never colour alone). */ +export const STATE_BADGE: Record = { + idle: { icon: 'dot', label: 'Idle', tone: 'neutral' }, + moving: { icon: 'walk', label: 'Walking', tone: 'active' }, + working: { icon: 'gear', label: 'Working', tone: 'active' }, + waiting: { icon: 'hourglass', label: 'Waiting', tone: 'warn' }, + awaiting_approval: { icon: 'question', label: 'Needs approval', tone: 'warn' }, + succeeded: { icon: 'check', label: 'Done', tone: 'ok' }, + failed: { icon: 'cross', label: 'Failed', tone: 'err' }, + paused: { icon: 'pause', label: 'Paused', tone: 'muted' }, +}; + +const PRESET_ICONS: Record = { search: 'search', hammer: 'hammer', flask: 'flask', rocket: 'rocket' }; +export const presetIcon = (icon: string | undefined): IconName => (icon && PRESET_ICONS[icon]) || 'bot'; + +const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** + * The badge's second line: the state label plus a short, factual detail + * taken from Layer 1/2 (active tool, walking destination, waiting reason). + */ +export function badgeDetail(state: PresentationState, agentId: string, run: RunState, world: CompiledWorld): string { + const base = STATE_BADGE[state].label; + const agent = run.exec.agents[agentId]; + const ch = run.world.characters[agentId]; + switch (state) { + case 'working': { + const call = agent?.activeCallId ? run.exec.toolCalls[agent.activeCallId] : undefined; + return call ? `${base} · ${call.tool}` : base; + } + case 'moving': { + const t = ch?.target; + if (t && (t.kind === 'slot' || t.kind === 'overflow')) { + const label = world.stationsById[t.stationId]?.label; + return label ? `${base} → ${clip(label, 18)}` : base; + } + return t?.kind === 'home' ? `${base} → home` : base; + } + case 'waiting': { + // "waiting for a fix" → "Waiting · for a fix" + const reason = agent?.stateReason?.replace(/^waiting\s+/i, ''); + return reason ? `${base} · ${clip(reason, 22)}` : base; + } + default: + return base; + } +} diff --git a/apps/studio/src/scene/context.tsx b/apps/studio/src/scene/context.tsx new file mode 100644 index 0000000..d41057e --- /dev/null +++ b/apps/studio/src/scene/context.tsx @@ -0,0 +1,74 @@ +'use client'; +import type { Incident } from '@runloop-world/core'; +import type { AnyEvent } from '@runloop-world/events'; +import type { RunState } from '@runloop-world/replay'; +import { presentationAt, type CharacterPose, type CompiledWorld } from '@runloop-world/world-engine'; +import { createContext, useContext } from 'react'; +import type { LabelStore } from './labels'; +import { incidentStationId } from './layout'; +import type { SceneFrame } from './types'; + +/** Shared, rarely-changing inputs for scene components (per-frame data comes from getFrame()). */ +export interface SceneCtx { + world: CompiledWorld; + getFrame: () => SceneFrame; + subscribeEffects: (fn: (events: readonly AnyEvent[]) => void) => () => void; + reducedMotion: boolean; + /** Remembers which station each incident happened at (see incidentStationId). */ + incidentStations: Map; + /** DOM labels drawn over the canvas. */ + labels: LabelStore; +} + +export const SceneContext = createContext(null); + +export function useScene(): SceneCtx { + const ctx = useContext(SceneContext); + if (!ctx) throw new Error('useScene() outside '); + return ctx; +} + +/** Layer-2 pose of a character at the frame's tick (null when it has no character). */ +export function poseAt(world: CompiledWorld, frame: SceneFrame, agentId: string): CharacterPose | null { + const c = frame.state.world.characters[agentId]; + if (!c) return null; + return presentationAt(c, frame.tick, world, { paused: frame.state.world.paused }); +} + +export interface StationStatus { + /** Agent standing in a slot of this station (first occupied slot). */ + occupant: string | null; + /** Occupant has a tool call in flight (Layer 1). */ + busy: boolean; + /** Open (unresolved) incidents placed at this station. */ + openIncidents: Incident[]; + resolvedIncidents: Incident[]; +} + +const statusCache = new WeakMap>(); + +/** + * Per-station status derived from one RunState. Cached per state object + * (ReplayIndex returns the same object for the same sequence), so it is + * computed once per playhead step, not once per frame. + */ +export function stationStatuses(ctx: SceneCtx, state: RunState): Map { + const hit = statusCache.get(state); + if (hit) return hit; + const out = new Map(); + for (const s of ctx.world.def.stations) { + const ws = state.world.stations[s.id]; + const occupant = ws?.slots.find((a): a is string => a !== null) ?? null; + const agent = occupant ? state.exec.agents[occupant] : undefined; + out.set(s.id, { occupant, busy: Boolean(agent?.activeCallId), openIncidents: [], resolvedIncidents: [] }); + } + for (const inc of state.exec.incidents) { + if (inc.kind === 'run_failed') continue; + const id = incidentStationId(ctx.world, state, inc, ctx.incidentStations); + const st = id ? out.get(id) : undefined; + if (!st) continue; + (inc.resolvedSeq === null ? st.openIncidents : st.resolvedIncidents).push(inc); + } + statusCache.set(state, out); + return out; +} diff --git a/apps/studio/src/scene/labels.tsx b/apps/studio/src/scene/labels.tsx new file mode 100644 index 0000000..a25f917 --- /dev/null +++ b/apps/studio/src/scene/labels.tsx @@ -0,0 +1,183 @@ +'use client'; +import { useFrame, useThree } from '@react-three/fiber'; +import type { PresentationState } from '@runloop-world/world-engine'; +import { useEffect, useSyncExternalStore } from 'react'; +import { Vector3 } from 'three'; +import { STATE_BADGE, TONE_COLOR, type IconName } from './badges'; +import { useScene } from './context'; +import { Icon } from './Icon'; +import styles from './scene.module.css'; + +/** + * DOM labels over the canvas (badges, signs, incident chips). They live in + * the page's own React tree (accessible buttons, real text, no font fetch); + * the scene only registers what to show and where, and a projector moves + * them every frame through refs — no React updates per frame. + */ +export type LabelContent = + | { kind: 'zone'; text: string; color: string } + | { kind: 'station'; text: string; color: string } + | { + kind: 'agent'; + agentId: string; + name: string; + color: string; + icon: IconName; + state: PresentationState; + detail: string; + selected: boolean; + } + | { kind: 'incident'; open: boolean; text: string; title: string }; + +/** World-space anchor: a fixed point, or a function evaluated every frame. */ +export type Anchor = Vector3 | ((out: Vector3) => Vector3 | null); + +export class LabelStore { + private specs = new Map(); + private snap: readonly [string, LabelContent][] = []; + private listeners = new Set<() => void>(); + readonly anchors = new Map(); + readonly elements = new Map(); + /** Stacking boost: station label (hover/selection) > selected badge > badges > signs and incident chips. */ + readonly boost = new Map(); + + set(id: string, content: LabelContent, anchor: Anchor) { + this.specs.set(id, content); + this.anchors.set(id, anchor); + this.boost.set(id, content.kind === 'station' ? 3000 : content.kind === 'agent' ? (content.selected ? 2000 : 1000) : 0); + this.emit(); + } + + remove(id: string) { + if (!this.specs.delete(id)) return; + this.anchors.delete(id); + this.boost.delete(id); + this.emit(); + } + + subscribe = (fn: () => void) => { + this.listeners.add(fn); + return () => void this.listeners.delete(fn); + }; + + snapshot = () => this.snap; + + private emit() { + this.snap = [...this.specs]; + for (const fn of this.listeners) fn(); + } +} + +/** Show a label while the calling component is mounted (content compared by value). */ +export function useLabel(id: string, content: LabelContent | null, anchor: Anchor) { + const { labels } = useScene(); + const key = content ? JSON.stringify(content) : ''; + useEffect(() => { + if (!content) { + labels.remove(id); + return; + } + labels.set(id, content, anchor); + // `key` captures content by value; anchor objects are stable per caller. + }, [labels, id, key]); + useEffect(() => () => labels.remove(id), [labels, id]); +} + +const v = new Vector3(); + +/** Inside the Canvas: projects every label anchor to screen space each frame. Mount it last. */ +export function LabelProjector() { + const { labels } = useScene(); + const camera = useThree((s) => s.camera); + const size = useThree((s) => s.size); + useFrame(() => { + camera.updateMatrixWorld(); + for (const [id, el] of labels.elements) { + const a = labels.anchors.get(id); + const p = typeof a === 'function' ? a(v) : a ? v.copy(a) : null; + if (!p) { + el.style.visibility = 'hidden'; + continue; + } + p.project(camera); + const x = ((p.x + 1) / 2) * size.width; + const y = ((1 - p.y) / 2) * size.height; + const off = p.z > 1 || x < -200 || y < -100 || x > size.width + 200 || y > size.height + 100; + el.style.visibility = off ? 'hidden' : 'visible'; + el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) translate(-50%, -100%)`; + el.style.zIndex = String(Math.round((1 - p.z) * 500) + (labels.boost.get(id) ?? 0)); + } + }); + return null; +} + +/** Outside the Canvas: renders the labels as DOM. */ +export function LabelLayer({ store, onSelectAgent }: { store: LabelStore; onSelectAgent: (id: string | null) => void }) { + const list = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); + return ( +
+ {list.map(([id, c]) => ( +
{ + if (el) store.elements.set(id, el); + else store.elements.delete(id); + }} + > + +
+ ))} +
+ ); +} + +function LabelBody({ content: c, onSelectAgent }: { content: LabelContent; onSelectAgent: (id: string | null) => void }) { + switch (c.kind) { + case 'zone': + return ( +
+ + {c.text} +
+ ); + case 'station': + return ( +
+ {c.text} +
+ ); + case 'incident': + return ( +
+ + {c.text} +
+ ); + case 'agent': { + const badge = STATE_BADGE[c.state]; + return ( + + ); + } + } +} diff --git a/apps/studio/src/scene/layout.test.ts b/apps/studio/src/scene/layout.test.ts new file mode 100644 index 0000000..3b609ec --- /dev/null +++ b/apps/studio/src/scene/layout.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs'; +import { ReplayIndex } from '@runloop-world/replay'; +import { codingMissionFixture } from '@runloop-world/testkit'; +import { loadWorld, type Cell } from '@runloop-world/world-engine'; +import { describe, expect, it } from 'vitest'; +import { badgeDetail, STATE_BADGE } from './badges'; +import { + FLOOR_Y, + ROOM_Y, + classifyWalls, + floorTiles, + glowPaths, + heightAt, + hubRing, + incidentStationId, + pathPoints, + stationFront, +} from './layout'; + +const world = loadWorld(JSON.parse(readFileSync(new URL('../../../../worlds/research-campus/world.json', import.meta.url), 'utf8'))); +const adjacent = (a: Cell, b: Cell) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1; + +describe('scene layout (derived from the world definition)', () => { + it('classifies every wall cell; north/west room edges are tall back walls, south/east are curbs', () => { + const walls = classifyWalls(world); + const hashes = world.def.grid.join('').split('').filter((c) => c === '#').length; + expect(walls).toHaveLength(hashes); + const at = (x: number, y: number) => walls.find((w) => w.cell[0] === x && w.cell[1] === y)!; + expect(at(4, 1).kind).toBe('back'); // research north wall + expect(at(1, 4).kind).toBe('back'); // research west wall + expect(at(4, 7).kind).toBe('curb'); // research south wall + expect(at(9, 4).kind).toBe('curb'); // research east wall + expect(at(1, 7).kind).toBe('back'); // SW corner continues the west wall + }); + + it('puts a floor tile on every non-wall cell', () => { + const cells = world.width * world.height; + expect(floorTiles(world).length + classifyWalls(world).length).toBe(cells); + }); + + it('builds glow paths along walkable, contiguous cells from the hub', () => { + const ring = hubRing(world); + expect(ring.length).toBeGreaterThan(0); + const paths = glowPaths(world); + const doors = world.def.zones.filter((z) => z.door && z.kind !== 'hub'); + expect(paths.filter((p) => !p.branch && p.key.startsWith('hub>')).length).toBe(doors.length + 1); // + the gate + for (const p of paths) { + p.cells.forEach((c, i) => { + expect(world.isWalkable(c[0], c[1])).toBe(true); + if (i > 0) expect(adjacent(p.cells[i - 1]!, c)).toBe(true); + }); + if (!p.branch) expect(ring.some((r) => r[0] === p.cells[0]![0] && r[1] === p.cells[0]![1])).toBe(true); + } + }); + + it('steps between corridor and room heights at the cell boundary', () => { + expect(heightAt(world, 7, 9)).toBeCloseTo(FLOOR_Y); + expect(heightAt(world, 4, 4)).toBeCloseTo(ROOM_Y); + const mid = heightAt(world, 7, 7.5); // between door (7,7) and corridor (7,8) + expect(mid).toBeGreaterThan(FLOOR_Y); + expect(mid).toBeLessThan(ROOM_Y); + // Path polylines get a vertical riser where the height changes. + const pts = pathPoints(world, [[7, 8], [7, 7]], 0); + expect(pts).toHaveLength(4); + }); + + it('faces stations towards their first slot', () => { + expect(stationFront(world.stationsById['archive']!)).toEqual([0, 1]); + expect(stationFront(world.stationsById['testrig']!)).toEqual([0, -1]); + }); +}); + +describe('scene derivations from Layer 1/2 state', () => { + const events = codingMissionFixture(); + const index = new ReplayIndex(world, events); + const seqOf = (pred: (e: (typeof events)[number]) => boolean) => events.find(pred)!.sequence; + + it('places the failing test incident at the station the tester used', () => { + const failSeq = seqOf((e) => e.type === 'tool.call_completed' && e.payload.status === 'error'); + const later = index.stateAt(failSeq + 4); // tester has walked away (blocked → home) + const inc = later.exec.incidents[0]!; + // Without a remembered station it falls back to the first station the rules map the task kind to. + expect(incidentStationId(world, later, inc, new Map())).toBe('probe'); + const cache = new Map(); + expect(incidentStationId(world, index.stateAt(failSeq), inc, cache)).toBe('probe'); + expect(cache.get(inc.eventId)).toBe('probe'); + }); + + it('badge text comes from the state (icon + label for every state, tool / reason detail)', () => { + for (const b of Object.values(STATE_BADGE)) { + expect(b.label.length).toBeGreaterThan(0); + expect(b.icon.length).toBeGreaterThan(0); + } + const blocked = index.stateAt(seqOf((e) => e.type === 'agent.state_changed' && e.payload.state === 'waiting')); + expect(badgeDetail('waiting', 'tester', blocked, world)).toBe('Waiting · for a fix'); + const s = index.stateAt(seqOf((e) => e.type === 'tool.call_started' && e.payload.tool === 'run_command')); + expect(badgeDetail('working', 'tester', s, world)).toBe('Working · run_command'); + }); +}); diff --git a/apps/studio/src/scene/layout.ts b/apps/studio/src/scene/layout.ts new file mode 100644 index 0000000..d5b8d30 --- /dev/null +++ b/apps/studio/src/scene/layout.ts @@ -0,0 +1,264 @@ +import type { Incident } from '@runloop-world/core'; +import type { RunState } from '@runloop-world/replay'; +import { + bfs, + gridToScene, + matchActivityRule, + type Cell, + type CompiledWorld, + type Station, + type Zone, +} from '@runloop-world/world-engine'; + +/** + * Pure layout helpers: everything the scene draws is derived from the world + * definition here (no hard-coded map). Grid space: x = column, y = row. + */ + +/** Top of corridor floor tiles. */ +export const FLOOR_Y = 0.06; +/** Top of raised room floors. */ +export const ROOM_Y = 0.16; +/** Height of a back (north/west) room wall above the room floor. */ +export const BACK_WALL_H = 1.3; +/** Height of a front (south/east) curb above the room floor. */ +export const CURB_H = 0.26; + +const DIRS: readonly Cell[] = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], +]; +const key = (x: number, y: number) => `${x},${y}`; + +const inRect = (z: Zone, x: number, y: number) => + x >= z.rect[0] && y >= z.rect[1] && x < z.rect[0] + z.rect[2] && y < z.rect[1] + z.rect[3]; + +/** The room (non-hub zone) containing a cell, if any. */ +export function roomAt(world: CompiledWorld, x: number, y: number): Zone | null { + for (const z of world.def.zones) if (z.kind !== 'hub' && inRect(z, x, y)) return z; + return null; +} + +/** The zone (hub included) containing a cell, if any. */ +export function zoneAt(world: CompiledWorld, x: number, y: number): Zone | null { + return world.def.zones.find((z) => inRect(z, x, y)) ?? null; +} + +export const isWall = (world: CompiledWorld, x: number, y: number) => world.def.grid[y]?.[x] === '#'; + +/** Floor height of a cell (rooms are raised platforms). */ +export function cellTop(world: CompiledWorld, x: number, y: number): number { + return roomAt(world, x, y) ? ROOM_Y : FLOOR_Y; +} + +const smooth = (t: number) => { + const s = Math.min(1, Math.max(0, (t - 0.3) / 0.4)); + return s * s * (3 - 2 * s); +}; + +/** Floor height at a continuous grid position (steps up/down at the cell boundary). */ +export function heightAt(world: CompiledWorld, gx: number, gy: number): number { + const x0 = Math.floor(gx); + const y0 = Math.floor(gy); + const fx = gx - x0; + const fy = gy - y0; + const h = (x: number, y: number) => cellTop(world, Math.min(world.width - 1, x), Math.min(world.height - 1, y)); + const top = h(x0, y0) + (h(x0 + 1, y0) - h(x0, y0)) * smooth(fx); + const bottom = h(x0, y0 + 1) + (h(x0 + 1, y0 + 1) - h(x0, y0 + 1)) * smooth(fx); + return top + (bottom - top) * smooth(fy); +} + +/** Scene position [x, y, z] of a (continuous) grid position at height y. */ +export function toScene(world: CompiledWorld, gx: number, gy: number, y = 0): [number, number, number] { + const p = gridToScene(gx, gy, world); + return [p.x, y, p.z]; +} + +export interface WallCell { + cell: Cell; + /** 'back' = tall wall on a room's north/west edge; 'curb' = low wall elsewhere (cutaway). */ + kind: 'back' | 'curb'; + /** Room the wall belongs to (null for free-standing walls). */ + zone: Zone | null; + /** Which edge of the room it sits on (back walls get panels on their inner face). */ + side: 'n' | 'w' | 's' | 'e' | null; +} + +/** Classify every '#' cell for a cutaway look from the south-east. */ +export function classifyWalls(world: CompiledWorld): WallCell[] { + const out: WallCell[] = []; + world.def.grid.forEach((row, y) => { + for (let x = 0; x < row.length; x++) { + if (row[x] !== '#') continue; + const zone = roomAt(world, x, y); + if (!zone) { + out.push({ cell: [x, y], kind: 'curb', zone: null, side: null }); + continue; + } + const [rx, ry, rw, rh] = zone.rect; + const side = y === ry ? 'n' : x === rx ? 'w' : y === ry + rh - 1 ? 's' : x === rx + rw - 1 ? 'e' : null; + out.push({ cell: [x, y], kind: side === 'n' || side === 'w' ? 'back' : 'curb', zone, side }); + } + }); + return out; +} + +export interface FloorTile { + cell: Cell; + zone: Zone | null; + top: number; +} + +/** One tile per non-wall cell (stations, props and the hub core stand on tiles). */ +export function floorTiles(world: CompiledWorld): FloorTile[] { + const out: FloorTile[] = []; + world.def.grid.forEach((row, y) => { + for (let x = 0; x < row.length; x++) { + if (row[x] === '#') continue; + out.push({ cell: [x, y], zone: zoneAt(world, x, y), top: cellTop(world, x, y) }); + } + }); + return out; +} + +export interface GlowPath { + key: string; + color: string; + cells: Cell[]; + /** Branches run from a room door to its stations (drawn fainter). */ + branch: boolean; +} + +/** Walkable cells around the hub core, in a fixed order. */ +export function hubRing(world: CompiledWorld): Cell[] { + const core = world.def.hub.core; + const coreSet = new Set(core.map(([x, y]) => key(x, y))); + const seen = new Set(); + const ring: Cell[] = []; + for (const [cx, cy] of core) { + for (const [dx, dy] of DIRS) { + const x = cx + dx; + const y = cy + dy; + const k = key(x, y); + if (coreSet.has(k) || seen.has(k) || !world.isWalkable(x, y)) continue; + seen.add(k); + ring.push([x, y]); + } + } + if (ring.length === 0) { + const c: Cell = [Math.round(world.def.hub.center[0]), Math.round(world.def.hub.center[1])]; + if (world.isWalkable(c[0], c[1])) ring.push(c); + } + return ring; +} + +/** + * Conveyor-like glow paths: hub → each room door, hub → stations in rooms + * without a door (e.g. the gate), and door → each station in that room. + */ +export function glowPaths(world: CompiledWorld): GlowPath[] { + const ring = hubRing(world); + const out: GlowPath[] = []; + const shortestFromHub = (to: Cell): Cell[] | null => { + let best: Cell[] | null = null; + for (const start of ring) { + const p = bfs(world, start, to); + if (p && (!best || p.length < best.length)) best = p; + } + return best; + }; + + for (const zone of world.def.zones) { + if (zone.kind === 'hub' || !zone.door) continue; + const main = shortestFromHub(zone.door); + if (main) out.push({ key: `hub>${zone.id}`, color: zone.color, cells: [...main], branch: false }); + for (const s of world.def.stations) { + if (s.zoneId !== zone.id) continue; + const p = bfs(world, zone.door, s.slots[0]!); + if (p && p.length > 1) out.push({ key: `${zone.id}>${s.id}`, color: zone.color, cells: [...p], branch: true }); + } + } + for (const s of world.def.stations) { + const zone = world.zonesById[s.zoneId]; + if (zone && zone.kind !== 'hub' && zone.door) continue; + const p = shortestFromHub(s.slots[0]!); + if (p) out.push({ key: `hub>${s.id}`, color: zone?.color ?? '#f59e0b', cells: [...p], branch: false }); + } + return out; +} + +/** + * Scene-space polyline for a path of cells, `lift` above the floor. Height + * changes (corridor ↔ room) become a vertical riser at the cell boundary. + */ +export function pathPoints(world: CompiledWorld, cells: readonly Cell[], lift: number): [number, number, number][] { + const pts: [number, number, number][] = []; + cells.forEach((c, i) => { + const h = cellTop(world, c[0], c[1]) + lift; + if (i > 0) { + const prev = cells[i - 1]!; + const hp = cellTop(world, prev[0], prev[1]) + lift; + if (hp !== h) { + const mx = (prev[0] + c[0]) / 2; + const my = (prev[1] + c[1]) / 2; + pts.push(toScene(world, mx, my, hp), toScene(world, mx, my, h)); + } + } + pts.push(toScene(world, c[0], c[1], h)); + }); + return pts; +} + +const FACING: Record = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] }; + +/** Grid direction a station's front faces (towards its first slot; falls back to `facing`). */ +export function stationFront(s: Station): Cell { + const slot = s.slots[0]!; + const dx = slot[0] - s.cell[0]; + const dy = slot[1] - s.cell[1]; + if (Math.abs(dx) + Math.abs(dy) === 1) return [dx, dy]; + return FACING[s.facing]; +} + +/** Y rotation that turns a model whose front is +z towards a grid direction. */ +export const yawFor = (dx: number, dy: number) => Math.atan2(dx, dy); + +/** Y rotation for a world-engine heading (radians in grid space, 0 = +x, π/2 = +y). */ +export const yawForHeading = (heading: number) => Math.atan2(Math.cos(heading), Math.sin(heading)); + +export function stationColor(world: CompiledWorld, s: Station): string { + return world.zonesById[s.zoneId]?.color ?? '#f59e0b'; +} + +/** + * Station an incident marker belongs to. Prefers the station the agent was + * working at for that task (remembered in `cache`), else the first station + * the activity rules map the task kind to. + */ +export function incidentStationId( + world: CompiledWorld, + state: RunState, + inc: Incident, + cache: Map, +): string | null { + const hit = cache.get(inc.eventId); + if (hit) return hit; + const ch = inc.agentId ? state.world.characters[inc.agentId] : undefined; + if (ch && ch.stationId && (inc.taskId === null || ch.taskId === inc.taskId)) { + cache.set(inc.eventId, ch.stationId); + return ch.stationId; + } + const task = inc.taskId ? state.exec.tasks[inc.taskId] : undefined; + if (!task) return null; + const kind = matchActivityRule(world.def.activityRules, { taskKind: task.kind, permission: false }); + return (kind && world.stationsByKind[kind]?.[0]?.id) || null; +} + +/** Cheap deterministic per-cell noise in [0, 1). */ +export function cellNoise(x: number, y: number): number { + let h = (x * 374761393 + y * 668265263) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} diff --git a/apps/studio/src/scene/palette.ts b/apps/studio/src/scene/palette.ts new file mode 100644 index 0000000..3ab8318 --- /dev/null +++ b/apps/studio/src/scene/palette.ts @@ -0,0 +1,37 @@ +import { Color } from 'three'; + +/** Scene colours: dark slate base, cyan/purple neon, amber accents. */ +export const PALETTE = { + slabTop: '#121a29', + slabSide: '#0a0f19', + tile: '#1b2436', + corridorTile: '#161e2e', + wall: '#1d2739', + wallTop: '#2b3850', + curb: '#253149', + track: '#080c15', + metal: '#323e55', + metalDark: '#1c2536', + metalLight: '#4a5874', + rim: '#f59e0b', + cyan: '#22d3ee', + purple: '#a78bfa', + amber: '#f59e0b', + ok: '#34d399', + err: '#f43f5e', + warn: '#fbbf24', + resolved: '#64748b', + robotShell: '#eef2f8', + robotJoint: '#9aa7bd', + visor: '#070b16', +} as const; + +/** A colour pushed above 1.0 so it glows (and blooms) on toneMapped={false} materials. */ +export function glow(c: string | Color, intensity = 1.8): Color { + return new Color(c).multiplyScalar(intensity); +} + +/** Mix two hex colours (t = 0 → a, t = 1 → b). */ +export function mix(a: string, b: string, t: number): Color { + return new Color(a).lerp(new Color(b), t); +} diff --git a/apps/studio/src/scene/scene.module.css b/apps/studio/src/scene/scene.module.css new file mode 100644 index 0000000..537c11a --- /dev/null +++ b/apps/studio/src/scene/scene.module.css @@ -0,0 +1,144 @@ +.wrap { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + /* Keep label z-indexes inside the scene. */ + isolation: isolate; + background: + radial-gradient(ellipse 70% 60% at 50% 45%, #141c33 0%, #0a0e1a 55%, #05070d 100%); +} + +.labels { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.label { + position: absolute; + left: 0; + top: 0; + visibility: hidden; + will-change: transform; +} + +.fallback { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + color: #cbd5e1; + font: 14px/1.5 system-ui, sans-serif; + text-align: center; +} + +.zoneLabel { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px 3px 7px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--zone) 55%, transparent); + background: rgba(8, 12, 22, 0.78); + color: #e2e8f0; + font: 600 11px/1.2 system-ui, sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + user-select: none; +} + +.zoneDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--zone); + box-shadow: 0 0 8px var(--zone); +} + +.badge { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + padding: 4px 9px 4px 7px; + border-radius: 9px; + border: 1px solid color-mix(in srgb, var(--agent) 70%, transparent); + background: rgba(8, 12, 22, 0.86); + color: #f1f5f9; + font: 12px/1.2 system-ui, sans-serif; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; + user-select: none; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); + transform: translateY(-2px); +} + +.badge:focus-visible { + outline: 2px solid #f8fafc; + outline-offset: 2px; +} + +.badgeSelected { + border-color: #f8fafc; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent) 60%, transparent), 0 2px 12px rgba(0, 0, 0, 0.5); +} + +.badgeName { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 650; +} + +.badgeState { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--tone); +} + +.icon { + width: 12px; + height: 12px; + flex: none; +} + +.stationLabel { + padding: 3px 8px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--zone) 60%, transparent); + background: rgba(8, 12, 22, 0.85); + color: #e2e8f0; + font: 600 11px/1.2 system-ui, sans-serif; + white-space: nowrap; + user-select: none; +} + +.incident { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 220px; + padding: 3px 8px; + border-radius: 6px; + border: 1px solid #f43f5e; + background: rgba(40, 6, 14, 0.88); + color: #fecdd3; + font: 600 11px/1.2 system-ui, sans-serif; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; +} + +.incidentResolved { + border-color: #475569; + background: rgba(15, 23, 42, 0.85); + color: #94a3b8; +} diff --git a/apps/studio/src/scene/textures.ts b/apps/studio/src/scene/textures.ts new file mode 100644 index 0000000..21da877 --- /dev/null +++ b/apps/studio/src/scene/textures.ts @@ -0,0 +1,146 @@ +import { BufferAttribute, BufferGeometry, CanvasTexture, RepeatWrapping, SRGBColorSpace, Vector3 } from 'three'; + +/** Procedural textures and geometry (no external assets). Created lazily, shared, never disposed. */ + +let blob: CanvasTexture | null = null; +/** Soft radial shadow used under characters and machines instead of shadow maps. */ +export function blobShadowTexture(): CanvasTexture { + if (blob) return blob; + const c = document.createElement('canvas'); + c.width = c.height = 64; + const g = c.getContext('2d')!; + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, 'rgba(0,0,0,0.85)'); + grad.addColorStop(0.55, 'rgba(0,0,0,0.45)'); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + g.fillStyle = grad; + g.fillRect(0, 0, 64, 64); + blob = new CanvasTexture(c); + return blob; +} + +let chevron: CanvasTexture | null = null; +/** Chevrons pointing along +u; scroll `offset.x` to make conveyor lines flow. */ +export function chevronTexture(): CanvasTexture { + if (chevron) return chevron; + const c = document.createElement('canvas'); + c.width = 64; + c.height = 32; + const g = c.getContext('2d')!; + g.clearRect(0, 0, 64, 32); + g.strokeStyle = '#ffffff'; + g.lineWidth = 7; + g.lineCap = 'round'; + g.lineJoin = 'round'; + g.beginPath(); + g.moveTo(22, 6); + g.lineTo(40, 16); + g.lineTo(22, 26); + g.stroke(); + chevron = new CanvasTexture(c); + chevron.wrapS = RepeatWrapping; + chevron.colorSpace = SRGBColorSpace; + return chevron; +} + +/** + * A flat ribbon along a polyline (axis-aligned segments plus vertical risers), + * with u = distance along the line × uPerUnit. `extend` overlaps horizontal + * segments by half the width so opaque ribbons have filled corners. + */ +export function ribbonGeometry( + points: readonly (readonly [number, number, number])[], + width: number, + opts: { uPerUnit?: number; extend?: boolean } = {}, +): BufferGeometry { + const uPerUnit = opts.uPerUnit ?? 1; + const pos: number[] = []; + const uv: number[] = []; + const idx: number[] = []; + const half = width / 2; + let perp = new Vector3(1, 0, 0); + let cum = 0; + // Initial perpendicular from the first horizontal segment. + for (let i = 0; i + 1 < points.length; i++) { + const d = new Vector3(points[i + 1]![0] - points[i]![0], 0, points[i + 1]![2] - points[i]![2]); + if (d.lengthSq() > 1e-9) { + d.normalize(); + perp = new Vector3(-d.z, 0, d.x); + break; + } + } + for (let i = 0; i + 1 < points.length; i++) { + const a = new Vector3(...points[i]!); + const b = new Vector3(...points[i + 1]!); + const d = b.clone().sub(a); + const len = d.length(); + if (len < 1e-9) continue; + const horizontal = Math.abs(d.y) < 1e-9; + const dir = d.clone().divideScalar(len); + if (horizontal) perp = new Vector3(-dir.z, 0, dir.x); + const ext = opts.extend && horizontal ? half : 0; + const a2 = a.clone().addScaledVector(dir, -ext); + const b2 = b.clone().addScaledVector(dir, ext); + const base = pos.length / 3; + for (const p of [a2.clone().addScaledVector(perp, half), a2.clone().addScaledVector(perp, -half), b2.clone().addScaledVector(perp, half), b2.clone().addScaledVector(perp, -half)]) { + pos.push(p.x, p.y, p.z); + } + const u0 = cum * uPerUnit; + const u1 = (cum + len) * uPerUnit; + uv.push(u0, 1, u0, 0, u1, 1, u1, 0); + idx.push(base, base + 1, base + 2, base + 1, base + 3, base + 2); + cum += len; + } + const g = new BufferGeometry(); + g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3)); + g.setAttribute('uv', new BufferAttribute(new Float32Array(uv), 2)); + g.setIndex(idx); + g.computeBoundingSphere(); + return g; +} + +/** Total length of a polyline. */ +export function polylineLength(points: readonly (readonly [number, number, number])[]): number { + let len = 0; + for (let i = 0; i + 1 < points.length; i++) { + const a = points[i]!; + const b = points[i + 1]!; + len += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + } + return len; +} + +/** Point at distance `s` along a polyline (clamped), written into `out`. */ +export function pointAlong(points: readonly (readonly [number, number, number])[], s: number, out: Vector3): Vector3 { + if (points.length === 0) return out.set(0, 0, 0); + let left = Math.max(0, s); + for (let i = 0; i + 1 < points.length; i++) { + const a = points[i]!; + const b = points[i + 1]!; + const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + if (left <= len && len > 0) { + const t = left / len; + return out.set(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t); + } + left -= len; + } + const last = points[points.length - 1]!; + return out.set(last[0], last[1], last[2]); +} + +let glowTex: CanvasTexture | null = null; +/** White radial falloff; tint it with a material colour for soft light pools. */ +export function glowTexture(): CanvasTexture { + if (glowTex) return glowTex; + const c = document.createElement('canvas'); + c.width = c.height = 128; + const g = c.getContext('2d')!; + const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64); + grad.addColorStop(0, 'rgba(255,255,255,1)'); + grad.addColorStop(0.4, 'rgba(255,255,255,0.45)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + g.fillStyle = grad; + g.fillRect(0, 0, 128, 128); + glowTex = new CanvasTexture(c); + return glowTex; +} diff --git a/apps/studio/src/state/connect.ts b/apps/studio/src/state/connect.ts new file mode 100644 index 0000000..0c7b838 --- /dev/null +++ b/apps/studio/src/state/connect.ts @@ -0,0 +1,125 @@ +import { SSE_END_EVENT, type StreamEndData } from '@runloop-world/events'; +import { getAllEvents, getEventsPage, getRun, streamUrl } from '../lib/api'; +import type { StudioStore } from './store'; + +const GAP_CHECK_MS = 1000; +const REOPEN_MS = 3000; +/** SSE frames are applied in small batches so a burst re-renders once. */ +const FLUSH_MS = 30; + +/** + * Load a run and keep it up to date: run detail (with the world), the event + * backlog page by page, then an SSE stream from the last sequence received. + * The browser resumes a dropped stream with Last-Event-ID; if it gives up, + * we reopen from the last contiguous sequence. Gaps that stay open for ~2 s + * are backfilled from GET /events. Returns a disconnect function; safe to + * call twice (React StrictMode mounts effects twice). + */ +export function connectRun(store: StudioStore, runId: string): () => void { + let closed = false; + let es: EventSource | null = null; + let reopenTimer: ReturnType | null = null; + let flushTimer: ReturnType | null = null; + let queue: unknown[] = []; + let backfilling = false; + + const state = () => store.getState(); + const current = () => !closed && state().runId === runId; + + const flush = () => { + flushTimer = null; + const batch = queue; + queue = []; + if (current() && batch.length) state().ingest(batch, 'live'); + }; + + const openStream = () => { + if (!current()) return; + const source = new EventSource(streamUrl(runId, state().lastSequence)); + es = source; + source.onopen = () => { + if (current()) state().setConn('streaming'); + }; + source.onmessage = (m: MessageEvent) => { + queue.push(parseJson(m.data)); + flushTimer ??= setTimeout(flush, FLUSH_MS); + }; + source.addEventListener(SSE_END_EVENT, (m) => { + // Without close() the browser would reconnect forever. + source.close(); + if (es === source) es = null; + flush(); + if (!current()) return; + const end = parseJson((m as MessageEvent).data) as Partial | null; + if (end?.status) state().markEnded(end.status, end.lastSequence ?? 0); + state().setConn('complete'); + }); + source.onerror = () => { + if (!current()) return; + if (source.readyState === EventSource.CLOSED) { + // The browser gave up (HTTP error, CORS, runtime gone): retry ourselves. + if (es === source) es = null; + state().setConn('error'); + reopenTimer = setTimeout(() => { + reopenTimer = null; + if (!state().ended) openStream(); + }, REOPEN_MS); + } else { + state().setConn('reconnecting'); + } + }; + }; + + const checkGap = async () => { + if (!current() || backfilling) return; + const gap = state().gapToBackfill(); + if (!gap) return; + backfilling = true; + try { + const page = await getEventsPage(runId, gap.from - 1, gap.to - gap.from + 1); + if (current()) state().ingest(page.events, 'backlog'); + } catch { + // Retried on the next check. + } finally { + backfilling = false; + } + }; + const gapTimer = setInterval(() => void checkGap(), GAP_CHECK_MS); + + void (async () => { + try { + const detail = await getRun(runId); + if (!current()) return; + state().loadRun(detail); + const backlog = await getAllEvents(runId, state().lastSequence); + if (!current()) return; + state().ingest(backlog, 'backlog'); + const s = state(); + if (detail.run.status !== 'running' && s.lastSequence >= detail.run.lastSequence) { + s.markEnded(detail.run.status, detail.run.lastSequence); + s.setConn('complete'); + return; + } + openStream(); + } catch (err) { + if (current()) state().setLoadError(err instanceof Error ? err.message : String(err)); + } + })(); + + return () => { + closed = true; + clearInterval(gapTimer); + if (reopenTimer) clearTimeout(reopenTimer); + if (flushTimer) clearTimeout(flushTimer); + es?.close(); + es = null; + }; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} diff --git a/apps/studio/src/state/context.tsx b/apps/studio/src/state/context.tsx new file mode 100644 index 0000000..a001f50 --- /dev/null +++ b/apps/studio/src/state/context.tsx @@ -0,0 +1,21 @@ +'use client'; +import { createContext, useContext, type ReactNode } from 'react'; +import { useStore } from 'zustand'; +import type { StudioState, StudioStore } from './store'; + +const StoreContext = createContext(null); + +export function StudioProvider({ store, children }: { store: StudioStore; children: ReactNode }) { + return {children}; +} + +export function useStudioStore(): StudioStore { + const store = useContext(StoreContext); + if (!store) throw new Error('useStudioStore must be used inside '); + return store; +} + +/** Subscribe to one slice of the studio state (re-renders only when it changes). */ +export function useStudio(selector: (s: StudioState) => T): T { + return useStore(useStudioStore(), selector); +} diff --git a/apps/studio/src/state/hooks.ts b/apps/studio/src/state/hooks.ts new file mode 100644 index 0000000..0a46950 --- /dev/null +++ b/apps/studio/src/state/hooks.ts @@ -0,0 +1,107 @@ +'use client'; +import { useEffect, useReducer } from 'react'; +import { getHealth, listRuns } from '../lib/api'; +import { shortcutFor } from './keyboard'; +import type { StudioStore } from './store'; + +const HEALTH_POLL_MS = 3000; + +/** Re-render every `ms` while `active` (for clocks that move without store updates). */ +export function useTicker(active: boolean, ms = 250): void { + const [, tick] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + if (!active) return; + const id = setInterval(tick, ms); + return () => clearInterval(id); + }, [active, ms]); +} + +/** Drives replay playback from animation frames. */ +export function usePlaybackDriver(store: StudioStore): void { + useEffect(() => { + let raf = 0; + let last = performance.now(); + const loop = (t: number) => { + const s = store.getState(); + if (s.cursor.mode === 'replay' && s.cursor.playing) s.advance(t - last); + last = t; + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, [store]); +} + +/** Polls GET /api/health and the runs list; opens a run when none is open yet. */ +export function useRuntimePolling(store: StudioStore): void { + useEffect(() => { + let stopped = false; + let timer: ReturnType | undefined; + const poll = async () => { + const s = store.getState(); + try { + const health = await getHealth(AbortSignal.timeout(2500)); + const runs = await listRuns(); + if (stopped) return; + s.setRuntime({ status: 'online', version: health.version, activeRunId: health.activeRunId }); + s.setRuns(runs); + const open = store.getState(); + if (open.runId === null && runs.length > 0) selectRun(store, health.activeRunId ?? runs[0]!.runId); + } catch { + if (!stopped) s.setRuntime({ status: 'offline' }); + } + if (!stopped) timer = setTimeout(() => void poll(), HEALTH_POLL_MS); + }; + void poll(); + return () => { + stopped = true; + clearTimeout(timer); + }; + }, [store]); +} + +/** Open a run and reflect it in the URL (?run=). */ +export function selectRun(store: StudioStore, runId: string): void { + if (store.getState().runId === runId) return; + store.getState().openRun(runId); + const url = new URL(window.location.href); + url.searchParams.set('run', runId); + window.history.replaceState(null, '', url); +} + +export function runIdFromUrl(): string | null { + return new URLSearchParams(window.location.search).get('run'); +} + +/** Tracks prefers-reduced-motion. */ +export function useReducedMotion(store: StudioStore): void { + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + const apply = () => store.getState().setReducedMotion(mq.matches); + apply(); + mq.addEventListener('change', apply); + return () => mq.removeEventListener('change', apply); + }, [store]); +} + +/** Space, ←/→, L, [ ], F. */ +export function useKeyboardShortcuts(store: StudioStore): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const s = store.getState(); + if (s.viewArtifactId) return; + const action = shortcutFor(e, e.target instanceof Element ? e.target : null); + if (!action) return; + e.preventDefault(); + if (action === 'togglePlay') s.togglePlay(); + else if (action === 'stepPrev') s.stepPrev(); + else if (action === 'stepNext') s.stepNext(); + else if (action === 'goLive') s.goLive(); + else if (action === 'prevAgent') s.cycleAgent(-1); + else if (action === 'nextAgent') s.cycleAgent(1); + else s.toggleFollow(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [store]); +} diff --git a/apps/studio/src/state/keyboard.test.ts b/apps/studio/src/state/keyboard.test.ts new file mode 100644 index 0000000..bdd4ddf --- /dev/null +++ b/apps/studio/src/state/keyboard.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { shortcutFor } from './keyboard'; + +const key = (k: string, mods: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; defaultPrevented: boolean }> = {}) => ({ + key: k, + metaKey: false, + ctrlKey: false, + altKey: false, + defaultPrevented: false, + ...mods, +}); + +describe('keyboard shortcuts', () => { + it('maps Space, arrows, L, [ ], F', () => { + expect(shortcutFor(key(' '), null)).toBe('togglePlay'); + expect(shortcutFor(key('ArrowLeft'), null)).toBe('stepPrev'); + expect(shortcutFor(key('ArrowRight'), null)).toBe('stepNext'); + expect(shortcutFor(key('l'), null)).toBe('goLive'); + expect(shortcutFor(key('L'), null)).toBe('goLive'); + expect(shortcutFor(key('['), null)).toBe('prevAgent'); + expect(shortcutFor(key(']'), null)).toBe('nextAgent'); + expect(shortcutFor(key('f'), null)).toBe('toggleFollow'); + expect(shortcutFor(key('x'), null)).toBeNull(); + }); + + it('leaves modified keys to the browser', () => { + expect(shortcutFor(key('l', { ctrlKey: true }), null)).toBeNull(); + expect(shortcutFor(key('f', { metaKey: true }), null)).toBeNull(); + expect(shortcutFor(key(' ', { defaultPrevented: true }), null)).toBeNull(); + }); +}); diff --git a/apps/studio/src/state/keyboard.ts b/apps/studio/src/state/keyboard.ts new file mode 100644 index 0000000..1afe6f0 --- /dev/null +++ b/apps/studio/src/state/keyboard.ts @@ -0,0 +1,34 @@ +export type ShortcutAction = 'togglePlay' | 'stepPrev' | 'stepNext' | 'goLive' | 'prevAgent' | 'nextAgent' | 'toggleFollow'; + +const KEYS: Record = { + ' ': 'togglePlay', + ArrowLeft: 'stepPrev', + ArrowRight: 'stepNext', + l: 'goLive', + L: 'goLive', + '[': 'prevAgent', + ']': 'nextAgent', + f: 'toggleFollow', + F: 'toggleFollow', +}; + +/** Keys that controls handle themselves when focused (Space on a button, arrows in a slider/tab list). */ +const NATIVE: Record = { + ' ': 'button, a, summary, [role="tab"], input, select, textarea', + ArrowLeft: 'input, select, textarea, [role="tablist"]', + ArrowRight: 'input, select, textarea, [role="tablist"]', +}; + +/** Global shortcut for a key press, or null when the focused control should get it. */ +export function shortcutFor( + e: Pick, + target: Element | null, +): ShortcutAction | null { + if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return null; + const action = KEYS[e.key]; + if (!action || !target) return action ?? null; + if (target.closest('input[type="text"], input[type="search"], textarea, select, [contenteditable="true"], [role="dialog"]')) return null; + const native = NATIVE[e.key]; + if (native && target.closest(native)) return null; + return action; +} diff --git a/apps/studio/src/state/playback.test.ts b/apps/studio/src/state/playback.test.ts new file mode 100644 index 0000000..ecc670f --- /dev/null +++ b/apps/studio/src/state/playback.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { advancePlayback, MAX_FRAME_MS, nextEventTime, prevEventTime, type TimeIndex } from './playback'; + +const replay = (tMs: number, speed: 0.5 | 1 | 2 | 4 = 1) => ({ mode: 'replay' as const, tMs, playing: true, speed }); + +function indexOf(times: number[]): TimeIndex { + return { + lastSequence: times.length, + timeOfSeq: (seq) => (seq <= 0 ? 0 : times[Math.min(seq, times.length) - 1]!), + seqAtTime: (ms) => times.filter((t) => t <= ms).length, + }; +} + +describe('playback pacing', () => { + it('scales wall-clock time by speed', () => { + expect(advancePlayback(replay(1000, 1), 100, 10_000).tMs).toBe(1100); + expect(advancePlayback(replay(1000, 0.5), 100, 10_000).tMs).toBe(1050); + expect(advancePlayback(replay(1000, 4), 100, 10_000).tMs).toBe(1400); + }); + + it('caps one frame so a hidden tab does not leap ahead', () => { + expect(advancePlayback(replay(0, 2), 60_000, 100_000).tMs).toBe(MAX_FRAME_MS * 2); + expect(advancePlayback(replay(500), -20, 1000).tMs).toBe(500); + }); + + it('stops at the end', () => { + const c = advancePlayback(replay(9950, 4), 100, 10_000); + expect(c).toEqual({ mode: 'replay', tMs: 10_000, playing: false, speed: 4 }); + const paused = { ...replay(10), playing: false }; + expect(advancePlayback(paused, 100, 1000)).toBe(paused); + }); + + it('finds neighbouring event times', () => { + const idx = indexOf([0, 0, 300, 300, 1200]); + expect(nextEventTime(idx, 0)).toBe(300); + expect(nextEventTime(idx, 150)).toBe(300); + expect(nextEventTime(idx, 300)).toBe(1200); + expect(nextEventTime(idx, 1200)).toBeNull(); + expect(prevEventTime(idx, 1200)).toBe(300); + expect(prevEventTime(idx, 301)).toBe(300); + expect(prevEventTime(idx, 300)).toBe(0); + expect(prevEventTime(idx, 0)).toBe(0); + }); +}); diff --git a/apps/studio/src/state/playback.ts b/apps/studio/src/state/playback.ts new file mode 100644 index 0000000..8f8aa62 --- /dev/null +++ b/apps/studio/src/state/playback.ts @@ -0,0 +1,40 @@ +import type { Cursor } from './store'; + +type ReplayCursor = Extract; + +/** The parts of ReplayIndex that time navigation needs. */ +export interface TimeIndex { + readonly lastSequence: number; + seqAtTime(ms: number): number; + timeOfSeq(seq: number): number; +} + +/** + * Longest wall-clock step one frame may take: playback keeps real time down to + * ~1 fps, but a tab returning from the background does not leap ahead. + */ +export const MAX_FRAME_MS = 1000; + +/** + * Move a playing replay cursor forward by `dtMs` of wall-clock time, scaled by + * its speed. Stops (playing = false) at `endMs`. Paused cursors are returned as is. + */ +export function advancePlayback(cursor: ReplayCursor, dtMs: number, endMs: number): ReplayCursor { + if (!cursor.playing) return cursor; + const dt = Math.max(0, Math.min(MAX_FRAME_MS, dtMs)); + const tMs = Math.min(endMs, cursor.tMs + dt * cursor.speed); + return { ...cursor, tMs, playing: tMs < endMs }; +} + +/** Time of the first event strictly after `tMs`, or null at the end. */ +export function nextEventTime(index: TimeIndex, tMs: number): number | null { + const seq = index.seqAtTime(tMs); + return seq < index.lastSequence ? index.timeOfSeq(seq + 1) : null; +} + +/** Latest event time strictly before `tMs` (0 when there is none). */ +export function prevEventTime(index: TimeIndex, tMs: number): number { + let seq = index.seqAtTime(tMs); + while (seq > 0 && index.timeOfSeq(seq) >= tMs) seq -= 1; + return seq > 0 ? index.timeOfSeq(seq) : 0; +} diff --git a/apps/studio/src/state/store.test.ts b/apps/studio/src/state/store.test.ts new file mode 100644 index 0000000..a7f9fb4 --- /dev/null +++ b/apps/studio/src/state/store.test.ts @@ -0,0 +1,216 @@ +import { foldExecution } from '@runloop-world/core'; +import { deliver, uuidFor } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { fakeNow, openFixture, wire } from './test-utils'; + +const ms = (iso: string) => Date.parse(iso); + +describe('studio store: ingest', () => { + it('orders and dedupes shuffled, duplicated deliveries', () => { + const { store, events } = openFixture(); + for (const e of deliver(events, 7, { dupRate: 0.3, shuffleWindow: 6 })) store.getState().ingest([wire(e)], 'live'); + const s = store.getState(); + expect(s.lastSequence).toBe(events.length); + expect(s.events().map((e) => e.eventId)).toEqual(events.map((e) => e.eventId)); + expect(s.warnings).toEqual([]); + expect(s.gap).toBeNull(); + expect(s.ended).toBe(true); + }); + + it('holds events after a gap and asks for a backfill after ~2 s', () => { + const clock = fakeNow(ms('2026-10-17T09:00:00.000Z')); + const { store, events } = openFixture({ now: clock.now }); + const missing = events[9]!; // sequence 10 + store.getState().ingest(events.filter((e) => e !== missing).map(wire), 'live'); + expect(store.getState().lastSequence).toBe(9); + expect(store.getState().gap).toEqual({ from: 10, to: 10 }); + expect(store.getState().gapToBackfill()).toBeNull(); + clock.advance(2000); + expect(store.getState().gapToBackfill()).toEqual({ from: 10, to: 10 }); + expect(store.getState().gapToBackfill()).toBeNull(); // re-armed + + store.getState().ingest([wire(missing)], 'backlog'); + expect(store.getState().lastSequence).toBe(events.length); + expect(store.getState().gap).toBeNull(); + }); + + it('keeps the first event when two different events claim a sequence', () => { + const { store, events } = openFixture(); + const impostor = { ...events[2]!, eventId: uuidFor(0x77, 1) }; + store.getState().ingest([wire(events[0]), wire(events[1]), wire(events[2]), wire(impostor)], 'live'); + const s = store.getState(); + expect(s.lastSequence).toBe(3); + expect(s.events()[2]!.eventId).toBe(events[2]!.eventId); + expect(s.warnings).toHaveLength(1); + expect(s.warnings[0]).toContain('#3'); + }); + + it('keeps invalid envelopes in raw[] and folds unknown event types', () => { + const { store, events } = openFixture(); + const unknownType = { ...events[1]!, type: 'robot.danced', payload: { style: 'disco' } }; + store.getState().ingest([wire(events[0]), { hello: 'world' }, wire(unknownType), { hello: 'world' }], 'live'); + const s = store.getState(); + expect(s.raw).toHaveLength(1); + expect(s.raw[0]!.raw).toEqual({ hello: 'world' }); + expect(s.lastSequence).toBe(2); + expect(s.events()[1]!.type).toBe('robot.danced'); + expect(s.stateAt(2).exec.anomalies.map((a) => a.code)).toContain('unknown_event'); + }); + + it('keeps the raw JSON of each event as received', () => { + const { store, events } = openFixture(); + const withExtra = { ...(wire(events[0]) as object), extraField: 'kept' }; + store.getState().ingest([withExtra], 'live'); + expect(store.getState().rawEvent(1)).toEqual(withExtra); + }); +}); + +describe('studio store: cursor', () => { + it('new live events never move a replay cursor', () => { + const { store, events } = openFixture(); + store.getState().ingest(events.slice(0, 30).map(wire), 'live'); + const t = store.getState().timeOfSeq(20); + store.getState().seek(t); + const before = store.getState(); + expect(before.cursor).toMatchObject({ mode: 'replay', tMs: t, playing: false }); + const seq = before.playheadSeq; + + store.getState().ingest(events.slice(30).map(wire), 'live'); + const after = store.getState(); + expect(after.cursor).toEqual(before.cursor); + expect(after.playheadSeq).toBe(seq); + expect(after.newSinceReplay).toBe(events.length - 30); + + after.goLive(); + expect(store.getState().playheadSeq).toBe(events.length); + expect(store.getState().newSinceReplay).toBe(0); + }); + + it('state at the playhead equals a direct fold, forward and backward', () => { + const { store, events } = openFixture(); + store.getState().ingest(events.map(wire), 'backlog'); + for (const n of [40, 5, 58, 1, 23]) { + store.getState().seekToSeq(n); + const s = store.getState(); + expect(s.playheadSeq).toBeGreaterThanOrEqual(n); + expect(s.timeOfSeq(s.playheadSeq)).toBe(s.timeOfSeq(n)); // same instant + expect(s.stateAt(s.playheadSeq).exec).toEqual(foldExecution(events.slice(0, s.playheadSeq))); + } + }); + + it('prev/next jump between event times and clamp at the ends', () => { + const { store, events } = openFixture(); + store.getState().ingest(events.map(wire), 'backlog'); + store.getState().seek(0); + expect(store.getState().playheadSeq).toBe(1); + store.getState().stepPrev(); + expect(store.getState().cursor).toMatchObject({ tMs: 0 }); + store.getState().stepNext(); + expect(store.getState().playheadSeq).toBe(2); + expect(store.getState().cursor).toMatchObject({ tMs: store.getState().timeOfSeq(2) }); + store.getState().seekToSeq(events.length); + store.getState().stepNext(); // nothing after the last event + expect(store.getState().playheadSeq).toBe(events.length); + }); + + it('getFrame is memoized per sequence', () => { + const { store, events } = openFixture(); + store.getState().ingest(events.map(wire), 'backlog'); + store.getState().seekToSeq(12); + const a = store.getState().getFrame(); + const b = store.getState().getFrame(); + expect(a.state).toBe(b.state); + expect(a.playheadMs).toBe(store.getState().timeOfSeq(12)); + expect(a.advancing).toBe(false); + }); +}); + +describe('studio store: live playhead', () => { + it('follows estimated server time, corrected for client clock skew', () => { + const { events } = openFixture(); + const skew = 5000; // client clock 5 s ahead of the server + const clock = fakeNow(ms(events[0]!.timestamp) + skew); + const { store } = openFixture({ now: clock.now }); + store.getState().ingest([wire(events[0])], 'live'); + expect(store.getState().playheadMs()).toBe(0); + clock.advance(1500); + expect(store.getState().playheadMs()).toBe(1500); + // A later event arriving "early" (lower latency) can only move the estimate forward. + clock.set(ms(events[1]!.timestamp) + skew); + store.getState().ingest([wire(events[1])], 'live'); + expect(store.getState().playheadMs()).toBeGreaterThanOrEqual(store.getState().durationMs); + }); + + it('never trails the last event and stops once a finished run has settled', () => { + const { events } = openFixture(); + const clock = fakeNow(0); // client clock far behind the server + const { store } = openFixture({ now: clock.now }); + store.getState().ingest(events.map(wire), 'backlog'); + const s = store.getState(); + expect(s.ended).toBe(true); + expect(s.settleMs).toBeGreaterThanOrEqual(s.durationMs); + expect(s.playheadMs()).toBe(s.durationMs); + clock.set(Number.MAX_SAFE_INTEGER / 2); + expect(store.getState().playheadMs()).toBe(s.settleMs); + expect(store.getState().getFrame().advancing).toBe(false); + }); +}); + +describe('studio store: playback', () => { + it('plays a finished run to its end and stops; play again restarts from 0', () => { + const { store, events } = openFixture(); + store.getState().ingest(events.map(wire), 'backlog'); + store.getState().seek(0); + store.getState().setSpeed(4); + store.getState().play(); + for (let i = 0; i < 100 && store.getState().cursor.mode === 'replay'; i++) { + store.getState().advance(1000); + if (!(store.getState().cursor as { playing: boolean }).playing) break; + } + const s = store.getState(); + expect(s.cursor).toMatchObject({ mode: 'replay', playing: false, speed: 4, tMs: s.settleMs }); + expect(s.playheadSeq).toBe(events.length); + s.play(); + expect(store.getState().cursor).toMatchObject({ mode: 'replay', playing: true, tMs: 0 }); + }); + + it('hands over to live when playback catches up with a running run', () => { + const { events } = openFixture(); + const clock = fakeNow(ms(events[29]!.timestamp)); + const { store } = openFixture({ now: clock.now }); + store.getState().ingest(events.slice(0, 30).map(wire), 'live'); + store.getState().seekToSeq(25); + store.getState().play(); + for (let i = 0; i < 20 && store.getState().cursor.mode === 'replay'; i++) store.getState().advance(1000); + expect(store.getState().cursor).toEqual({ mode: 'live' }); + }); +}); + +describe('studio store: effects', () => { + it('fire for live arrivals and playback, never for scrubs or replay arrivals', () => { + const { store, events } = openFixture(); + const fired: number[][] = []; + store.getState().subscribeEffects((evs) => fired.push(evs.map((e) => e.sequence))); + + store.getState().ingest(events.slice(0, 20).map(wire), 'backlog'); + expect(fired).toEqual([]); // backlog + store.getState().ingest([wire(events[20])], 'live'); + expect(fired).toEqual([[21]]); + + store.getState().seekToSeq(5); + expect(fired).toHaveLength(1); // scrub + store.getState().ingest([wire(events[21])], 'live'); + expect(fired).toHaveLength(1); // arrival while in replay + + store.getState().play(); + const start = store.getState().playheadSeq; + for (let i = 0; i < 40; i++) store.getState().advance(100); + const seen = fired.slice(1).flat(); + expect(seen.length).toBeGreaterThan(0); + expect(seen).toEqual(seen.map((_, i) => start + 1 + i)); // contiguous, in order, no repeats + + store.getState().seekToSeq(1); + store.getState().seekToSeq(22); // big jump forward: no bulk effects + expect(fired.slice(1).flat()).toEqual(seen); + }); +}); diff --git a/apps/studio/src/state/store.ts b/apps/studio/src/state/store.ts new file mode 100644 index 0000000..8fde231 --- /dev/null +++ b/apps/studio/src/state/store.ts @@ -0,0 +1,554 @@ +import { + parseEvent, + SequenceBuffer, + type AnyEvent, + type RunDetailResponse, + type RunStatus, + type RunSummary, +} from '@runloop-world/events'; +import { initialRunState, ReplayIndex, type RunState } from '@runloop-world/replay'; +import { arrivalTick, loadWorld, type CompiledWorld } from '@runloop-world/world-engine'; +import { createStore, type StoreApi } from 'zustand/vanilla'; +import type { CameraMode, Quality, SceneFrame } from '../scene/types'; +import { advancePlayback, nextEventTime, prevEventTime } from './playback'; + +export type Speed = 0.5 | 1 | 2 | 4; +export const SPEEDS: readonly Speed[] = [0.5, 1, 2, 4]; + +/** + * Where the playhead is. Live follows the run in (estimated) server time; + * replay is a fixed time since run start that only the user (or playback) moves. + */ +export type Cursor = { mode: 'live' } | { mode: 'replay'; tMs: number; playing: boolean; speed: Speed }; + +export type Tab = 'timeline' | 'logs' | 'state' | 'metadata'; +export type ConnStatus = 'idle' | 'loading' | 'streaming' | 'reconnecting' | 'complete' | 'error'; +export type RuntimeStatus = 'unknown' | 'online' | 'offline'; + +/** Something received that is not a valid event envelope (kept so the Logs tab can show it). */ +export interface RawEntry { + id: number; + receivedAt: number; + raw: unknown; + error: string; + sequence: number | null; +} + +export interface StudioState { + // ── runtime + runs list ─────────────────────────────────────────── + runtime: { status: RuntimeStatus; version: string | null; activeRunId: string | null }; + runs: RunSummary[]; + + // ── the open run ───────────────────────────────────────────────── + runId: string | null; + run: RunSummary | null; + world: CompiledWorld | null; + loadError: string | null; + conn: ConnStatus; + /** Data-integrity problems worth showing (sequence conflicts, fold errors). */ + warnings: string[]; + /** Highest contiguous sequence received (the replay index holds 1..lastSequence). */ + lastSequence: number; + /** Time of the last event since run start. */ + durationMs: number; + /** End of the scrubbable range once characters finished walking (≥ durationMs). */ + settleMs: number; + /** The run reached a terminal status. */ + ended: boolean; + raw: RawEntry[]; + /** Missing sequences blocking release, if any. */ + gap: { from: number; to: number } | null; + + // ── playhead ───────────────────────────────────────────────────── + cursor: Cursor; + /** Sequence at the playhead (live → lastSequence; replay → seqAtTime(tMs)). */ + playheadSeq: number; + /** Events that arrived while the cursor was in replay. */ + newSinceReplay: number; + /** Replay speed preference (also used when a replay starts from live). */ + speed: Speed; + + // ── selection + view options ───────────────────────────────────── + selectedAgentId: string | null; + selectedStationId: string | null; + tab: Tab; + cameraMode: CameraMode; + follow: boolean; + quality: Quality; + reducedMotion: boolean; + /** Artifact open in the viewer. */ + viewArtifactId: string | null; + + // ── actions ────────────────────────────────────────────────────── + setRuntime(r: Partial): void; + setRuns(runs: RunSummary[]): void; + /** Forget the current run and start loading `runId` (null = nothing open). */ + openRun(runId: string | null): void; + loadRun(detail: RunDetailResponse): void; + setLoadError(message: string): void; + setConn(conn: ConnStatus): void; + /** Parse and add raw events. `live` = pushed by the stream (may fire effects). */ + ingest(raws: readonly unknown[], source: 'backlog' | 'live'): void; + /** The server said the run is terminal (SSE `end`). */ + markEnded(status: RunStatus, lastSequence: number): void; + /** Gap that has stayed open long enough to backfill (re-armed after each call). */ + gapToBackfill(): { from: number; to: number } | null; + + goLive(): void; + seek(tMs: number): void; + seekToSeq(seq: number): void; + play(): void; + pause(): void; + togglePlay(): void; + stepNext(): void; + stepPrev(): void; + setSpeed(speed: Speed): void; + /** Advance replay playback by wall-clock `dtMs` (called every animation frame). */ + advance(dtMs: number): void; + + selectAgent(agentId: string | null): void; + selectStation(stationId: string | null): void; + cycleAgent(dir: 1 | -1): void; + setTab(tab: Tab): void; + setCameraMode(mode: CameraMode): void; + toggleFollow(): void; + setQuality(q: Quality): void; + setReducedMotion(on: boolean): void; + openArtifact(artifactId: string | null): void; + + // ── reads (non-reactive; pair them with lastSequence / playheadSeq) ─ + events(): readonly AnyEvent[]; + /** Event JSON exactly as received, by sequence. */ + rawEvent(seq: number): unknown; + stateAt(seq: number): RunState; + timeOfSeq(seq: number): number; + playheadMs(): number; + /** Right end of the scrubber right now. */ + endMs(): number; + getFrame(): SceneFrame; + subscribeEffects(fn: (events: readonly AnyEvent[]) => void): () => void; +} + +export type StudioStore = StoreApi; + +export interface StudioStoreOptions { + /** Wall clock (ms since epoch); injectable for tests. */ + now?: () => number; + /** How long a gap may stay open before it is backfilled. */ + gapBackfillMs?: number; +} + +const TERMINAL: ReadonlySet = new Set(['succeeded', 'failed', 'stopped', 'interrupted']); +/** Larger forward steps than this are treated as jumps (no effects). */ +const MAX_EFFECT_BATCH = 12; +/** Cap on how long characters may keep walking after the last event. */ +const MAX_SETTLE_MS = 15_000; + +export function createStudioStore(opts: StudioStoreOptions = {}): StudioStore { + const now = opts.now ?? Date.now; + const gapBackfillMs = opts.gapBackfillMs ?? 2000; + + // Non-reactive internals, reset per run. React re-renders on lastSequence/playheadSeq instead. + let buffer = new SequenceBuffer(); + let index: ReplayIndex | null = null; + let held: AnyEvent[] = []; // released before the world was known + /** Event JSON as received, by eventId (first delivery wins, like the buffer). */ + let rawById = new Map(); + let rawSeen = new Set(); + let rawId = 0; + let conflictsSeen = 0; + /** min(clientNow − eventTimestamp): client clock minus server clock, plus the smallest latency seen. */ + let clockOffsetMs: number | null = null; + let gapSince: number | null = null; + let frameMemo: { seq: number; state: RunState } | null = null; + const effectListeners = new Set<(events: readonly AnyEvent[]) => void>(); + + const emitEffects = (events: readonly AnyEvent[]) => { + if (events.length === 0 || events.length > MAX_EFFECT_BATCH) return; + for (const fn of effectListeners) { + try { + fn(events); + } catch (err) { + console.error('[studio] effect listener failed', err); + } + } + }; + + const resetInternals = () => { + buffer = new SequenceBuffer(); + index = null; + held = []; + rawById = new Map(); + rawSeen = new Set(); + conflictsSeen = 0; + clockOffsetMs = null; + gapSince = null; + frameMemo = null; + }; + + /** + * When the characters finish their last walk (ms since run start, ≥ durationMs). + * A finished run's playhead stops here instead of at the last event, so + * characters are not frozen mid-step at the end of a replay. + */ + const computeSettleMs = (world: CompiledWorld): number => { + if (!index) return 0; + const w = index.latest().world; + let maxTick = 0; + for (const id of w.characterOrder) { + const c = w.characters[id]; + if (c) maxTick = Math.max(maxTick, arrivalTick(c.motion, world.def.ticksPerCell)); + } + const duration = index.durationMs; + return Math.min(duration + MAX_SETTLE_MS, Math.max(duration, (maxTick / world.def.tickHz) * 1000)); + }; + + /** + * Live playhead: estimated server time since run start (client clock corrected + * by the offset seen on arrivals), never before the last event, and stopping + * once a finished run's characters have settled. + */ + const livePlayheadMs = (s: StudioState): number => { + if (!index || index.runStartMs === null) return 0; + const serverNow = now() - (clockOffsetMs ?? 0); + const t = Math.max(index.durationMs, serverNow - index.runStartMs); + return s.ended ? Math.min(t, s.settleMs) : t; + }; + + const endMsOf = (s: StudioState): number => (s.ended ? s.settleMs : Math.max(s.settleMs, livePlayheadMs(s))); + + const seqAt = (cursor: Cursor): number => { + if (!index) return 0; + return cursor.mode === 'live' ? index.lastSequence : index.seqAtTime(cursor.tMs); + }; + + /** Character's station at the current playhead, for selection. */ + const stationOf = (agentId: string | null): string | null => { + if (!agentId || !index) return null; + const s = store.getState(); + return index.stateAt(s.playheadSeq).world.characters[agentId]?.stationId ?? null; + }; + + const toReplay = (s: StudioState, tMs: number, playing?: boolean): Cursor => { + const prev = s.cursor.mode === 'replay' ? s.cursor : null; + const clamped = Math.max(0, Math.min(endMsOf(s), tMs)); + return { mode: 'replay', tMs: clamped, playing: playing ?? prev?.playing ?? false, speed: s.speed }; + }; + + const setCursor = (cursor: Cursor) => { + const s = store.getState(); + const leavingLive = s.cursor.mode === 'live' && cursor.mode === 'replay'; + store.setState({ + cursor, + playheadSeq: seqAt(cursor), + ...(cursor.mode === 'live' || leavingLive ? { newSinceReplay: 0 } : {}), + }); + }; + + const appendReleased = (released: AnyEvent[]) => { + if (released.length === 0) return; + if (!index) { + held.push(...released); + return; + } + try { + index.append(released); + } catch (err) { + store.setState((s) => ({ warnings: [...s.warnings, `Could not fold events: ${String(err)}`] })); + } + }; + + const store = createStore()((set, get) => ({ + runtime: { status: 'unknown', version: null, activeRunId: null }, + runs: [], + + runId: null, + run: null, + world: null, + loadError: null, + conn: 'idle', + warnings: [], + lastSequence: 0, + durationMs: 0, + settleMs: 0, + ended: false, + raw: [], + gap: null, + + cursor: { mode: 'live' }, + playheadSeq: 0, + newSinceReplay: 0, + speed: 1, + + selectedAgentId: null, + selectedStationId: null, + tab: 'timeline', + cameraMode: 'iso', + follow: false, + quality: 'high', + reducedMotion: false, + viewArtifactId: null, + + setRuntime: (r) => set((s) => ({ runtime: { ...s.runtime, ...r } })), + + setRuns: (runs) => { + const s = get(); + const mine = s.runId ? runs.find((r) => r.runId === s.runId) : undefined; + const ended = s.ended || (mine !== undefined && TERMINAL.has(mine.status) && s.lastSequence >= mine.lastSequence); + set({ runs, ...(mine ? { run: mine, ended } : {}) }); + }, + + openRun: (runId) => { + resetInternals(); + set({ + runId, + run: runId ? (get().runs.find((r) => r.runId === runId) ?? null) : null, + world: null, + loadError: null, + conn: runId ? 'loading' : 'idle', + warnings: [], + lastSequence: 0, + durationMs: 0, + settleMs: 0, + ended: false, + raw: [], + gap: null, + cursor: { mode: 'live' }, + playheadSeq: 0, + newSinceReplay: 0, + selectedAgentId: null, + selectedStationId: null, + viewArtifactId: null, + }); + }, + + loadRun: (detail) => { + if (detail.run.runId !== get().runId) return; + if (index) { + // Reconnect to the run already open: keep the events we have. + set({ run: detail.run, loadError: null }); + return; + } + let world: CompiledWorld; + try { + world = loadWorld(detail.world); + } catch (err) { + set({ run: detail.run, loadError: `Invalid world definition: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + index = new ReplayIndex(world); + set({ run: detail.run, world, loadError: null }); + const pending = held; + held = []; + appendReleased(pending); + syncDerived(); + }, + + setLoadError: (message) => set({ loadError: message, conn: 'error' }), + setConn: (conn) => set({ conn }), + + ingest: (raws, source) => { + const s = get(); + const t = now(); + const released: AnyEvent[] = []; + const newRaw: RawEntry[] = []; + for (const raw of raws) { + const parsed = parseEvent(raw); + if (!parsed.ok) { + const key = safeJson(raw); + if (rawSeen.has(key)) continue; + rawSeen.add(key); + const seq = (raw as { sequence?: unknown } | null)?.sequence; + newRaw.push({ id: ++rawId, receivedAt: t, raw, error: parsed.error, sequence: typeof seq === 'number' ? seq : null }); + continue; + } + const e = parsed.event; + if (s.runId && e.runId !== s.runId) continue; // stale delivery from a previous run + const ts = Date.parse(e.timestamp); + if (Number.isFinite(ts)) clockOffsetMs = Math.min(clockOffsetMs ?? Infinity, t - ts); + if (!rawById.has(e.eventId)) rawById.set(e.eventId, raw); + released.push(...buffer.push(e)); + } + appendReleased(released); + + const conflicts = buffer.conflicts.slice(conflictsSeen).map( + (c) => `Sequence #${c.sequence}: kept event ${c.keptEventId}, dropped conflicting ${c.droppedEventId}`, + ); + conflictsSeen = buffer.conflicts.length; + const missing = buffer.missing(); + if (missing === null) gapSince = null; + else gapSince ??= t; + // Keep references stable when nothing changed (fewer re-renders). + const prevGap = get().gap; + const gap = missing && prevGap && missing.from === prevGap.from && missing.to === prevGap.to ? prevGap : missing; + + const wasReplay = get().cursor.mode === 'replay'; + set({ + gap, + ...(conflicts.length ? { warnings: [...get().warnings, ...conflicts] } : {}), + ...(newRaw.length ? { raw: [...get().raw, ...newRaw] } : {}), + ...(wasReplay && source === 'live' ? { newSinceReplay: get().newSinceReplay + released.length } : {}), + }); + if (released.length) syncDerived(); + if (source === 'live' && !wasReplay && index) emitEffects(released); + }, + + markEnded: (status, lastSequence) => { + const s = get(); + set({ + ended: true, + ...(s.run ? { run: { ...s.run, status, lastSequence: Math.max(s.run.lastSequence, lastSequence) } } : {}), + }); + syncDerived(); + }, + + gapToBackfill: () => { + const gap = get().gap; + if (!gap || gapSince === null || now() - gapSince < gapBackfillMs) return null; + gapSince = now(); // re-arm: try again after another interval if it is still open + return gap; + }, + + goLive: () => setCursor({ mode: 'live' }), + + seek: (tMs) => setCursor(toReplay(get(), tMs)), + + seekToSeq: (seq) => { + if (!index) return; + setCursor(toReplay(get(), index.timeOfSeq(seq), false)); + }, + + play: () => { + const s = get(); + const end = endMsOf(s); + const t = s.cursor.mode === 'replay' ? s.cursor.tMs : livePlayheadMs(s); + // At the end of a finished run, play restarts from the beginning. + setCursor(toReplay(s, t >= end - 1 && s.ended ? 0 : t, true)); + }, + + pause: () => { + const s = get(); + setCursor(toReplay(s, s.cursor.mode === 'replay' ? s.cursor.tMs : livePlayheadMs(s), false)); + }, + + togglePlay: () => { + const s = get(); + // Live counts as playing, except at the very end of a finished run. + const playing = s.cursor.mode === 'live' ? !(s.ended && livePlayheadMs(s) >= s.settleMs) : s.cursor.playing; + if (playing) s.pause(); + else s.play(); + }, + + stepNext: () => { + if (!index) return; + const s = get(); + const from = s.cursor.mode === 'replay' ? s.cursor.tMs : livePlayheadMs(s); + const t = nextEventTime(index, from); + if (t === null) return; + const before = s.playheadSeq; + setCursor(toReplay(s, t, false)); + const after = get().playheadSeq; + if (after > before) emitEffects(index.events.slice(before, after)); + }, + + stepPrev: () => { + if (!index) return; + const s = get(); + const from = s.cursor.mode === 'replay' ? s.cursor.tMs : livePlayheadMs(s); + setCursor(toReplay(s, prevEventTime(index, from), false)); + }, + + setSpeed: (speed) => { + const s = get(); + set({ speed, ...(s.cursor.mode === 'replay' ? { cursor: { ...s.cursor, speed } } : {}) }); + }, + + advance: (dtMs) => { + const s = get(); + if (s.cursor.mode !== 'replay' || !s.cursor.playing || !index) return; + const end = endMsOf(s); + const next = advancePlayback(s.cursor, dtMs, end); + const before = s.playheadSeq; + // Caught up with a run that is still going: hand over to live. + setCursor(!next.playing && !s.ended ? { mode: 'live' } : next); + const after = get().playheadSeq; + if (after > before) emitEffects(index.events.slice(before, after)); + }, + + selectAgent: (agentId) => set({ selectedAgentId: agentId, selectedStationId: stationOf(agentId) }), + selectStation: (stationId) => set({ selectedStationId: stationId }), + + cycleAgent: (dir) => { + if (!index) return; + const exec = index.latest().exec; + const ids = exec.agentOrder.filter((id) => exec.agents[id]?.embodied); + if (ids.length === 0) return; + const cur = get().selectedAgentId; + const i = cur ? ids.indexOf(cur) : -1; + const next = ids[(i + dir + ids.length) % ids.length] ?? ids[0]!; + get().selectAgent(i === -1 && dir === -1 ? ids[ids.length - 1]! : next); + }, + + setTab: (tab) => set({ tab }), + setCameraMode: (cameraMode) => set({ cameraMode }), + toggleFollow: () => set((s) => ({ follow: !s.follow })), + setQuality: (quality) => set({ quality }), + setReducedMotion: (reducedMotion) => set({ reducedMotion }), + openArtifact: (viewArtifactId) => set({ viewArtifactId }), + + events: () => index?.events ?? [], + rawEvent: (seq) => { + const e = index?.events[seq - 1]; + return e ? rawById.get(e.eventId) : undefined; + }, + stateAt: (seq) => index?.stateAt(seq) ?? initialRunState(), + timeOfSeq: (seq) => index?.timeOfSeq(seq) ?? 0, + playheadMs: () => { + const s = get(); + return s.cursor.mode === 'live' ? livePlayheadMs(s) : s.cursor.tMs; + }, + endMs: () => endMsOf(get()), + + getFrame: () => { + const s = get(); + const seq = s.playheadSeq; + if (!frameMemo || frameMemo.seq !== seq || !index) { + frameMemo = { seq, state: index ? index.stateAt(seq) : initialRunState() }; + } + const playheadMs = s.cursor.mode === 'live' ? livePlayheadMs(s) : s.cursor.tMs; + const advancing = s.cursor.mode === 'live' ? !s.ended || playheadMs < s.settleMs : s.cursor.playing; + return { state: frameMemo.state, tick: index ? index.tickAtMs(playheadMs) : 0, playheadMs, advancing }; + }, + + subscribeEffects: (fn) => { + effectListeners.add(fn); + return () => { + effectListeners.delete(fn); + }; + }, + })); + + /** Recompute the derived fields after events or the end status changed. */ + function syncDerived() { + const s = store.getState(); + if (!index || !s.world) return; + const terminal = TERMINAL.has(index.latest().exec.run.status); + store.setState({ + lastSequence: index.lastSequence, + durationMs: index.durationMs, + settleMs: computeSettleMs(s.world), + ended: s.ended || terminal, + playheadSeq: seqAt(s.cursor), + }); + } + + return store; +} + +function safeJson(v: unknown): string { + try { + return JSON.stringify(v) ?? String(v); + } catch { + return String(v); + } +} diff --git a/apps/studio/src/state/test-utils.ts b/apps/studio/src/state/test-utils.ts new file mode 100644 index 0000000..1a28405 --- /dev/null +++ b/apps/studio/src/state/test-utils.ts @@ -0,0 +1,49 @@ +// Test helpers (node only: reads the world from disk). Never import from browser code. +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { RunDetailResponse, RunEvent, RunSummary } from '@runloop-world/events'; +import { codingMissionFixture } from '@runloop-world/testkit'; +import { createStudioStore, type StudioStore } from './store'; + +const WORLD_FILE = path.join(import.meta.dirname, '../../../../worlds/research-campus/world.json'); + +export function worldJson(): unknown { + return JSON.parse(readFileSync(WORLD_FILE, 'utf8')) as unknown; +} + +export function detailFor(events: readonly RunEvent[], status: RunSummary['status'] = 'running'): RunDetailResponse { + const first = events[0]!; + return { + run: { + runId: first.runId, + title: 'Fixture run', + missionId: 'coding-mission', + worldId: 'research-campus', + status, + createdAt: first.timestamp, + endedAt: null, + lastSequence: events.length, + adapterLabel: 'Scripted adapter', + paused: false, + }, + world: worldJson(), + }; +} + +/** A mutable fake wall clock. */ +export function fakeNow(startMs: number) { + let t = startMs; + return { now: () => t, set: (ms: number) => (t = ms), advance: (ms: number) => (t += ms) }; +} + +/** A store with the canonical fixture's run opened (world loaded, no events yet). */ +export function openFixture(opts: { now?: () => number } = {}): { store: StudioStore; events: RunEvent[] } { + const events = codingMissionFixture(); + const store = createStudioStore(opts.now ? { now: opts.now } : {}); + store.getState().openRun(events[0]!.runId); + store.getState().loadRun(detailFor(events)); + return { store, events }; +} + +/** JSON round trip, like events arriving over HTTP. */ +export const wire = (v: T): unknown => JSON.parse(JSON.stringify(v)) as unknown; diff --git a/apps/studio/src/state/timeline.test.ts b/apps/studio/src/state/timeline.test.ts new file mode 100644 index 0000000..04e5c95 --- /dev/null +++ b/apps/studio/src/state/timeline.test.ts @@ -0,0 +1,76 @@ +import { CODING_MISSION, codingMissionFixture } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { buildTimelineRows, formatDuration, rowStatusAt } from './timeline'; + +const events = codingMissionFixture(); +const t0 = Date.parse(events[0]!.timestamp); +const timeOfSeq = (seq: number) => Date.parse(events[seq - 1]!.timestamp) - t0; +const rows = buildTimelineRows(events, timeOfSeq); + +describe('timeline rows', () => { + it('merges each tool start and its completion into one row', () => { + const starts = events.filter((e) => e.type === 'tool.call_started'); + const toolRows = rows.filter((r) => r.callId !== null); + expect(toolRows.map((r) => r.callId)).toEqual(starts.map((e) => (e.payload as { callId: string }).callId)); + for (const r of toolRows) { + expect(r.endSeq).not.toBeNull(); + expect(r.endSeq!).toBeGreaterThan(r.seq); + expect(r.durationMs).not.toBeNull(); + } + // No separate rows for completions, nor for artifacts produced by a call. + expect(rows.some((r) => r.type === 'tool.call_completed' || r.type === 'artifact.created')).toBe(false); + }); + + it('shows the failing test run with the payload counts verbatim', () => { + const runs = rows.filter((r) => r.title.startsWith('run_command')); + expect(runs).toHaveLength(2); + const [failed, passed] = runs as [(typeof runs)[0], (typeof runs)[0]]; + const done = events.find((e) => e.sequence === failed.endSeq)!; + expect(done.type).toBe('tool.call_completed'); + + expect(failed.status).toBe('error'); + expect(failed.title).toBe(`run_command ${CODING_MISSION.testCommand}`); + expect(failed.detail).toBe('12 tests: 10 passed, 2 failed'); + expect(failed.metrics).toEqual({ tests: 12, passed: 10, failed: 2 }); + expect(failed.failingTests).toEqual([...CODING_MISSION.failingTests]); + expect(failed.durationMs).toBe(380); + expect(failed.artifactIds).toEqual(['art-2']); + expect(failed.actorId).toBe('tester'); + + expect(passed.status).toBe('ok'); + expect(passed.metrics).toEqual({ tests: 12, passed: 12, failed: 0 }); + expect(passed.seq).toBeGreaterThan(failed.seq); + }); + + it('shows a tool as running while the playhead is between start and end', () => { + const r = rows.find((x) => x.title.startsWith('run_command'))!; + expect(rowStatusAt(r, r.seq)).toBe('running'); + expect(rowStatusAt(r, r.endSeq! - 1)).toBe('running'); + expect(rowStatusAt(r, r.endSeq!)).toBe('error'); + expect(rowStatusAt(r, r.seq - 1)).toBe('error'); // not started yet: final status (dimmed in the UI) + const info = rows.find((x) => x.type === 'task.created')!; + expect(rowStatusAt(info, 0)).toBe('info'); + }); + + it('attributes task events to their owner and run events to the system', () => { + const blocked = rows.find((r) => r.type === 'task.blocked')!; + expect(blocked.actorId).toBe('tester'); + expect(blocked.status).toBe('warn'); + expect(rows[0]!.type).toBe('run.started'); + expect(rows[0]!.actorId).toBe('system'); + expect(rows.at(-1)!.type).toBe('run.completed'); + expect(rows.at(-1)!.status).toBe('ok'); + }); + + it('keeps rows in sequence order with times from the index', () => { + const seqs = rows.map((r) => r.seq); + expect(seqs).toEqual([...seqs].sort((a, b) => a - b)); + for (const r of rows) expect(r.tMs).toBe(timeOfSeq(r.seq)); + }); + + it('formats durations', () => { + expect(formatDuration(380)).toBe('380 ms'); + expect(formatDuration(1450)).toBe('1.4 s'); + expect(formatDuration(125_000)).toBe('2 m 05 s'); + }); +}); diff --git a/apps/studio/src/state/timeline.ts b/apps/studio/src/state/timeline.ts new file mode 100644 index 0000000..4b6b1a0 --- /dev/null +++ b/apps/studio/src/state/timeline.ts @@ -0,0 +1,202 @@ +import { formatEvent } from '@runloop-world/core'; +import { isUnknownEvent, type AnyEvent, type TestMetrics } from '@runloop-world/events'; + +export type RowStatus = 'running' | 'ok' | 'error' | 'warn' | 'info'; + +/** One readable line of the Timeline. Text comes only from event payload fields. */ +export interface TimelineRow { + /** eventId of the row's first event. */ + key: string; + type: string; + /** Sequence of the first event (a tool row starts at tool.call_started). */ + seq: number; + /** Sequence of the event that finished the row (tool completion/failure), if any. */ + endSeq: number | null; + /** Ms since run start of `seq`. */ + tMs: number; + /** Agent id, or 'system'. */ + actorId: string; + icon: string; + title: string; + detail: string | null; + /** Final status, once every merged event happened. */ + status: RowStatus; + durationMs: number | null; + /** Test counts exactly as the payload reported them. */ + metrics: TestMetrics | null; + failingTests: string[]; + artifactIds: string[]; + taskId: string | null; + callId: string | null; +} + +const SEVERITY_TO_STATUS = { info: 'info', ok: 'ok', warn: 'warn', error: 'error' } as const; + +const ICONS: Record = { + 'run.started': '▶', + 'run.paused': '⏸', + 'run.resumed': '⏵', + 'run.completed': '■', + 'agent.registered': '+', + 'agent.state_changed': '◐', + 'agent.message_sent': '✉', + 'task.created': '◇', + 'task.assigned': '→', + 'task.started': '◆', + 'task.blocked': '⏳', + 'task.completed': '✓', + 'task.failed': '✗', + 'tool.call_started': '⚙', + 'tool.permission_requested': '🔒', + 'tool.permission_resolved': '🔑', + 'artifact.created': '📄', +}; + +/** Agent state changes worth a Timeline row (the rest is in Logs). */ +const NOTABLE_AGENT_STATES = new Set(['failed', 'blocked']); + +/** + * Turn the ordered event log into Timeline rows. A tool call's start and its + * completion (or failure) become one row carrying the duration and status; + * artifacts produced by a call attach to that row. Rows are independent of + * the playhead: use {@link rowStatusAt} / `row.seq > playheadSeq` when rendering. + */ +export function buildTimelineRows(events: readonly AnyEvent[], timeOfSeq: (seq: number) => number): TimelineRow[] { + const rows: TimelineRow[] = []; + const byCall = new Map(); + const taskOwner = new Map(); + + const base = (e: AnyEvent): TimelineRow => ({ + key: e.eventId, + type: e.type, + seq: e.sequence, + endSeq: null, + tMs: timeOfSeq(e.sequence), + actorId: e.actorId ?? (e.taskId ? taskOwner.get(e.taskId) : undefined) ?? 'system', + icon: ICONS[e.type] ?? '•', + title: e.type, + detail: null, + status: 'info', + durationMs: null, + metrics: null, + failingTests: [], + artifactIds: [], + taskId: e.taskId ?? null, + callId: null, + }); + + for (const e of events) { + if (isUnknownEvent(e)) { + const f = formatEvent(e); + rows.push({ ...base(e), icon: '⚠', title: f.label, detail: f.detail ?? e.type, status: 'warn' }); + continue; + } + switch (e.type) { + case 'tool.call_started': { + const p = e.payload; + const row: TimelineRow = { + ...base(e), + actorId: p.agentId, + title: `${p.tool} ${p.inputSummary}`.trim(), + status: 'running', + taskId: p.taskId ?? null, + callId: p.callId, + }; + byCall.set(p.callId, row); + rows.push(row); + break; + } + case 'tool.call_completed': { + const p = e.payload; + const row = byCall.get(p.callId); + if (!row) { + rows.push(orphan(base(e), e)); + break; + } + row.endSeq = e.sequence; + row.status = p.status === 'success' ? 'ok' : 'error'; + row.icon = p.status === 'success' ? '✓' : '✗'; + row.detail = p.outputSummary; + row.durationMs = p.durationMs; + row.metrics = p.metrics ?? null; + row.failingTests = p.failingTests ?? []; + for (const id of p.outputArtifactIds ?? []) if (!row.artifactIds.includes(id)) row.artifactIds.push(id); + break; + } + case 'tool.call_failed': { + const p = e.payload; + const row = byCall.get(p.callId); + if (!row) { + rows.push(orphan(base(e), e)); + break; + } + row.endSeq = e.sequence; + row.status = 'error'; + row.icon = '✗'; + row.detail = `Could not run (${p.errorType}): ${p.message}`; + row.durationMs = p.durationMs; + break; + } + case 'artifact.created': { + const p = e.payload; + const row = p.producedByCallId ? byCall.get(p.producedByCallId) : undefined; + if (row) { + if (!row.artifactIds.includes(p.artifactId)) row.artifactIds.push(p.artifactId); + } else { + rows.push({ ...plain(base(e), e), artifactIds: [p.artifactId] }); + } + break; + } + case 'agent.state_changed': + if (NOTABLE_AGENT_STATES.has(e.payload.state)) rows.push(plain(base(e), e)); + break; + case 'task.assigned': + case 'task.started': + taskOwner.set(e.payload.taskId, e.payload.agentId); + rows.push(plain(base(e), e)); + break; + default: + rows.push(plain(base(e), e)); + } + } + return rows; +} + +/** + * Status of a row as seen from the playhead: a tool call that started but has + * not finished at the playhead shows as running. Rows after the playhead keep + * their final status (the UI dims them). + */ +export function rowStatusAt(row: TimelineRow, playheadSeq: number): RowStatus { + const started = row.seq <= playheadSeq; + if (row.callId !== null && started && (row.endSeq === null || row.endSeq > playheadSeq)) return 'running'; + return row.status; +} + +/** A row built from the core text formatter (payload fields only). */ +function plain(row: TimelineRow, e: AnyEvent): TimelineRow { + const f = formatEvent(e); + return { ...row, title: f.label, detail: f.detail, status: SEVERITY_TO_STATUS[f.severity] }; +} + +/** A tool result whose start was never seen (shown on its own rather than dropped). */ +function orphan(row: TimelineRow, e: AnyEvent): TimelineRow { + const r = plain(row, e); + return { ...r, icon: r.status === 'ok' ? '✓' : '✗' }; +} + +/** "380 ms", "1.4 s", "2 m 05 s". */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; + const s = Math.round(ms / 1000); + return `${Math.floor(s / 60)} m ${String(s % 60).padStart(2, '0')} s`; +} + +export const STATUS_LABEL: Record = { + running: 'running', + ok: 'success', + error: 'error', + warn: 'warning', + info: 'info', +}; diff --git a/apps/studio/src/state/view.test.ts b/apps/studio/src/state/view.test.ts new file mode 100644 index 0000000..4a2d9ce --- /dev/null +++ b/apps/studio/src/state/view.test.ts @@ -0,0 +1,55 @@ +import { foldExecution } from '@runloop-world/core'; +import { codingMissionFixture, runBuilder } from '@runloop-world/testkit'; +import { describe, expect, it } from 'vitest'; +import { flowChips, formatClockPair } from './view'; + +const events = codingMissionFixture(); +const at = (pred: (e: (typeof events)[number]) => boolean) => foldExecution(events.slice(0, events.findIndex(pred) + 1)); +const statuses = (exec: ReturnType) => Object.fromEntries(flowChips(exec).map((c) => [c.kind, c.view.text])); + +describe('agent flow chips', () => { + it('follow the mission: current, then done', () => { + expect(statuses(at((e) => e.type === 'task.started'))).toEqual({ + research: 'in progress', + code: 'pending', + test: 'pending', + }); + expect(statuses(at((e) => e.type === 'task.blocked'))).toEqual({ + research: 'done', + code: 'done', + test: 'in progress', // blocked counts as current + fix: 'pending', + }); + const final = flowChips(foldExecution(events)); + expect(final.map((c) => c.label)).toEqual(['Research', 'Code', 'Test', 'Fix']); + expect(final.every((c) => c.status === 'done' && c.view.icon === '✓')).toBe(true); + }); + + it('marks a failed task kind as failed with an icon and text', () => { + const b = runBuilder(); + b.add('run.started', { + title: 't', + missionId: 'm', + worldId: 'research-campus', + seed: 1, + adapter: { id: 'a', kind: 'scripted', label: 'A' }, + workspace: 'w', + }); + b.add('agent.registered', { agentId: 'a1', name: 'A1', role: 'test', model: null, tools: [], embodied: true }); + b.add('task.created', { taskId: 'T1', title: 'Deploy', kind: 'deploy', dependsOn: [] }); + b.add('task.assigned', { taskId: 'T1', agentId: 'a1' }); + b.add('task.started', { taskId: 'T1', agentId: 'a1' }); + b.add('task.failed', { taskId: 'T1', reason: 'boom' }); + const [chip] = flowChips(foldExecution(b.events)); + expect(chip).toMatchObject({ kind: 'deploy', status: 'failed', label: 'Deploy' }); + expect(chip!.view).toEqual({ icon: '✗', text: 'failed', tone: 'err' }); + }); +}); + +describe('formatClockPair', () => { + it('formats "mm:ss / mm:ss"', () => { + expect(formatClockPair(92_000, 195_000)).toBe('01:32 / 03:15'); + expect(formatClockPair(0, 0)).toBe('00:00 / 00:00'); + expect(formatClockPair(-5, 999)).toBe('00:00 / 00:00'); + }); +}); diff --git a/apps/studio/src/state/view.ts b/apps/studio/src/state/view.ts new file mode 100644 index 0000000..7bce7c1 --- /dev/null +++ b/apps/studio/src/state/view.ts @@ -0,0 +1,118 @@ +import { + formatClock, + selectAgentFlow, + type ExecState, + type FlowChip, + type RunExecStatus, + type TaskStatus, + type ToolCallStatus, +} from '@runloop-world/core'; +import type { RunStatus, TaskKind } from '@runloop-world/events'; +import type { Activity, CompiledWorld, PresentationState } from '@runloop-world/world-engine'; + +/** Every status is shown as icon + text (never colour alone); `tone` picks the colour. */ +export interface StatusView { + icon: string; + text: string; + tone: 'ok' | 'err' | 'warn' | 'info' | 'live' | 'muted'; +} + +export const KIND_LABEL: Record = { + research: 'Research', + code: 'Code', + test: 'Test', + fix: 'Fix', + approval: 'Approval', + deploy: 'Deploy', +}; + +const FLOW_STATUS: Record = { + pending: { icon: '○', text: 'pending', tone: 'muted' }, + current: { icon: '◉', text: 'in progress', tone: 'live' }, + done: { icon: '✓', text: 'done', tone: 'ok' }, + failed: { icon: '✗', text: 'failed', tone: 'err' }, +}; + +export interface FlowChipView extends FlowChip { + label: string; + view: StatusView; +} + +/** Agent Flow chips (core selectAgentFlow) with labels and status icons. */ +export function flowChips(exec: ExecState): FlowChipView[] { + return selectAgentFlow(exec).map((c) => ({ ...c, label: KIND_LABEL[c.kind], view: FLOW_STATUS[c.status] })); +} + +const RUN_STATUS: Record = { + pending: { icon: '○', text: 'pending', tone: 'muted' }, + running: { icon: '●', text: 'running', tone: 'live' }, + paused: { icon: '⏸', text: 'paused', tone: 'warn' }, + succeeded: { icon: '✓', text: 'succeeded', tone: 'ok' }, + failed: { icon: '✗', text: 'failed', tone: 'err' }, + stopped: { icon: '■', text: 'stopped', tone: 'warn' }, + interrupted: { icon: '⚠', text: 'interrupted', tone: 'warn' }, +}; + +export function runStatusView(status: RunStatus | RunExecStatus, paused = false): StatusView { + return paused && status === 'running' ? RUN_STATUS.paused : RUN_STATUS[status]; +} + +const CHARACTER_STATUS: Record = { + idle: { icon: '◌', text: 'idle', tone: 'muted' }, + moving: { icon: '➜', text: 'moving', tone: 'info' }, + working: { icon: '⚙', text: 'working', tone: 'live' }, + waiting: { icon: '…', text: 'waiting', tone: 'warn' }, + awaiting_approval: { icon: '🔒', text: 'awaiting approval', tone: 'warn' }, + succeeded: { icon: '✓', text: 'done', tone: 'ok' }, + failed: { icon: '✗', text: 'failed', tone: 'err' }, + paused: { icon: '⏸', text: 'paused', tone: 'warn' }, +}; + +export function characterStatusView(state: PresentationState | Activity): StatusView { + return CHARACTER_STATUS[state]; +} + +/** Scrubber clock: "mm:ss / mm:ss". */ +export function formatClockPair(tMs: number, endMs: number): string { + return `${formatClock(tMs)} / ${formatClock(Math.max(tMs, endMs))}`; +} + +export interface AgentLook { + name: string; + /** CSS colour (the world's character preset colour for embodied agents). */ + color: string; + embodied: boolean; +} + +/** Display name and colour for an agent id ('system' and unknown ids included). */ +export function agentLook(exec: ExecState, world: CompiledWorld | null, agentId: string): AgentLook { + const a = exec.agents[agentId]; + if (!a) return { name: agentId === 'system' ? 'System' : agentId, color: 'var(--role-system)', embodied: false }; + if (!a.embodied || !world) return { name: a.name, color: 'var(--role-system)', embodied: a.embodied }; + const preset = (a.characterPreset ? world.presetsById[a.characterPreset] : undefined) ?? world.presetsById[world.def.defaultPreset]; + return { name: a.name, color: preset?.color ?? 'var(--role-system)', embodied: true }; +} + +const TASK_STATUS: Record = { + pending: { icon: '○', text: 'pending', tone: 'muted' }, + assigned: { icon: '→', text: 'assigned', tone: 'info' }, + in_progress: { icon: '◉', text: 'in progress', tone: 'live' }, + blocked: { icon: '⏳', text: 'blocked', tone: 'warn' }, + completed: { icon: '✓', text: 'completed', tone: 'ok' }, + failed: { icon: '✗', text: 'failed', tone: 'err' }, +}; + +export function taskStatusView(status: TaskStatus): StatusView { + return TASK_STATUS[status]; +} + +const CALL_STATUS: Record = { + running: { icon: '⚙', text: 'running', tone: 'live' }, + success: { icon: '✓', text: 'success', tone: 'ok' }, + error: { icon: '✗', text: 'error', tone: 'err' }, + failed: { icon: '⊘', text: 'could not run', tone: 'err' }, +}; + +export function callStatusView(status: ToolCallStatus): StatusView { + return CALL_STATUS[status]; +} From 782550685c407cba5a8f728e35bdd46a538d218b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:14:20 +0000 Subject: [PATCH 11/18] Add plan for Claude Code tracer mode A Claude Code plugin whose hooks forward session, prompt, tool, permission and subagent events to the local runtime, which records them as a traced run and shows the session (subagents included) live in Studio. Covers the hook-to-event mapping, schema and runtime changes, privacy, testing, milestones and open questions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- docs/plans/claude-code-tracer.md | 645 +++++++++++++++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 docs/plans/claude-code-tracer.md diff --git a/docs/plans/claude-code-tracer.md b/docs/plans/claude-code-tracer.md new file mode 100644 index 0000000..6c6459c --- /dev/null +++ b/docs/plans/claude-code-tracer.md @@ -0,0 +1,645 @@ +# Claude Code tracer mode: implementation plan + +> Status: proposed (2026-10-17) · Target: Runloop 0.1.x · File: `docs/plans/claude-code-tracer.md` +> Paths are relative to the repo root. Items marked **[unverified]** are not confirmed by the Claude Code docs or the repo, and milestone M0 settles them. Sources are in Appendix A. + +## 1. Summary, goals, non-goals, v1 scope + +**Summary.** Tracer mode records a normal Claude Code session in Runloop and shows it live, subagents included. A small plugin (`integrations/claude-code-plugin/`) registers command hooks for session, prompt, tool, permission, subagent, notification and compaction events. Each hook runs a dependency-free Node script that writes the event to a local spool and forwards it to the runtime on `127.0.0.1:4317`. The runtime maps each event to ordinary Runloop events and records them through the existing `Recorder`. Each session becomes one run, each embodied agent one robot, and each `tool_use_id` one tool call. Studio streams the run over SSE and can replay it like any other run. + +### 1.1 Goals + +- Keep using Claude Code unchanged. Hooks are async, never write to stdout, never return decisions, and always exit 0. +- Show the main agent and subagents as robots, tool calls at stations, permission waits at the gate, and prompts and replies as messages. +- Keep one durable, replayable log that uses the same store, SSE stream and `ReplayIndex` as executed runs. +- Label provenance honestly. Runloop recorded what Claude Code reported; it did not execute or verify anything. +- Survive a runtime that is down at start, a runtime restart, a missing SessionEnd, parallel tools, and out-of-order delivery (tolerated in v1, not reordered). + +### 1.2 Non-goals + +- No control. Runloop never runs, steers, approves or blocks Claude Code. +- No network. Data goes only to loopback. +- No re-execution, branching or "re-run from here" for traced runs. +- No transcript parsing and no token or cost accounting in v1. +- No tracing of `claude --bare` or `--safe-mode` sessions. Those modes skip plugins and hooks by design. + +### 1.3 v1 scope + +| Included in v1 | Deferred | +|---|---| +| Hook capture for the events in §8 | v1.1: reorder hold window, only if M3 measurements show frequent anomalies | +| Pure mapper, idempotent ingest, synchronous projection (no reorder queue) | v1.1: `openCallIds` for parallel calls | +| Per-event spool with flush | v1.1: rebuild run state from events after a restart | +| Restart policy: close as interrupted, then a continuation segment | v1.1: model-switch markers (`PreModelSwitch` / `PostModelSwitch`) | +| Idle sweep (120 min default) | v1.1: subagent `homes` and palette, follow toggle, `runloop token --rotate` | +| Summary capture only; client and server redaction; prompt text marked `sensitive` | v2: `capture=full` artifacts, subagent despawn, dwell and hysteresis, idle-gap compression, `RUNLOOP_READ_TOKEN`, run deletion, long-lived relay, usage and cost (no documented hook fields) | +| Studio: traced badge, Source panel, inferred chips, Live sessions, follow newest traced run, marker rows, tool categories and stations, cap of 8 embodied subagents | | +| `--doctor` check, marketplace packaging | | + +## 2. User experience + +**Install (local dev)** +```sh +pnpm dev # runtime :4317 + Studio :3000; runtime writes the ingest token on boot +claude --plugin-dir ~/code/runloop/integrations/claude-code-plugin +``` +After editing the plugin, run `/reload-plugins` inside Claude Code. + +**Install (marketplace)** +``` +/plugin marketplace add /runloop +/plugin install runloop-tracer@runloop +``` +Or from the shell: `claude plugin install runloop-tracer@runloop --scope user`. + +**Check the setup:** `node /scripts/runloop-hook.mjs --doctor`. It prints the runtime URL, whether the token file was found, whether the runtime answers, the spool size, the last error, and the time of the last accepted event. If no events have arrived, it suggests checking for managed-policy hook blocking (see §9). + +**Using Claude Code normally** + +| Claude Code | Studio | +|---|---| +| You submit your first prompt, or the first tool or subagent event happens | A run appears under **Live sessions**, titled "Claude Code · ``", with a **Traced · Claude Code** badge. Studio opens it if no run is open. Opening and closing Claude Code without typing creates nothing. | +| You submit a prompt | "You → Claude" message; the main robot starts a *Turn N* task | +| Claude works without a prompt (a background task finishes, a scheduled task runs) | An implicit turn opens with an *inferred* title | +| Read / Grep / Glob / WebFetch / WebSearch | Robot walks to the Research room (library stations) | +| Edit / Write / NotebookEdit, and Bash / PowerShell / Monitor that are not tests or deploys | Build room | +| Bash or PowerShell running a test runner | Test room | +| Bash or PowerShell matching a deploy pattern, or an `mcp__*` tool | Ops room | +| `Agent` spawns a subagent | A robot "`` #k" appears (up to 8 embodied per run), gets a *Delegated* task, works in parallel, and dims when it finishes | +| Permission dialog | Robot waits at the Approval Gate. Approved when the call completes; denied (inferred) otherwise | +| Tool error or API error | Red incident at the station; an API error fails the turn | +| Compaction, notifications | Marker rows in the timeline | +| You exit | Run ends as "Ended" (stored as outcome `stopped`) | +| Claude Code is killed | Run closes as "Interrupted" after 120 min idle (inferred). The next hook opens a continuation segment. | + +**Replay.** Open the run at any time and scrub it in the ReplayBar. Nothing is re-executed. + +## 3. Architecture + +``` +Claude Code (unchanged) + │ fires hook: command, exec form, async (SessionEnd is synchronous) + ▼ +node scripts/runloop-hook.mjs + │ read stdin (≤ 64 MiB) → parse → slim (drop tool_response, keep sizes) + │ → redact (shared patterns + Claude Code's env secrets) + │ envelope {v:1, deliveryId, observedAt, ppid, plugin, hook} + │ write spool//-.json (before any network call) + │ POST /api/ingest/claude-code (X-Runloop-Token; 1 s, or 800 ms for SessionEnd) + │ 2xx → delete own spool entry, then flush up to 20 older entries (any session) + │ network/5xx → keep entry; 400/413 → drop; 401/403 → keep + │ always exit 0 · never write stdout + ▼ +Runloop runtime 127.0.0.1:4317 + POST /api/ingest/claude-code + guards in order: Host (existing) → no Origin → JSON → bodyLimit 1 MiB → token → zod → rate cap + per envelope, synchronously, one SQLite transaction: + trace_ingest dedupe → mapHook() [pure] → Recorder.recordBatch (redact → sequence → validate → persist → publish) + Idle sweep every 60 s + ▼ +GET /api/runs/:id/stream (SSE, existing) + ▼ +Studio: SequenceBuffer → ReplayIndex → reduceExecution (L1) → reduceWorld (L2) → R3F scene +``` + +**Decisions** + +**D1. Command hooks with `async: true` in exec form. SessionEnd is synchronous.** +- Why not `http` hooks: the docs describe `async` as a command-hook field, and SessionStart accepts only `command` and `mcp_tool` handlers. An HTTP hook would make every tool call wait on the runtime, would fail on a refused connection, and could not redact on the client or spool. HTTP-hook async support is **[unverified]**. +- Async hooks should not be able to block. The docs say background responses are ignored for PostToolUse ("Synchronous responses only"), and `asyncRewake` is the only background form that acts on exit 2. The M0 probe confirms that an async PreToolUse exiting 2 does not stop the tool. +- Cost: one Node process per event, roughly 40–80 ms CPU, off the critical path. M3 measures it. +- SessionEnd: the docs give SessionEnd a 1.5 s budget, which a longer per-hook `timeout` in settings raises up to 60 s. Whether a plugin's `hooks.json` timeout counts is **[unverified]** (M0). The design assumes 1.5 s. +- `node` must be on the PATH that Claude Code sees. If it is not, hooks fail silently. The README documents the check. + +**D2. Fail open.** The script exits 0 on every path and writes nothing to stdout. Plain stdout would be injected into Claude's context for SessionStart and UserPromptSubmit. Errors go to `${CLAUDE_PLUGIN_DATA}/last-error.json`, and to stderr only with `RUNLOOP_TRACE_DEBUG=1`. A self-timeout of 1.5 s bounds the run. + +**D3. Spool first, then send.** Each event is written to its own file before any network call, so an event survives if Claude Code exits while the hook is still running. Any later hook flushes the spool, across sessions. There is no lock: duplicates are harmless because ingest is idempotent. Bounds: 50 MiB total, 7-day TTL, oldest dropped first. + +**D4. Map in the runtime.** Hook processes are stateless and see one event each. Only the runtime sees the whole session. The mapper is a pure function, testable with golden files. Plugin updates need user action, so the script stays small and stable. + +**D5. Ordering without a reorder window (v1).** Ingest projects each envelope synchronously in arrival order. No queue holds events, so a runtime crash cannot lose accepted events. The mapper is late-tolerant. A PostToolUse for an unknown call synthesizes the start (inferred), built from the PostToolUse's own `tool_input`, and the late PreToolUse is then ignored. Timestamps come from `observedAt`; `ReplayIndex` clamps them to be non-decreasing. A reorder hold is added in v1.1 only if M3 shows frequent anomalies. + +**D6. Token location.** The default data dir is `/.runloop`, which the plugin cannot discover, so the token lives per user: +- `$XDG_CONFIG_HOME/runloop/ingest-token` (default `~/.config/runloop/ingest-token`), or `%APPDATA%\runloop\ingest-token` on Windows. +- Contents: 32 random bytes, base64url. The runtime creates it at boot if it is missing, with mode 0600. On Windows the file's user-profile ACL applies instead of mode bits. +- `RUNLOOP_INGEST_TOKEN_FILE` overrides the path on both sides. +- Rotation for v1: delete the file and restart the runtime. `runloop token --rotate` is v1.1. The `runloop` CLI today has only `serve` and `demo`. + +**D7. Scope of tracing.** The plugin traces every session where it is enabled. That includes `claude -p` and Agent SDK sessions, which load plugins unless run with `--bare`. The docs name no hook field that separates interactive from headless sessions, so v1 cannot exclude them automatically. CI should disable the plugin or set `RUNLOOP_TRACE=0`. + +## 4. Hook → Runloop event mapping + +**Identifiers** +- `runId` = UUIDv5(fixed namespace, `claude-code::`). It is deterministic. `Recorder.createRun` throws on an existing id, so the manager checks first. +- Agents: `user` (not embodied), `main` (embodied, preset `claude`), and `sa-` for subagents, sanitised to `[A-Za-z0-9._-]` and at most 64 characters. An event's agent is `sa-` when `agent_id` is present, otherwise `main`. +- `callId` = `tool_use_id`. Task ids: `turn-` and `delegate-`. Permission ids: `perm-`. Message ids: `msg-`. +- Every draft carries `provenance: 'reported'` unless marked *(inferred)*. + +**Lazy run creation.** The mapper holds SessionStart's fields (`model`, `cwd`, `permission_mode`, `source`) in a pending state. The first UserPromptSubmit, or the first tool, subagent or permission event, creates the run: `run.started` (mode `traced`, adapter kind `external`), `agent.registered user`, and `agent.registered main`. + +| Hook | Input used | Runloop events | Notes | +|---|---|---|---| +| SessionStart | `session_id`, `source`, `model?`, `cwd`, `permission_mode` | None by itself; sets pending state. If a run is open and `source` is `resume` or `compact`: `run.marker` (`session_resumed` or `compaction`). | `resume` and `fork` are documented; `startup`, `clear`, `compact` are **[unverified]**. `projectLabel = basename(cwd)`; the absolute path is never stored. | +| UserPromptSubmit | `prompt`, `prompt_id` | Close any open turn (inferred summary). Then `task.created {turn-n, kind:'turn'}` → `task.started main` → `agent.message_sent user→main` → `agent.state_changed main working`. | Key `prompt:`, falling back to `delivery:`. `prompt_id` is absent until the first input (documented). Text is redacted, capped at 2 KB, and `sensitive`. `RUNLOOP_TRACE_PROMPTS=0` stores a placeholder. | +| PreToolUse | `tool_name`, `tool_input`, `tool_use_id`, `agent_id?` | `tool.call_started {callId, agentId, taskId, tool, category, inputSummary}`. If main has no open task, open an implicit turn first. | Key `pre:`. A repeat is dropped. | +| PermissionRequest | `tool_name`, `tool_input`, `agent_id?` | `tool.permission_requested {perm-n, agentId, callId?, action, scope: permission_mode}` | No `tool_use_id` is documented **[unverified]**. Match to the newest open call with the same agent, `tool_name`, and sha256(`tool_input`) (inferred). | +| PostToolUse | `tool_use_id`, `tool_input`, `tool_response` (dropped), `duration_ms?` | If a permission is pending on this call: `tool.permission_resolved approved by 'user'` (inferred). If the call is unknown, synthesize its start (inferred). Then `tool.call_completed {status:'success', durationMs, outputSummary}`. | `tool_use_id` appears in the docs example; whether every PostToolUse carries it is **[unverified]**. Key `post:`. | +| PostToolUseFailure | `tool_use_id?`, `error`, `is_interrupt?`, `duration_ms?` | Interrupt → `tool.call_failed {errorType:'aborted'}`. Otherwise `tool.call_completed {status:'error', errorType:'tool_error', outputSummary: error}`. | `is_interrupt` is **[unverified]**. This hook does not fire for permission denials, so denials are handled separately (below). | +| PermissionDenied (auto mode only) | `tool_use_id?` | `permission_resolved denied by 'auto mode'`, then `tool.call_failed {errorType:'denied'}` | Auto mode only (documented). The only deterministic denial signal. | +| SubagentStart | `agent_id`, `agent_type` | `agent.registered {sa-…, name:' #k', role:type, model:null, tools:[], embodied (within cap), characterPreset:'builder'}`, then `task.created {delegate-k, kind:'delegated', title}`, `task.assigned`, `task.started`, `state_changed working` | Link to the parent's newest `Agent` call that has no subagent yet. The `Agent` input field names (`subagent_type`, `description`) are **[unverified]**; the title falls back to the agent type. Subagents beyond the cap get no robot but are still traced. | +| SubagentStop | `agent_id`, `last_assistant_message` | Close its open calls as `tool.call_failed {errorType:'unobserved'}` (inferred). Close its pending permissions as denied (inferred). `agent.message_sent sa→main` (2 KB). `task.completed {delegate-k, summary, artifactIds:[]}`. `state_changed done`. | `last_assistant_message` is documented. A background subagent can outlive its parent's turn, so late events attach to the delegate task, not to the turn. | +| Stop | `last_assistant_message`, `stop_hook_active?` | Close main's pending permissions and their calls as denied (inferred). `agent.message_sent main→user`. `task.completed {turn-n, summary, artifactIds:[]}`. `state_changed main idle`. If `stop_hook_active` is true, do not close the turn. | Key is `deliveryId`, because Stop can repeat. `stop_hook_active` is **[unverified]**. Whether Stop fires on Esc is **[unverified]** (M0). | +| StopFailure | `error?`, `error_details?` | `task.failed {turn-n, reason, errorType:'api_error'}`, `state_changed main failed`, `run.marker api_error` | `error` fields are **[unverified]**. | +| Notification (matcher `idle_prompt\|permission_prompt`) | `notification_type`, `message` | `idle_prompt` → `state_changed main waiting`. `permission_prompt` with no pending permission → `tool.permission_requested` fallback (inferred). | The matcher stops other notification types from spawning processes. Notification type names are documented. | +| PreCompact / PostCompact | `trigger?` | `run.marker compaction` (start and end). Only the summary length is recorded. | `trigger` and `compact_summary` are **[unverified]**. | +| SessionEnd (synchronous) | `reason` | Close open calls (`unobserved`), pending permissions (denied), and open tasks (`task.completed`, "ended with session", inferred). Subagents → `done`. Then `run.completed {outcome:'stopped', summary:'Session ended ()'}`. | Reason values beyond `clear` and `resume` are **[unverified]**. | + +**Not registered in v1:** PostToolBatch, Setup, UserPromptExpansion, TaskCreated, TaskCompleted, TeammateIdle, Pre/PostModelSwitch (v1.1), MessageDisplay, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, Worktree*, Elicitation*. + +**Edge cases** +- **Parallel tool calls.** Each `tool_use_id` is its own call. Core keeps one `activeCallId` (the last started call), and `finishCall` clears it when that call finishes, even while an earlier sibling still runs. In v1 the robot then falls back to the turn's task rule until the siblings finish. This is cosmetic. `openCallIds` is v1.1. +- **Subagent attribution.** Documented: plugin hooks run inside subagents; `agent_type` is documented as present for hooks that fire inside a subagent; `agent_id` appears in the common fields. Whether every tool event in a subagent carries `agent_id` is **[unverified]** (M0). Fallback: if exactly one subagent is open, attribute the call to it (inferred). Otherwise attribute it to `main` and record a one-time marker: "subagent attribution unavailable". +- **Denied permission.** In default mode, a denial fires no hook: PostToolUseFailure does not cover denials, and PermissionDenied is auto-mode only. The denial is inferred at the agent's next Stop, UserPromptSubmit, SubagentStop, SessionEnd, or idle sweep, and the call closes as `denied`. Late results for an already-closed call are dropped and counted. +- **Esc interrupt.** `is_interrupt` maps to `aborted` **[unverified field]**. The open turn closes at the next prompt or at SessionEnd, with an inferred summary, and is not marked failed. +- **Work without a prompt.** A background task finishing or a scheduled task running can produce main-agent tool events with no open turn. The mapper opens an implicit turn with an inferred title. +- **No SessionEnd** (kill -9, terminal closed). The idle sweep closes the run as `interrupted` after `RUNLOOP_TRACE_IDLE_MINUTES` (default 120), inferred. If the recorded Claude Code pid is gone, close on the next sweep instead **[unverified]**: it depends on `ppid` being the Claude Code process. A later hook for the same session opens a continuation segment. +- **Resume.** The CLI reference says `--fork-session` is needed for a new session ID, so resume reuses the ID. If the run is open, record `run.marker session_resumed`. If it is closed, open segment `n+1` with `source.continuesRunId`. +- **/clear.** Documented: the previous conversation is saved and a fresh one starts. Expected: `SessionEnd(clear)` closes the run and SessionStart starts pending state for the new session. Whether the new conversation gets a new session ID is **[unverified]** (M0). `/branch` and `--fork-session` get new IDs (documented). +- **/compact.** Documented as a summary that replaces history. The session continues, so markers only. +- **Two processes on one session.** Resuming one session in two terminals without forking interleaves the transcript (documented). The envelope carries `ppid`. If it changes within a session, record `run.marker other: "second Claude Code process"`. The `ppid` semantics are **[unverified]**. +- **Runtime restart mid-session.** See §6.7. +- **Concurrent sessions.** Each `session_id` has its own state and run. They do not share the single scripted run slot. +- **Late events.** Spool entries flushed after SessionEnd are dropped for the closed run and counted in `--doctor`. + +## 5. Event schema changes (`packages/events`) + +**`src/payloads.ts`** +- `AdapterInfoSchema.kind`: `z.enum(['scripted','llm','external'])`. Today it is `['scripted','llm']`. +- New `RunModeSchema = z.enum(['executed','traced'])`. +- New `TraceSourceSchema`: + ```ts + z.object({ + kind: z.literal('claude-code'), sessionId: id, segment: z.int().min(1), + projectLabel: z.string().max(200), model: z.string().nullable().optional(), + permissionMode: z.string().optional(), startSource: z.string().optional(), + pluginVersion: z.string().optional(), continuesRunId: z.uuid().optional(), + }) + ``` +- `run.started`: add `mode` (optional; absent means executed) and `source` (optional). `seed` and `workspace` become optional, because today both are required. A `superRefine` enforces: `mode==='traced'` if and only if `source` is present and `adapter.kind==='external'`. Traced runs use `missionId:'claude-code'` and `worldId` from config (default `research-campus`). +- `TaskKindSchema`: add `turn` and `delegated`. +- `ToolCategorySchema = z.enum(['read','search','web','edit','execute','test','deploy','mcp','delegate','plan','other'])`. `tool.call_started` gains `category` (optional). +- `ToolFailureTypeSchema`: add `unobserved`. Today it is `timeout | denied | aborted | spawn_error | exception | runtime_restarted`. +- New `run.marker`: `{ kind: z.enum(['compaction','notification','api_error','session_resumed','model_switch','other']), label: z.string(), detail: z.string().optional(), agentId: id.optional() }`. +- Required fields the mapper must fill (checked against `payloads.ts`): + - `tool.call_failed` needs `message` and `durationMs`. + - `tool.call_completed` needs `durationMs` and `outputSummary`. + - `task.completed` needs `summary` and `artifactIds`. + - `task.failed` needs `reason`. + - `agent.registered` needs `model` (nullable) and `tools` (array). + - `task.created` needs `dependsOn` (array). +- Deferred: `agent.usage_reported`. No documented hook fields carry token counts. + +**`src/envelope.ts`**: add `ProvenanceSchema = z.enum(['reported','inferred'])` and `provenance` (optional). Absent means native. The envelope schema strips unknown keys, so older readers ignore the field. + +**`src/event.ts`**: `EventDraft` gains `provenance?` and `timestamp?`. Traced runs only; the recorder enforces this (§6.5). `deriveRefs` maps `run.marker` to `actorId = agentId`. + +**`src/redact.ts`**: export `REDACTION_PATTERN_SOURCES`. The pattern list is private today, so the plugin's copy cannot be checked against it. + +**`src/protocol.ts`** +- `RunSummary` gains `mode`, `source: {kind:'claude-code', sessionId, segment, projectLabel} | null`, and `lastEventAt`. +- `HealthResponse.activeRunId` stays scripted-only. Studio's demo button disables itself whenever it is set (`Sidebar.tsx`), so traced runs must never set it. Add `tracedRunIds: string[]` and `ingest: {claudeCode:{protocol:1}} | null`. +- New DTOs: `ClaudeCodeHookEnvelope { v: 1; deliveryId; observedAt; ppid?; plugin: {name, version}; hook: Record; derived?: {lines?, bytes?} }`, `ClaudeCodeIngestRequest { envelopes: ClaudeCodeHookEnvelope[] }` (1–100), and `ClaudeCodeIngestResponse { accepted, duplicates, ignored }`. Add `apiPaths.ingestClaudeCode = '/api/ingest/claude-code'`. + +**Compatibility.** `SCHEMA_VERSION` stays 1. Every change is an optional field, a new enum member, or a new type. Old logs parse unchanged. Readers from before this change see new types as `UnknownEvent`, which the reducers record as an `unknown_event` anomaly and the trace printer shows as "Unrecognised". Rule for the package: v1 changes are additive only. + +**Core follow-ups (`packages/core`)** +- `reduce-execution.ts`: `run.started` stores `mode` and `source`. A new `run.marker` handler appends to `ExecState.markers`. The `handlers` map is exhaustive by `EventType`, so the compiler forces the new handlers. +- `AgentExec.lastCategory`: set on `tool.call_started`. `deriveIntent` uses it to keep stations sticky (§7.1). +- `format.ts` `describe()`: category-aware tool labels and marker rows. +- `ExecState.run` gains `mode` and `source`. + +## 6. Runtime changes (`packages/runtime`) + +### 6.1 New modules + +``` +packages/runtime/src/tracers/claude-code/ + hook-input.ts loose zod schemas; unknown keys pass; session_id and hook_event_name required + summarize.ts tool_input/tool_response → summaries and categories; cwd-relative paths; Bash and PowerShell classifier + ids.ts UUIDv5 (SHA-1, ~15 lines); agent, task and permission ids + mapper.ts pure state machine (§6.2) + session-manager.ts TraceSessionManager: dedupe, projection, idle sweep, restart policy +packages/runtime/src/http/ingest.ts route, guards, limits +packages/runtime/src/ingest-token.ts create and read the token file +``` + +Wire these into `createRuntime` (`runtime.ts`) beside `RunManager`. `Runtime.close()` also stops the sweep timer. Today `createRuntime` calls `recorder.recoverInterrupted()` before building the manager, so traced recovery has to be mode-aware (§6.7). + +**Recorder additions.** `tx()` is not re-entrant (its comment says so), so the trace path cannot wrap several `Recorder.append` calls in one transaction. Add `Recorder.recordBatch({ runId, create?: RunManifest, drafts, ingest?: {sessionId, key} })`. It does run creation (optional), all drafts, and the `trace_ingest` row in one `tx()`. An envelope is atomic: if any draft fails validation, the whole envelope rolls back and is counted as ignored. + +### 6.2 Mapper contract + +```ts +export interface SessionState { + sessionId: string; runId: string | null; segment: number; status: 'open' | 'closed'; + pending: { model: string | null; cwd: string; permissionMode: string; source: string } | null; + agents: Record; + turn: { n: number; promptId: string | null; taskId: string; implicit: boolean } | null; turnCount: number; + calls: Record; + pendingPermissions: Record; + counters: { permission: number; delegate: number; message: number; embodied: number }; + lastObservedAt: string; ppid: number | null; +} +export type TraceOp = + | { op: 'create_run'; runId: string; manifest: RunManifest; timestamp: string } + | { op: 'append'; draft: EventDraft; parentRef?: `call:${string}` } // manager resolves parentEventId + | { op: 'artifact'; artifactId: string; content: string; draft: EventDraft<'artifact.created'> }; +export function mapHook(s: SessionState, env: ClaudeCodeHookEnvelope, ctx: MapContext): { state: SessionState; ops: TraceOp[] }; +export function sweep(s: SessionState, ctx: MapContext, reason: 'idle' | 'restart' | 'ended'): { state: SessionState; ops: TraceOp[] }; +``` + +`summarize.ts` examples: `Read src/app.ts`, `Grep "useStore" in src/`, `Bash · pnpm test`, `Edit src/x.ts (+3 −1)` (line counts from `derived`), `Agent → Explore: "find auth code"`. Caps: 200 characters for inputs, 300 for outputs. + +**Classifier** (Bash and PowerShell): +- test: `/\b(vitest|jest|pytest|mocha|rspec|phpunit|ctest|go test|cargo test|(npm|pnpm|yarn|bun)( run)? test)\b/` +- deploy: `/\b(git (push|tag)|gh (pr|release)|docker (build|push)|kubectl|terraform apply|npm publish|vercel|fly deploy)\b/` +- otherwise execute. + +Test is checked before deploy, so `pnpm test && git push` counts as a test. + +### 6.3 Ingest endpoint: `POST /api/ingest/claude-code` + +The existing `/api/*` middleware applies CORS and the JSON check, and rejects a disallowed Origin with 403 (verified in `app.ts`). The ingest route adds the rules below. Checks run in this order: +1. The existing Host guard (`localhost` or `127.0.0.1`). +2. Any `Origin` header is rejected with 403, even an allowed one. The plugin is not a browser. +3. JSON content type (existing; 415 otherwise). +4. Body limit 1 MiB (413). Today there is no body limit. +5. `X-Runloop-Token`, compared in constant time on SHA-256 digests (401). +6. zod validation (400). `v !== 1` returns 400 with `{error, supported:[1]}`. +7. Per-session token bucket: 100 per second, burst 1000 (429). +8. `TraceSessionManager.ingest()`, then 202 with `{accepted, duplicates, ignored}`. + +`RUNLOOP_TRACE=off` removes the route. + +### 6.4 Idempotency + +- Transport: `trace_ingest(session_id, ingest_key)` is the primary key. The row is inserted in the same transaction as the events it produced. The key is semantic when possible: `pre:`, `post:`, `prompt:`, `sa-start:`, `sa-stop:`. Otherwise `delivery:`. Spool retries reuse the file's `deliveryId`, so retries are safe. +- Semantic: the mapper ignores repeat starts and ends for a call or an agent, so duplicate deliveries cannot create `duplicate_id` anomalies. +- Known gap: installing the plugin twice (for example `--plugin-dir` plus the marketplace copy) double-sends. Semantic keys stop most duplicates, but Stop, Notification and compaction markers would still repeat. v1 documents "install once". + +### 6.5 Timestamps and mode guards + +- `Recorder.build()` currently sets `timestamp` from the runtime clock and builds the envelope field by field. For traced runs it uses `draft.timestamp ?? now()`, clamped to at most now and at least `run.started`. It must also copy `provenance` explicitly, because it does not pass draft keys through. +- The recorder caches each run's mode and enforces the rule in both directions. Executed runs reject drafts that carry `timestamp` or `provenance`. Traced runs require `provenance` on `tool.*` events. This keeps the trust rule in `adapter.ts` (`run.*`, `tool.*`, `artifact.*` come only from the runtime) meaningful: in an executed run, `tool.*` still means "Runloop ran it". + +### 6.6 Runs and concurrency + +- `TraceSessionManager` keeps `Map`. It has no workspace, adapter, AbortController or PauseGate. +- `RunManager.active` remains the single scripted slot. Only `RunManager.start` raises `RunConflictError` today, so traced runs never conflict. +- Control routes (`stop`, `pause`, `resume`) on traced runs return 409 with "Traced runs are controlled by Claude Code". Today `requireActive` already returns 409 for known runs that are not active, so this is a message change. +- Migration `MIGRATIONS[1]` (append-only; only index 0 exists today): + ```sql + ALTER TABLE runs ADD COLUMN mode TEXT NOT NULL DEFAULT 'executed'; + ALTER TABLE runs ADD COLUMN source_kind TEXT; + ALTER TABLE runs ADD COLUMN external_session_id TEXT; + ALTER TABLE runs ADD COLUMN segment INTEGER; + ALTER TABLE runs ADD COLUMN project_label TEXT; + ALTER TABLE runs ADD COLUMN last_event_at TEXT; + CREATE INDEX runs_by_external_session ON runs (source_kind, external_session_id, segment); + CREATE TABLE trace_ingest ( + session_id TEXT NOT NULL, ingest_key TEXT NOT NULL, run_id TEXT REFERENCES runs (run_id), + hook_event TEXT NOT NULL, observed_at TEXT, received_at TEXT NOT NULL, + PRIMARY KEY (session_id, ingest_key) + ) WITHOUT ROWID; + ``` + `persist()` updates `last_event_at`. +- A normal SessionEnd maps to the existing outcome `stopped`. There is no new status. The `runs.status` CHECK constraint would need a table rebuild, which is unsafe because `events` and `artifacts` reference `runs` with foreign keys on (`db.ts` sets `foreign_keys = ON`). Studio shows a traced `stopped` run as "Ended". + +### 6.7 Restart and idle sweep + +- **Boot (v1).** `Recorder.recoverInterrupted()` marks every `running` run interrupted, including traced runs, and adds `tool.call_failed{runtime_restarted}` for open calls. For traced calls that is false: they may well have finished. The change is mode-aware. Traced runs get `tool.call_failed{errorType:'unobserved', message:'Runloop was not running when this call finished (inferred)'}` and `run.completed{interrupted}`. +- **After a restart (v1).** The next hook for the same session finds the run closed and opens a continuation segment with `continuesRunId`. The turn counter continues from the closed run's count. Rebuilding in-memory state from events is v1.1. +- **Idle sweep (v1).** Runs every 60 s on an unref'd timer. A session with no envelope for `RUNLOOP_TRACE_IDLE_MINUTES` (default 120) gets its open items closed as inferred `unobserved`, then `run.completed{interrupted, "No events from Claude Code for N min"}`. + +### 6.8 Limits, redaction and capture + +- **Plugin side.** Read stdin up to 64 MiB. Drop `tool_response` and keep `derived.bytes` and `derived.lines`. Truncate each string field to 4 KB before sending. The server truncates again: prompts and messages to 2 KB, tool summaries to the caps in §6.2. +- **Secrets.** + - Client side: the shared pattern list (a copy, parity-tested against `REDACTION_PATTERN_SOURCES`), plus a port of `collectEnvSecrets` applied to Claude Code's environment. + - Bash and PowerShell only: `(KEY|TOKEN|SECRET|PASSWORD|PASS)\w*=\S+` and `--(password|token|secret|api-key)[= ]\S+`. Values are masked. + - Recorder side: `Recorder.build()` redacts again with the runtime's own environment. `redactEvent` changes visibility to `redacted` when it masks something. +- **Visibility.** Prompts and messages are `sensitive`. +- **Paths.** Relative to `cwd`. Paths outside `cwd` appear as `~/…` or `…/`. No absolute path appears in any event. +- **Capture.** Summary only in v1. `capture=full` (artifacts) is v2. + +## 7. World and Studio changes + +### 7.1 Tool categories drive stations + +| Category | Claude Code tools | Station (research-campus) | +|---|---|---| +| read, search, web | Read, Grep, Glob, WebFetch, WebSearch | library | +| edit | Edit, Write, NotebookEdit | build | +| execute | Bash, PowerShell, Monitor (not test or deploy) | build | +| test | Bash or PowerShell whose command matches the test pattern | test | +| deploy | Bash or PowerShell matching the deploy pattern | ops | +| mcp | `mcp__*` | ops | +| delegate | Agent | none (robot stays put) | +| plan | EnterPlanMode, ExitPlanMode | none | +| other | TaskCreate, TaskGet, TaskList, TaskUpdate, TaskStop, TodoWrite, and any unlisted name | none | + +The tools reference does not list LS, MultiEdit, NotebookRead, BashOutput or KillShell, so they are not mapped. + +**World rules (`definition.ts`, `intent.ts`, `reduce-world.ts`)** +- `ActivityRuleSchema.when.toolCategory?: ToolCategory[]`. +- `matchActivityRule` takes `{taskKind, toolCategory, permission}`. +- Category resolution order: the active call's category; then the agent's `lastCategory` while the task is still active (sticky, so short gaps between calls do not send the robot home); then the task-kind rules. +- Priority is unchanged except for the new category step: failed, then awaiting approval, then waiting, then working, then succeeded, then idle. The draft said permission ranks first; it ranks second, after failed (`intent.ts`). +- The "agent with an open call but no task counts as working" rule from the draft is dropped. Every traced agent has a task, because main gets an implicit turn. +- `Sim.updateIntents` must also compare `stationKind`. Today it compares only activity and `taskId`, on the stated assumption that station kind is a function of (activity, task). That no longer holds once categories exist (`reduce-world.ts`, lines 177–178). +- `worlds/research-campus/world.json`: insert the `toolCategory` rules after the permission rule and before the task-kind rules. Scripted demo calls carry no category, so the demo is unchanged. Add one preset `claude` on a free walkable home cell. `world.test.ts` checks reachability. +- Thrashing is controlled by the sticky category and the coarse categories. Measure in M5. Dwell and hysteresis are v2. + +### 7.2 Robots for subagents + +- At most 8 embodied subagents per run. Subagents beyond that are traced (tasks, calls, timeline) but get no robot. The cap is needed because overflow queues stack every waiting character on one cell (`reserve()` in `reduce-world.ts`). +- Embodied subagents use the existing `builder` preset in v1. Per-type colours, `homes` and `variantPalette` are v1.1. +- Finished subagents dim in place (state `done`). They are not despawned; despawn is v2. + +### 7.3 Provenance labelling + +- `RunHeader.tsx` and the `Sidebar.tsx` row show the **Traced · Claude Code** badge. +- The metadata panel (`MetadataPanel.tsx`) shows a **Source** block for traced runs: session id (short), segment, project, model, permission mode, plugin version. It also shows this banner: *"Recorded from Claude Code hooks. Runloop did not run, approve or verify these actions; results are shown as Claude Code reported them. Items marked inferred were reconstructed by Runloop."* +- `LogsPanel.tsx` and `TimelinePanel` show an *inferred* chip next to the visibility chip. +- `AgentDetails` shows the model from SessionStart. + +### 7.4 Session list and following + +- The Sidebar groups runs into **Live sessions**, **Demo runs** and **History**. Live rows show "idle 5m" from `lastEventAt`. +- The demo button keeps keying on `health.activeRunId`, which stays scripted-only (`Sidebar.tsx`, line 54). +- `useRuntimePolling` (`state/hooks.ts`, line 49) opens `health.activeRunId ?? runs[0]` when nothing is open. Change it to prefer the newest running traced run when no scripted run is active. A "Follow new sessions" toggle is v1.1. + +### 7.5 Copy and labels + +- `KIND_LABEL` (`state/view.ts`, a `Record`) gains Turn and Delegated. The compiler forces this. +- `selectAgentFlow` (`packages/core/src/selectors.ts`) groups by kind and needs no change. +- `AgentFlow.tsx` says "as the coordinator creates them". Reword it so it is not coordinator-specific. +- `WorldView.tsx` empty state mentions "Run demo mission". Add a line about the plugin. + +### 7.6 Long sessions + +- `connect.ts` loads the whole backlog with `getAllEvents`, and `ReplayIndex` keeps every event in memory with a snapshot every 50 events (`SNAPSHOT_EVERY` in `replay-index.ts`). That is fine for thousands of events. +- Replay time is wall-clock, so idle hours leave the scrub bar mostly empty. Compressing idle gaps is v2. It has to stay consistent with `eventTick`, which uses raw timestamps. + +### 7.7 Transcript-derived extras (deferred) + +- Model: `SessionStart.model` (optional) only. Model switches are v1.1, using the documented `from_model` and `to_model` on `PreModelSwitch` and `PostModelSwitch`. +- Final text: `last_assistant_message` on Stop and SubagentStop. +- Tokens and cost: no documented hook fields. The transcript format is internal and may change between versions (documented). Deferred until a documented source exists. + +## 8. Plugin package + +``` +.claude-plugin/marketplace.json # repo root +integrations/claude-code-plugin/ + .claude-plugin/plugin.json + hooks/hooks.json + scripts/runloop-hook.mjs # ~250 lines, Node built-ins only, // @ts-check + test/ # unit tests, redaction parity test + package.json # private; typecheck and test only + README.md +``` + +**Self-contained plugin.** A component path must resolve inside the plugin root (documented), and a marketplace install may copy only the plugin directory. The script therefore cannot import from `packages/`. Redaction patterns are copied and checked by a parity test. + +**Workspace wiring.** Add `integrations/*` to `pnpm-workspace.yaml` (today `apps/*` and `packages/*`) and to `projects` in `vitest.config.ts` (today `packages/*` and `apps/studio`). + +**`plugin.json`** +```json +{ + "name": "runloop-tracer", + "version": "0.1.0", + "description": "Record and visualise Claude Code sessions in a local Runloop world. Observe-only.", + "license": "Apache-2.0", + "userConfig": { + "runtime_url": { "type": "string", "title": "Runloop runtime URL", "description": "Loopback only", "default": "http://127.0.0.1:4317" }, + "enabled": { "type": "boolean", "title": "Trace sessions", "description": "Turn tracing off without uninstalling", "default": true }, + "trace_prompts":{ "type": "boolean", "title": "Record prompt text", "description": "Stored as sensitive", "default": true } + } +} +``` +The `version` pins the installed plugin until it changes, so bump it on every release (documented). Option values reach hooks as `CLAUDE_PLUGIN_OPTION_RUNTIME_URL`, `CLAUDE_PLUGIN_OPTION_ENABLED` and so on (documented). + +**`hooks/hooks.json`**: exec form, as documented. Every event uses the same handler shape, shown once here: +```json +{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/runloop-hook.mjs"], "async": true, "timeout": 5 } +``` +- Async (one entry each): SessionStart, UserPromptSubmit, PreToolUse (`*`), PermissionRequest (`*`), PermissionDenied (`*`), PostToolUse (`*`), PostToolUseFailure (`*`), SubagentStart, SubagentStop, Stop, StopFailure, Notification (matcher `idle_prompt|permission_prompt`), PreCompact, PostCompact. +- SessionEnd, synchronous: `"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/runloop-hook.mjs", "--sync"]`, `"timeout": 2`, no `async`. +- The hook takes its ppid and event name from its own input, so one script serves every event. + +**Script steps** +1. Read stdin up to 64 MiB, then `JSON.parse`. On any failure, record the error and exit 0. +2. Exit 0 if tracing is disabled (`RUNLOOP_TRACE=0` or `enabled` false). +3. Slim the payload, compute `derived` sizes, and redact (§6.8). +4. Build the envelope with `deliveryId` (UUID), `observedAt`, and `ppid`. +5. Write the spool file (`${CLAUDE_PLUGIN_DATA}/spool//-.json`) atomically with tmp-then-rename. +6. Read the token; refuse non-loopback URLs. +7. POST with `AbortSignal.timeout` (1 s; 800 ms with `--sync`). +8. On 2xx: delete the own spool file, then POST up to 20 older spool entries in one batch, oldest first, across all sessions. On network failure or 5xx: keep the file. On 400 or 413: drop it and record in `last-error.json`. On 401 or 403: keep it and record. +9. Exit 0. Never write stdout. + +`--doctor` runs the same token and reachability checks and reads `last-error.json` and the spool. It also flushes the spool when the runtime is reachable. + +**Configuration** (precedence: env, then plugin option, then default) + +| Setting | Env | Plugin option | Default | +|---|---|---|---| +| Runtime URL | `RUNLOOP_URL` | `runtime_url` | `http://127.0.0.1:4317` (loopback only; anything else is refused) | +| Token file | `RUNLOOP_INGEST_TOKEN_FILE` | none | per-user path (D6) | +| Enabled | `RUNLOOP_TRACE=0` | `enabled` | on | +| Prompt text | `RUNLOOP_TRACE_PROMPTS=0` | `trace_prompts` | on (sensitive) | +| Debug | `RUNLOOP_TRACE_DEBUG=1` | none | off | +| Idle close (runtime) | `RUNLOOP_TRACE_IDLE_MINUTES` | none | 120 | +| Kill switch (runtime) | `RUNLOOP_TRACE=off` | none | on | + +**Commands** +- `claude --plugin-dir `; then `/reload-plugins`. +- `claude plugin validate `; add `--strict` in CI. +- `claude plugin install runloop-tracer@runloop --scope user --config trace_prompts=true`. +- `claude plugin disable runloop-tracer` to turn it off. +- `${CLAUDE_PLUGIN_DATA}` is deleted when the plugin is uninstalled from its last scope unless `--keep-data` is passed (documented). The spool goes with it, which is acceptable. + +## 9. Privacy and security + +- **Local only.** The plugin refuses non-loopback URLs. The runtime binds `127.0.0.1` and keeps its Host guard. +- **Authentication.** The token file is per user (mode 0600), compared in constant time. Requests with an Origin header are rejected, so web pages cannot forge sessions. Body, field and rate limits bound what a misbehaving local process can write. Same-user processes can read the token, which is the same trust boundary as the existing database and GET routes. +- **Read exposure is unchanged.** GET routes and SSE are unauthenticated for local processes (`app.ts`). Traced runs contain prompts and commands, so the README must say so. `RUNLOOP_READ_TOKEN` is v2. +- **Minimisation.** Summaries only. No `tool_response` bodies, Write contents, or Edit strings. No absolute paths. `transcript_path` is never stored. +- **Two-sided redaction.** Parity-tested shared patterns, Bash-specific secret patterns, prompt text marked `sensitive`, and `RUNLOOP_TRACE_PROMPTS=0` to skip prompt text. +- **Managed policy.** `allowManagedHooksOnly` blocks user, project, local and plugin hooks (documented). In such an environment the plugin records nothing. `--doctor` reports "no events received" and names this cause. `disableAllHooks` is documented for settings-level hooks; whether it also silences plugin hooks is **[unverified]** (M0). +- **Trust.** Plugin hooks run as the user with the user's permissions. Install only trusted plugins. The plugin runs no commands; it only writes files and POSTs to loopback. +- **Opt-out.** `RUNLOOP_TRACE=0` per shell, `claude plugin disable runloop-tracer`, or uninstall. +- **Deletion.** No API in v1. Delete `runloop.db`, the artifacts directory, or the spool directory. A `runs rm` command is v2. +- **Honesty.** Provenance is stored on every traced event. The UI never presents traced results as verified. + +## 10. Testing strategy + +1. **Fixtures.** Raw payloads from the M0 probe, sanitised, in `packages/runtime/test/fixtures/claude-code/.jsonl`. Scenarios: + - basic turn; + - parallel reads; + - two parallel subagents of the same type; + - permission approved; + - permission denied (no hook in default mode); + - auto-mode denial (PermissionDenied); + - Esc interrupt; + - Bash test failure; + - compaction; + - /clear; + - resume with the same session id; + - no SessionEnd; + - PostToolUse arriving before PreToolUse; + - background subagent finishing after Stop; + - implicit turn from background work; + - two processes on one session (ppid change). +2. **Mapper golden tests** (`mapper.test.ts`). Fold each fixture through `mapHook`, record into a `:memory:` Recorder, and render with `TracePrinter` to a golden file. Invariants: `foldExecution` gives no anomalies beyond the documented ones; every `tool.call_started` has exactly one terminal event after SessionEnd or the sweep; `reduceWorld` does not throw. +3. **Summarize and ids.** Table tests for categories, the classifier, path relativisation, truncation and redaction. +4. **Ingest tests** (`test/ingest.test.ts`, in the style of `http.test.ts`): + - 401 without a valid token; 403 with an Origin; 413; 429; 400 for unknown `v`; + - duplicate deliveries and keys counted, not recorded; + - concurrent sessions in separate runs with contiguous sequences; + - scripted and traced runs coexisting; control routes return 409 for traced runs; + - restart closes traced runs with `unobserved`, not `runtime_restarted`; + - the idle sweep closes runs; + - the recorder rejects `timestamp` and `provenance` on executed runs; + - `recordBatch` rolls back the whole envelope on an invalid draft. +5. **Layers.** + - core: `lastCategory`, `run.marker`, traced `run.started` without seed; + - world-engine: `toolCategory` rules, sticky category, station-kind change detection, demo presets unchanged; + - studio: view labels and incident placement by category. +6. **End to end** (`packages/runtime/test/claude-code-plugin.e2e.test.ts`). Start `createRuntime` on port 0. For each fixture line, spawn `node integrations/claude-code-plugin/scripts/runloop-hook.mjs` with the line on stdin, `RUNLOOP_URL`, a temp token file, and a temp `CLAUDE_PLUGIN_DATA`. Assert: + - the golden output matches; + - exit 0 and empty stdout for every invocation, including garbage stdin, a bad token, and an unreachable URL; + - each invocation finishes under 1.5 s; + - offline, then spool written, then runtime up, then the next hook flushes, giving the same golden output. +7. **Parity.** The plugin's pattern list equals `REDACTION_PATTERN_SOURCES`. +8. **Manual checklist with real Claude Code** (M3 and M5): + - install with `--plugin-dir`, then from the marketplace; + - start a session before the runtime, start the runtime, then send a prompt; + - parallel Read and Grep; two parallel Agent subagents; + - approve one permission and deny another; confirm the denied call closes at Stop; + - Esc mid-Bash and Esc mid-response (does Stop fire?); + - /compact, /clear, `--resume` (same session id), `/branch` (new id); + - a background subagent that finishes after the turn; + - exit normally; kill -9 Claude Code; + - two terminals on one session (marker); + - restart the runtime mid-session (run splits, continuation opens); + - `claude -p` is traced; `claude --bare` is not; + - a managed-policy environment where `--doctor` reports no events; + - no hook error notices in the transcript; check latency with the probe's timing logs; + - Windows PowerShell smoke test: `node` on PATH, paths with spaces, token file location. + +## 11. Milestones + +| # | Scope | Acceptance | Effort | +|---|---|---|---| +| M0 | Probe plugin logs raw stdin and timing for every registered event; run the §10 scenarios; answer the M0 questions in an appendix to this doc | Fixtures committed (sanitised); every **[unverified]** item in §12 answered | 1 d | +| M1 | Schema and layers: events (§5), core (`lastCategory`, markers, run fields), world-engine (`toolCategory`, station-kind compare, preset), migration `[1]`, recorder guards and `recordBatch` | `pnpm test` and `pnpm typecheck` green; demo golden traces unchanged | 2 d | +| M2 | Mapper, summarize, ids, goldens | Fixtures give no unexpected anomalies; late-tolerant goldens pass | 2–3 d | +| M3 | Ingest route, token, `TraceSessionManager` (synchronous projection, dedupe), plugin script (no spool yet), `hooks.json` | A real session appears live in Studio; E2E green; no hook error notices; latency measured | 2 d | +| M4 | Spool and flush, restart policy, idle sweep, continuation segments | Offline-start and restart scenarios produce complete runs | 1.5 d | +| M5 | Studio: badge, Source panel, inferred chips, Live sessions, follow newest traced run, labels, incident placement, subagent cap | Manual checklist passes; view tests green | 2–3 d | +| M6 | Packaging: `marketplace.json`, README, `--doctor`, `claude plugin validate .`, root and runtime docs | Fresh-machine install from the marketplace works end to end | 1 d | +| M7 (v1.1+) | Reorder hold (if measured), `openCallIds`, rehydrate across restarts, model markers, subagent homes and palette, token rotate command | Each behind a flag, with tests | 2–4 d | + +Core v1 (M0–M6): about 11–14 working days. + +## 12. Risks and open questions + +| Risk or question | Status | Mitigation | +|---|---|---| +| Hook payloads change between Claude Code versions | Ongoing | Loose schemas; unknown events and fields ignored and counted; envelope `v` and plugin version; re-run M0 after each major release | +| **[M0]** Do tool events inside subagents carry `agent_id` and `agent_type`? | Partly documented | Fallback attribution (§4). This decides whether subagents get their own robots | +| **[M0]** Do async command hooks finish for every event, including SessionStart, PermissionRequest and Stop, when Claude Code exits right after? | Unverified | Spool before send; any later hook flushes. M0 measures loss at exit | +| **[M0]** Can an async PreToolUse block a call (exit 2 or decision JSON)? Expected: no | Partly documented | Probe. The design assumes it cannot | +| **[M0]** SessionEnd budget with a `hooks.json` timeout of 2 | Unverified | Design for 1.5 s; spool before the POST | +| **[M0]** Does PostToolUse carry `tool_use_id` and `tool_input`? Does PermissionRequest carry no `tool_use_id`? | Unverified | Fallback match by agent, tool and input hash (inferred) | +| **[M0]** Does a user denial fire any hook in default mode? | Partly (PermissionDenied is auto-mode only) | Inferred close at the next Stop, prompt, SubagentStop, SessionEnd or sweep | +| **[M0]** Does Stop fire on Esc? Does `ppid` equal the Claude Code pid? | Unverified | If not, the idle sweep alone handles it | +| **[M0]** SessionStart source values `startup`, `clear`, `compact`; SessionEnd reasons | Partly | Markers for unknown values | +| **[M0]** Does the build in use print a hook duration line under `--debug`? | Unverified | Timing from the probe's own logs | +| `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` | Not in the env-vars reference | Not used | +| Node startup cost under heavy parallelism | Measure in M3 | Async keeps it off the critical path; a long-lived relay is v2 if needed | +| Windows: `node` not on the PATH that Claude Code sees; path handling | Open | Exec form; `--doctor` check; PowerShell smoke test; forward-slash paths documented | +| Robots thrash with many subagents or fast tool bursts | Open | Coarse categories, sticky category, cap of 8 subagents; dwell and hysteresis in v2 | +| SQLite growth in long sessions | Open | Summary capture; `trace_ingest` pruning and `runs rm` in v2 | +| Two runtimes or a non-default port | Open | `RUNLOOP_URL` plus the shared per-user token; `--doctor` reports reachability | +| Managed policy with `allowManagedHooksOnly` | Documented | `--doctor` explains "no events" | +| Other local processes can read traced data | Documented | README; `RUNLOOP_READ_TOKEN` in v2 | +| Plugin installed twice double-sends | Open | Semantic keys dedupe most events; document "install once" | +| Background subagents outlive their turn | Documented behaviour | Late events attach to the delegate task | +| `claude -p` and Agent SDK sessions are traced too | Inferred from `--bare` (documented) | v1 traces them; CI disables the plugin or sets `RUNLOOP_TRACE=0` | + +## Appendix A: Verified facts and sources + +**Hooks** (https://code.claude.com/docs/en/hooks) +- Verified: 33 events, including SubagentStart and SubagentStop, PermissionDenied (auto mode), and PreModelSwitch and PostModelSwitch. +- Verified: common fields are `session_id`, `prompt_id` (absent until the first input), `transcript_path`, `cwd`, `scratchpad_dir`, `permission_mode`, `effort`, `hook_event_name`. `agent_type` is present when the hook fires inside a subagent or with `--agent`. `agent_id` is listed in a separate table. +- Verified: SessionStart fields are `source`, `model` (optional), `agent_type`, `session_title`. SessionStart supports only `command` and `mcp_tool` handlers. +- Verified: PreToolUse fields are `tool_name`, `tool_input`, `tool_use_id`. Stop and SubagentStop provide `last_assistant_message`, which the docs recommend over reading the transcript. PreModelSwitch and PostModelSwitch provide `from_model` and `to_model`. +- Verified: exit 2 blocks on PreToolUse, Stop, SubagentStop and others. It is not honoured for PostToolUse, PostToolUseFailure, PermissionRequest, SessionStart, SubagentStart and SessionEnd. Plain stdout reaches Claude only for UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch. +- Verified: `async: true` runs in the background without blocking. Its timeout is not enforced. Background responses are ignored for PostToolUse ("Synchronous responses only"). `asyncRewake` wakes Claude on exit 2. +- Verified: default timeouts are 600 s for command, http and mcp_tool; 30 s for prompt; 60 s for agent; UserPromptSubmit defaults to 30 s. SessionEnd has a 1.5 s budget that a longer per-hook `timeout` in settings raises up to 60 s. +- Verified: exec form takes `command` and `args`. `${CLAUDE_PLUGIN_ROOT}` is substituted in `command` and in each `args` element. `shell` defaults to bash, or powershell on Windows when Git Bash is absent, and is ignored when `args` is set. +- Verified: matcher rules. `*`, empty or omitted match all. Plain identifiers match exactly, lists separated by `|` or `,` match exactly, anything else is a regex. For SubagentStart and SubagentStop the matcher is the agent type. +- Verified: `onFailure` applies to command and http hooks, defaults to `continue`, and `block` requires v2.1.295 or later. +- Verified: Notification matcher values include `permission_prompt` and `idle_prompt`. +- Verified: `allowManagedHooksOnly` blocks user, project, local and plugin hooks. `disableAllHooks` semantics for plugin hooks are unverified. +- Unverified (research step only, not in the pages read): SessionEnd reason values; SubagentStop `agent_transcript_path`; PreCompact and PostCompact fields; PermissionRequest and PermissionDenied fields; Stop's `stop_hook_active`; PostToolUseFailure `is_interrupt`; UserPromptSubmit `prompt`; PostToolUse `tool_use_id`. + +**Sub-agents** (https://code.claude.com/docs/en/sub-agents) +- Verified: hooks from settings, managed settings and plugins all apply inside subagents. +- Verified: plugin subagents ignore the `hooks` frontmatter field; put hooks in `hooks/hooks.json`. +- Verified: SubagentStart and SubagentStop matchers take the agent name, or a plugin-scoped name with a colon, which is treated as a regex. + +**Plugins** (https://code.claude.com/docs/en/plugins/manifest-reference; https://code.claude.com/docs/en/plugins/cli-reference; https://code.claude.com/docs/en/plugins/marketplace-reference) +- Verified: only `name` is required in `plugin.json`. Hooks live in `hooks/hooks.json` under a top-level `"hooks"` key. `settings.json` applies only `agent` and `subagentStatusLine`. +- Verified: `CLAUDE_PLUGIN_ROOT` changes on update. `CLAUDE_PLUGIN_DATA` is `~/.claude/plugins/data//`, kept across updates, and deleted on uninstall from the last scope unless kept. `CLAUDE_PROJECT_DIR` is set. `CLAUDE_PLUGIN_OPTION_` is exported to hook processes for each option. +- Verified: `userConfig` types are `string`, `number`, `boolean`, `directory` and `file`. `sensitive` and `options` (v2.1.271 or later) are supported. `${user_config.KEY}` is substituted in exec-form `args`. +- Verified: component paths must resolve inside the plugin root. A `version` pins the plugin until it changes. +- Verified: `claude plugin validate ` with `--strict`; `claude plugin install --scope user|project|local --config KEY=VALUE`; `claude plugin disable `; `/reload-plugins`; `claude --plugin-dir `. +- Verified: the marketplace file lives at `.claude-plugin/marketplace.json` in the marketplace root. A relative `./` source resolves when the marketplace comes from GitHub, git, file or directory. `owner/repo` is accepted by `marketplace add`. `runloop` is not a reserved name. + +**Sessions and CLI** (https://code.claude.com/docs/en/sessions; https://code.claude.com/docs/en/cli-reference) +- Verified: transcripts are at `~/.claude/projects//.jsonl`, where `` is the working directory with non-alphanumeric characters replaced by `-`. The entry format is internal and may change between versions. +- Verified: `--resume` restores history. A tool still running when the previous process ended is shown as cut off and is not re-run. Background subagents and Bash commands that ended show as notes. +- Verified: `--fork-session` creates a new session ID "instead of reusing the original", so resume reuses the ID by default. +- Verified: `/clear` saves the previous conversation and starts a fresh one. `/branch` and `--fork-session` get new IDs. +- Verified: resuming one session in two terminals without forking interleaves the transcript. +- Verified: `--bare` and `--safe-mode` skip plugins and hooks. `--include-hook-events` works with stream-json output. `--debug-file ` and `--debug[=filter]` exist. `--init-only` runs SessionStart hooks, then exits. +- Verified: `CLAUDE_CODE_SKIP_PROMPT_HISTORY`, `CLAUDE_CODE_RESUME_INTERRUPTED_TURN`, `CLAUDE_CODE_TRANSCRIPT_LOCAL_GC`, `CLAUDE_CODE_PROJECT_DIR_NAME`. +- Not found: `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS`, `CLAUDE_EFFORT` (use the hook's `effort` field). Not used. +- Unverified: `~/.claude/debug/.txt` as the debug log location; subagent transcript paths; `ppid` semantics. + +**Tools** (https://code.claude.com/docs/en/tools-reference) +- Verified names: `Agent` (subagent), `Bash`, `PowerShell`, `Monitor`, `Read`, `Write`, `Edit`, `NotebookEdit`, `Glob`, `Grep`, `WebFetch`, `WebSearch`, `EnterPlanMode`, `ExitPlanMode`, `TaskCreate`, `TaskGet`, `TaskList`, `TaskUpdate`, `TaskStop`, `TodoWrite` (off by default in favour of the Task tools). +- Not listed: `LS`, `MultiEdit`, `NotebookRead`, `BashOutput`, `KillShell`. `TaskOutput` was removed in v2.1.277. +- Unverified: the `Agent` input field names (`subagent_type`, `description`). + +**Environment** (https://code.claude.com/docs/en/env-vars) +- Verified: `CLAUDE_CODE_DEBUG_LOG_LEVEL` (values verbose, debug, info, warn, error); `CLAUDE_CODE_EFFORT_LEVEL`. No documented variable disables hooks. + +**Repo (checked 2026-10-17)** +- `packages/runtime/src/recorder.ts`: `build()` sets timestamp from the runtime clock and builds the envelope field by field; `createRun` throws on an existing id; `recoverInterrupted` has no mode filter and writes `runtime_restarted`. +- `packages/runtime/src/run-manager.ts`: one active slot; `RunConflictError` (409); `requireActive` returns 409 or 404. +- `packages/runtime/src/http/app.ts`: Host guard; `/api/*` CORS and JSON check; Origin check on POST; no body limit. +- `packages/runtime/src/cli.ts`: `runtime.pid` lock; commands are `serve` and `demo` only. +- `packages/runtime/src/store/migrations.ts`: one migration. `store/db.ts`: WAL, `synchronous = FULL`, foreign keys on, migrations in transactions, `tx()` not re-entrant. +- `packages/events/src/payloads.ts`: the required fields listed in §5; `AdapterInfo.kind` is `scripted | llm`. +- `packages/events/src/parse.ts`: `parseKnownEvent` rejects unknown types. `redact.ts`: pattern list is not exported. `protocol.ts`: port 4317, host 127.0.0.1. +- `packages/runtime/src/adapter.ts`: `run.*`, `tool.*`, `artifact.*` are runtime-only. +- `packages/core/src/reduce-execution.ts`: `finishCall` and the exhaustive handler map. `packages/world-engine/src/intent.ts`: priority order. `reduce-world.ts`: `updateIntents` compares activity and task only, `homeFor` BFS, `reserve` overflow queuing. `definition.ts`: `ActivityRuleSchema` has no category yet. +- `worlds/research-campus/world.json`: activity rules begin at about line 171 with the permission rule; `defaultPreset` is `operator`; stations are library×2, build×2, test×2, ops×1, gate×1. +- Studio: `components/Sidebar.tsx` (demo button disabled when `activeRunId` is set, line 54); `state/hooks.ts` (line 49); `state/view.ts` (`KIND_LABEL`); `scene/layout.ts` (`incidentStationId` uses task kind only, lines 240–255); `components/AgentFlow.tsx` (coordinator wording); `components/WorldView.tsx` (demo copy); `state/connect.ts` (`getAllEvents`); `scene/Character.tsx` (the draft's `components/Character.tsx` path is wrong); `state/timeline.ts`, `components/MetadataPanel.tsx`, `components/LogsPanel.tsx`, `components/ReplayBar.tsx`, `components/RunHeader.tsx` all exist. +- Root `package.json`: `engines.node` is `>=22.16`; `packageManager` is `pnpm@10.28.0`. `pnpm-workspace.yaml` lists `apps/*` and `packages/*`. `vitest.config.ts` lists `packages/*` and `apps/studio`. From d5b20fc39c1f0e637c30191ff6b70e599d6f4115 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:36:49 +0000 Subject: [PATCH 12/18] Integrate end to end and fix review findings Integration: studio, runtime and engine wired together and verified with a live Playwright run (live view, failure, replay, restart persistence). Review fixes (18 of 19 findings; 235 tests): - Characters of stopped or interrupted runs no longer stay 'Working'. - Route starts use fractional event ticks, so walkers never jump. - 'Succeeded' only on the completing event; blocked tasks show blocked. - Invalid envelopes become anomalies instead of stalling the stream. - Boot recovery stamps events at the run's last activity. - Artifact reads verify sha256; tests are documented as unisolated. - Graceful shutdown delivers the final events to SSE clients. - Non-loopback clients and hosts are refused. - Scrubber reaches the last event; timeline follows seeks; effects do not pile up in background tabs; fewer per-frame re-renders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- apps/studio/next.config.ts | 2 - apps/studio/src/components/AgentDetails.tsx | 13 ++- .../studio/src/components/CharacterRoster.tsx | 8 +- .../studio/src/components/CharacterStatus.tsx | 33 +++++++ apps/studio/src/components/ReplayBar.tsx | 12 ++- apps/studio/src/components/TimelinePanel.tsx | 14 ++- apps/studio/src/components/WorldView.tsx | 7 +- apps/studio/src/lib/config.ts | 7 +- apps/studio/src/scene/Character.tsx | 11 ++- apps/studio/src/scene/Effects.tsx | 56 +++++++---- apps/studio/src/scene/labels.tsx | 60 ++++++++++-- apps/studio/src/scene/scene.module.css | 3 +- apps/studio/src/state/store.test.ts | 30 ++++++ apps/studio/src/state/store.ts | 23 ++++- apps/studio/src/state/view.test.ts | 17 +++- apps/studio/src/state/view.ts | 22 ++++- package.json | 2 +- packages/core/src/format.test.ts | 13 +++ packages/core/src/format.ts | 10 +- packages/core/src/selectors.test.ts | 2 +- packages/core/src/selectors.ts | 7 +- packages/core/src/types.ts | 3 +- packages/events/src/event.ts | 9 +- packages/events/src/parse.test.ts | 34 ++++++- packages/events/src/parse.ts | 46 ++++++++- packages/replay/src/replay.test.ts | 21 ++++- packages/runtime/README.md | 10 +- packages/runtime/src/cli.ts | 25 ++++- packages/runtime/src/http/app.ts | 32 +++++-- packages/runtime/src/http/sse.ts | 3 + packages/runtime/src/index.ts | 2 +- packages/runtime/src/recorder.ts | 87 +++++++++++++---- packages/runtime/src/run-manager.ts | 8 +- packages/runtime/src/sandbox/command.ts | 8 +- packages/runtime/src/trace-printer.ts | 93 ++++--------------- packages/runtime/test/helpers.ts | 2 +- packages/runtime/test/http.test.ts | 33 ++++++- packages/runtime/test/recorder.test.ts | 48 +++++++++- packages/runtime/test/run-manager.test.ts | 65 +++++++++++++ packages/runtime/test/trace-printer.test.ts | 18 +++- packages/world-engine/src/intent.ts | 17 +++- packages/world-engine/src/motion.test.ts | 14 ++- packages/world-engine/src/motion.ts | 18 +++- .../world-engine/src/reduce-world.test.ts | 71 +++++++++++++- packages/world-engine/src/reduce-world.ts | 6 +- packages/world-engine/src/test-utils.ts | 6 +- worlds/research-campus/world.json | 2 +- 47 files changed, 824 insertions(+), 209 deletions(-) create mode 100644 apps/studio/src/components/CharacterStatus.tsx create mode 100644 packages/runtime/test/run-manager.test.ts diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index 240aaa5..a1ac41c 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -3,8 +3,6 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'export', - // Lets a second dev server (e.g. a scene preview) run from the same folder. - distDir: process.env.NEXT_DIST_DIR ?? '.next', reactStrictMode: true, // Don't (re)generate an AGENTS.md file on `next dev`. agentRules: false, diff --git a/apps/studio/src/components/AgentDetails.tsx b/apps/studio/src/components/AgentDetails.tsx index 7073b30..7451586 100644 --- a/apps/studio/src/components/AgentDetails.tsx +++ b/apps/studio/src/components/AgentDetails.tsx @@ -3,8 +3,9 @@ import { selectRecentActions, selectTaskProgress, type ExecState } from '@runloo import { useMemo, type CSSProperties } from 'react'; import { useStudio, useStudioStore } from '../state/context'; import { formatDuration } from '../state/timeline'; -import { agentLook, callStatusView, characterStatusView, KIND_LABEL, taskStatusView } from '../state/view'; +import { agentLook, callStatusView, KIND_LABEL, runHasEnded, taskStatusView } from '../state/view'; import styles from './AgentDetails.module.css'; +import CharacterStatus from './CharacterStatus'; import StatusBadge from './StatusBadge'; /** Details of the selected agent at the playhead (or a task overview when none is selected). */ @@ -55,8 +56,10 @@ export default function AgentDetails() {

- {character && } - state: {agent.state} + {character && } + + {runHasEnded(exec) ? 'last state' : 'state'}: {agent.state} + {station && at {station.label}}

{agent.stateReason &&

{agent.stateReason}

} @@ -78,7 +81,7 @@ export default function AgentDetails() {
{task.title} - +
{task.id} · {KIND_LABEL[task.kind]} · attempt {Math.max(1, task.attempts)} @@ -197,7 +200,7 @@ function TaskOverview({ exec }: { exec: ExecState }) { {t.id} · {t.title} - + {KIND_LABEL[t.kind]} diff --git a/apps/studio/src/components/CharacterRoster.tsx b/apps/studio/src/components/CharacterRoster.tsx index 8074a56..57a621f 100644 --- a/apps/studio/src/components/CharacterRoster.tsx +++ b/apps/studio/src/components/CharacterRoster.tsx @@ -2,9 +2,9 @@ import { selectEmbodiedAgents } from '@runloop-world/core'; import { useMemo, type CSSProperties } from 'react'; import { useStudio, useStudioStore } from '../state/context'; -import { agentLook, characterStatusView } from '../state/view'; +import { agentLook } from '../state/view'; +import CharacterStatus from './CharacterStatus'; import styles from './WorldView.module.css'; -import StatusBadge from './StatusBadge'; /** Keyboard-reachable list of characters over the canvas (mirrors clicking them in 3D). */ export default function CharacterRoster() { @@ -20,8 +20,6 @@ export default function CharacterRoster() { return (
{agents.map((a) => { - const c = state.world.characters[a.id]; - const view = characterStatusView(state.world.paused ? 'paused' : (c?.activity ?? 'idle')); const isSelected = selected === a.id; return ( ); })} diff --git a/apps/studio/src/components/CharacterStatus.tsx b/apps/studio/src/components/CharacterStatus.tsx new file mode 100644 index 0000000..fdcae1b --- /dev/null +++ b/apps/studio/src/components/CharacterStatus.tsx @@ -0,0 +1,33 @@ +'use client'; +import { presentationAt } from '@runloop-world/world-engine'; +import { useSyncExternalStore } from 'react'; +import { useStudio, useStudioStore } from '../state/context'; +import { useTicker } from '../state/hooks'; +import { characterStatusView } from '../state/view'; +import StatusBadge from './StatusBadge'; + +/** + * A character's state exactly as its 3D badge shows it at the playhead + * (Layer-2 state → presentationAt at the frame's tick), so DOM panels and + * the scene never disagree. Re-renders while the playhead moves. + */ +export default function CharacterStatus({ agentId }: { agentId: string }) { + const store = useStudioStore(); + const world = useStudio((s) => s.world); + const frame = store.getState().getFrame(); + // Live: the playhead follows wall time. Replay: re-render on scrubs, at tick resolution. + useTicker(frame.advancing, 200); + useSyncExternalStore( + store.subscribe, + () => { + const s = store.getState(); + return s.cursor.mode === 'replay' ? Math.round(s.cursor.tMs / 100) : s.playheadSeq; + }, + () => 0, + ); + + const c = frame.state.world.characters[agentId]; + if (!c || !world) return null; + const pose = presentationAt(c, frame.tick, world, { paused: frame.state.world.paused }); + return ; +} diff --git a/apps/studio/src/components/ReplayBar.tsx b/apps/studio/src/components/ReplayBar.tsx index 287f6fe..e8a37a9 100644 --- a/apps/studio/src/components/ReplayBar.tsx +++ b/apps/studio/src/components/ReplayBar.tsx @@ -10,7 +10,11 @@ import styles from './ReplayBar.module.css'; /** Transport controls: prev / play / next, speed, time scrubber, live toggle. */ export default function ReplayBar() { const store = useStudioStore(); - const cursor = useStudio((s) => s.cursor); + // Not the whole cursor: replay playback replaces it every animation frame. + const live = useStudio((s) => s.cursor.mode === 'live'); + const cursorPlaying = useStudio((s) => s.cursor.mode === 'replay' && s.cursor.playing); + useStudio((s) => (s.cursor.mode === 'replay' ? Math.floor(s.cursor.tMs / 100) : -1)); // re-render per 100 ms of playhead + useStudio((s) => s.playheadSeq); const runId = useStudio((s) => s.runId); const ended = useStudio((s) => s.ended); const settleMs = useStudio((s) => s.settleMs); @@ -19,7 +23,6 @@ export default function ReplayBar() { const speed = useStudio((s) => s.speed); const s = store.getState(); - const live = cursor.mode === 'live'; const t = s.playheadMs(); const end = s.endMs(); const settled = ended && t >= settleMs; @@ -27,7 +30,7 @@ export default function ReplayBar() { useTicker(live && runId !== null && !settled, 250); const disabled = runId === null || lastSequence === 0; - const playing = !disabled && (live ? !settled : cursor.playing); + const playing = !disabled && (live ? !settled : cursorPlaying); const incidents = useMemo(() => { const st = store.getState(); @@ -69,7 +72,8 @@ export default function ReplayBar() { type="range" className={styles.range} min={0} - max={Math.max(1, Math.round(end))} + // Rounded up to a whole step so the last event is reachable (seek clamps to the real end). + max={Math.max(100, Math.ceil(end / 100) * 100)} step={100} value={Math.round(t)} disabled={disabled} diff --git a/apps/studio/src/components/TimelinePanel.tsx b/apps/studio/src/components/TimelinePanel.tsx index f73dbd7..2690b47 100644 --- a/apps/studio/src/components/TimelinePanel.tsx +++ b/apps/studio/src/components/TimelinePanel.tsx @@ -33,8 +33,18 @@ export default function TimelinePanel() { } useEffect(() => { - if (!follow || current < 0) return; - listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }); + const list = listRef.current; + const row = current >= 0 ? list?.querySelector('[data-current="true"]') : null; + if (!list || !row) return; + if (follow) { + row.scrollIntoView({ block: 'nearest' }); + return; + } + // A seek while paused (scrub, ←/→, incident marker, "Move playhead here"): + // show the row if it is off-screen; a click on a visible row does not scroll. + const l = list.getBoundingClientRect(); + const r = row.getBoundingClientRect(); + if (r.top < l.top || r.bottom > l.bottom) row.scrollIntoView({ block: 'center' }); }, [current, follow]); const onRow = (row: TimelineRow) => { diff --git a/apps/studio/src/components/WorldView.tsx b/apps/studio/src/components/WorldView.tsx index bf8b1a7..f3d402c 100644 --- a/apps/studio/src/components/WorldView.tsx +++ b/apps/studio/src/components/WorldView.tsx @@ -41,9 +41,10 @@ export default function WorldView() { const reducedMotion = useStudio((s) => s.reducedMotion); const [webgl, setWebgl] = useState<'checking' | 'ok' | 'unavailable'>('checking'); - const [ready, setReady] = useState(false); + // The world whose canvas has rendered: a new run's world is not ready until its own canvas says so. + const [readyWorld, setReadyWorld] = useState(null); useEffect(() => setWebgl(webglAvailable() ? 'ok' : 'unavailable'), []); - const onReady = useCallback(() => setReady(true), []); + const onReady = useCallback(() => setReadyWorld(world), [world]); let body: ReactNode; if (loadError) { @@ -108,7 +109,7 @@ export default function WorldView() { } return ( -
+
{body}
{world && ( diff --git a/apps/studio/src/lib/config.ts b/apps/studio/src/lib/config.ts index c2c3145..e88b5fa 100644 --- a/apps/studio/src/lib/config.ts +++ b/apps/studio/src/lib/config.ts @@ -4,5 +4,8 @@ export const API_BASE = (process.env.NEXT_PUBLIC_RUNLOOP_API ?? 'http://127.0.0. /** Mission started by the "Run demo mission" button. */ export const DEMO_MISSION_ID = 'coding-mission'; -/** Command shown when the runtime is offline. */ -export const START_RUNTIME_COMMAND = 'pnpm dev'; +/** + * Command shown when the runtime is offline. Runtime only: when this hint is + * visible the Studio is already being served, so `pnpm dev` would hit EADDRINUSE. + */ +export const START_RUNTIME_COMMAND = 'pnpm serve'; diff --git a/apps/studio/src/scene/Character.tsx b/apps/studio/src/scene/Character.tsx index 9d8e18c..9698150 100644 --- a/apps/studio/src/scene/Character.tsx +++ b/apps/studio/src/scene/Character.tsx @@ -1,6 +1,6 @@ 'use client'; import { RoundedBox } from '@react-three/drei'; -import { useFrame, type ThreeEvent } from '@react-three/fiber'; +import { flushSync, useFrame, type ThreeEvent } from '@react-three/fiber'; import type { PresentationState } from '@runloop-world/world-engine'; import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Color, MathUtils, MeshBasicMaterial, type Group, type Mesh, type Vector3 } from 'three'; @@ -102,11 +102,14 @@ export const Character = memo(function Character({ } g.rotation.y = yaw.current; - // React only hears about badge changes. + // React only hears about badge changes. They are rare, so commit them + // synchronously: the badge then paints in the same frame as this pose + // (otherwise it trails by a frame or more on slow renderers). const detail = badgeDetail(pose.state, agentId, frame.state, world); if (pose.state !== viewRef.current.state || detail !== viewRef.current.detail) { - viewRef.current = { state: pose.state, detail }; - setView(viewRef.current); + const next = { state: pose.state, detail }; + viewRef.current = next; + flushSync(() => setView(next)); } // Layer 3 decoration. diff --git a/apps/studio/src/scene/Effects.tsx b/apps/studio/src/scene/Effects.tsx index 6b48253..54180b1 100644 --- a/apps/studio/src/scene/Effects.tsx +++ b/apps/studio/src/scene/Effects.tsx @@ -14,13 +14,21 @@ import type { SceneFrame } from './types'; type P3 = [number, number, number]; +/** `born` is performance.now() when the effect was queued; its age is wall-clock time since then. */ type Fx = - | { id: number; kind: 'packet'; pts: P3[]; color: string } - | { id: number; kind: 'burst'; pos: P3; ok: boolean } - | { id: number; kind: 'artifact'; pos: P3; color: string }; + | { id: number; born: number; kind: 'packet'; pts: P3[]; color: string } + | { id: number; born: number; kind: 'burst'; pos: P3; ok: boolean } + | { id: number; born: number; kind: 'artifact'; pos: P3; color: string }; let nextId = 1; +/** + * Seconds since the effect was queued. Measured on the wall clock (not from + * its first frame), so effects queued while frames were paused (background + * tab) expire instead of all playing at once when frames resume. + */ +const ageOf = (fx: Fx) => (performance.now() - fx.born) / 1000; + const ARTIFACT_COLOR: Record = { file: PALETTE.cyan, diff: PALETTE.purple, @@ -40,6 +48,8 @@ export function Effects() { useEffect( () => ctx.subscribeEffects((events) => { + // Nobody sees transient effects in a hidden tab; persistent markers show the outcome. + if (document.hidden) return; const frame = ctx.getFrame(); const add: Fx[] = []; for (const e of events) add.push(...effectsFor(e, frame, ctx)); @@ -92,13 +102,21 @@ function effectsFor(e: AnyEvent, frame: SceneFrame, ctx: SceneCtx): Fx[] { const call = exec.toolCalls[e.payload.callId]; const pos = anchorFor(ctx, frame, call?.agentId); const ok = e.type === 'tool.call_completed' && e.payload.status === 'success'; - return pos ? [{ id: nextId++, kind: 'burst', pos, ok }] : []; + return pos ? [{ id: nextId++, born: performance.now(), kind: 'burst', pos, ok }] : []; } case 'artifact.created': { const by = e.payload.producedByCallId ? exec.toolCalls[e.payload.producedByCallId]?.agentId : e.actorId; const pos = anchorFor(ctx, frame, by); if (!pos) return []; - return [{ id: nextId++, kind: 'artifact', pos: [pos[0], pos[1] - 0.35, pos[2]], color: ARTIFACT_COLOR[e.payload.kind] ?? PALETTE.cyan }]; + return [ + { + id: nextId++, + born: performance.now(), + kind: 'artifact', + pos: [pos[0], pos[1] - 0.35, pos[2]], + color: ARTIFACT_COLOR[e.payload.kind] ?? PALETTE.cyan, + }, + ]; } case 'agent.message_sent': { const { world } = ctx; @@ -115,7 +133,7 @@ function effectsFor(e: AnyEvent, frame: SceneFrame, ctx: SceneCtx): Fx[] { const to = poseAt(world, frame, id); if (!to) continue; const cells = bfs(world, start, to.cell) ?? [start, to.cell]; - out.push({ id: nextId++, kind: 'packet', pts: pathPoints(world, cells, 1.05), color }); + out.push({ id: nextId++, born: performance.now(), kind: 'packet', pts: pathPoints(world, cells, 1.05), color }); } return out; } @@ -132,18 +150,18 @@ function Packet({ fx, onDone }: { fx: Extract; onDone: ( const len = useMemo(() => polylineLength(fx.pts), [fx.pts]); const trail = useMemo(() => ribbonGeometry(fx.pts, 0.1, { uPerUnit: 1 / Math.max(len, 0.001), extend: true }), [fx.pts, len]); useEffect(() => () => trail.dispose(), [trail]); + const root = useRef(null); const head = useRef(null); const trailMat = useRef(null); - const start = useRef(null); const travel = Math.min(3, Math.max(0.6, len / SPEED)); const total = reducedMotion ? 1.6 : travel + 0.6; const v = useMemo(() => new Vector3(), []); const c = useMemo(() => glow(fx.color, 2.4), [fx.color]); - useFrame(({ clock }) => { - start.current ??= clock.elapsedTime; - const age = clock.elapsedTime - start.current; + useFrame(() => { + const age = ageOf(fx); if (age > total) { + if (root.current) root.current.visible = false; onDone(fx.id); return; } @@ -161,7 +179,7 @@ function Packet({ fx, onDone }: { fx: Extract; onDone: ( }); return ( - + @@ -186,14 +204,13 @@ function Burst({ fx, onDone }: { fx: Extract; onDone: (id const ringA = useRef(null); const ringB = useRef(null); const mats = useRef([]); - const start = useRef(null); const color = useMemo(() => glow(fx.ok ? PALETTE.ok : PALETTE.err, 2.4), [fx.ok]); const total = fx.ok ? 1.3 : 1.6; - useFrame(({ clock }) => { - start.current ??= clock.elapsedTime; - const age = clock.elapsedTime - start.current; + useFrame(() => { + const age = ageOf(fx); if (age > total) { + if (group.current) group.current.visible = false; onDone(fx.id); return; } @@ -254,14 +271,13 @@ function ArtifactPop({ fx, onDone }: { fx: Extract; on const { reducedMotion } = useScene(); const g = useRef(null); const mat = useRef(null); - const start = useRef(null); const color = useMemo(() => glow(fx.color, 2.2), [fx.color]); const total = 2.4; - useFrame(({ clock }) => { - start.current ??= clock.elapsedTime; - const age = clock.elapsedTime - start.current; + useFrame(() => { + const age = ageOf(fx); if (age > total) { + if (g.current) g.current.visible = false; onDone(fx.id); return; } @@ -364,7 +380,7 @@ function BeaconMarker({ beacon }: { beacon: Beacon }) { kind: 'incident', open, title: inc.message, - text: open ? `${INCIDENT_LABEL[inc.kind]} · ${clip(inc.message, 26)}` : `Resolved · ${INCIDENT_LABEL[inc.kind]}`, + text: open ? `${INCIDENT_LABEL[inc.kind]} · ${clip(inc.message, 36)}` : `Resolved · ${INCIDENT_LABEL[inc.kind]}`, }, anchor, ); diff --git a/apps/studio/src/scene/labels.tsx b/apps/studio/src/scene/labels.tsx index a25f917..c8df051 100644 --- a/apps/studio/src/scene/labels.tsx +++ b/apps/studio/src/scene/labels.tsx @@ -1,7 +1,7 @@ 'use client'; import { useFrame, useThree } from '@react-three/fiber'; import type { PresentationState } from '@runloop-world/world-engine'; -import { useEffect, useSyncExternalStore } from 'react'; +import { useLayoutEffect, useSyncExternalStore } from 'react'; import { Vector3 } from 'three'; import { STATE_BADGE, TONE_COLOR, type IconName } from './badges'; import { useScene } from './context'; @@ -40,10 +40,14 @@ export class LabelStore { readonly elements = new Map(); /** Stacking boost: station label (hover/selection) > selected badge > badges > signs and incident chips. */ readonly boost = new Map(); + /** Zone signs stay put; every other label moves up out of the way of labels in front of it. */ + readonly fixed = new Set(); set(id: string, content: LabelContent, anchor: Anchor) { this.specs.set(id, content); this.anchors.set(id, anchor); + if (content.kind === 'zone') this.fixed.add(id); + else this.fixed.delete(id); this.boost.set(id, content.kind === 'station' ? 3000 : content.kind === 'agent' ? (content.selected ? 2000 : 1000) : 0); this.emit(); } @@ -52,6 +56,7 @@ export class LabelStore { if (!this.specs.delete(id)) return; this.anchors.delete(id); this.boost.delete(id); + this.fixed.delete(id); this.emit(); } @@ -72,7 +77,8 @@ export class LabelStore { export function useLabel(id: string, content: LabelContent | null, anchor: Anchor) { const { labels } = useScene(); const key = content ? JSON.stringify(content) : ''; - useEffect(() => { + // Layout effects, so a synchronous badge update reaches the label layer in the same commit. + useLayoutEffect(() => { if (!content) { labels.remove(id); return; @@ -80,18 +86,36 @@ export function useLabel(id: string, content: LabelContent | null, anchor: Ancho labels.set(id, content, anchor); // `key` captures content by value; anchor objects are stable per caller. }, [labels, id, key]); - useEffect(() => () => labels.remove(id), [labels, id]); + useLayoutEffect(() => () => labels.remove(id), [labels, id]); } const v = new Vector3(); +/** Minimum spacing (px) between labels that would otherwise overlap. */ +const LABEL_GAP = 3; -/** Inside the Canvas: projects every label anchor to screen space each frame. Mount it last. */ +interface Placed { + el: HTMLElement; + id: string; + x: number; + /** Screen y of the label's bottom edge (the anchor point). */ + y: number; + depth: number; +} + +/** + * Inside the Canvas: projects every label anchor to screen space each frame, + * then nudges overlapping labels apart. Labels nearer the front (lower on + * screen) keep their spot; ones further back move up just enough to clear + * them, so badges at a crowded hub or next to a station stay readable. + * Mount it last. + */ export function LabelProjector() { const { labels } = useScene(); const camera = useThree((s) => s.camera); const size = useThree((s) => s.size); useFrame(() => { camera.updateMatrixWorld(); + const items: Placed[] = []; for (const [id, el] of labels.elements) { const a = labels.anchors.get(id); const p = typeof a === 'function' ? a(v) : a ? v.copy(a) : null; @@ -104,8 +128,32 @@ export function LabelProjector() { const y = ((1 - p.y) / 2) * size.height; const off = p.z > 1 || x < -200 || y < -100 || x > size.width + 200 || y > size.height + 100; el.style.visibility = off ? 'hidden' : 'visible'; - el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) translate(-50%, -100%)`; - el.style.zIndex = String(Math.round((1 - p.z) * 500) + (labels.boost.get(id) ?? 0)); + if (!off) items.push({ el, id, x, y, depth: p.z }); + } + + items.sort((a, b) => b.y - a.y); + const taken: { l: number; r: number; t: number; b: number }[] = []; + for (const it of items) { + if (!labels.fixed.has(it.id)) { + const half = it.el.offsetWidth / 2; + const h = it.el.offsetHeight; + // Move up past every placed label this one overlaps (a few passes settle chains). + for (let pass = 0; pass < 6; pass++) { + let moved = false; + for (const r of taken) { + const overlaps = + it.x + half > r.l - LABEL_GAP && it.x - half < r.r + LABEL_GAP && it.y > r.t - LABEL_GAP && it.y - h < r.b + LABEL_GAP; + if (overlaps) { + it.y = r.t - LABEL_GAP; + moved = true; + } + } + if (!moved) break; + } + taken.push({ l: it.x - half, r: it.x + half, t: it.y - h, b: it.y }); + } + it.el.style.transform = `translate3d(${it.x.toFixed(1)}px, ${it.y.toFixed(1)}px, 0) translate(-50%, -100%)`; + it.el.style.zIndex = String(Math.round((1 - it.depth) * 500) + (labels.boost.get(it.id) ?? 0)); } }); return null; diff --git a/apps/studio/src/scene/scene.module.css b/apps/studio/src/scene/scene.module.css index 537c11a..4c5e445 100644 --- a/apps/studio/src/scene/scene.module.css +++ b/apps/studio/src/scene/scene.module.css @@ -124,7 +124,8 @@ display: inline-flex; align-items: center; gap: 5px; - max-width: 220px; + /* Room for "Tool error · 12 tests: 10 passed, 2 failed" (the text is clipped to 36 chars). */ + max-width: 300px; padding: 3px 8px; border-radius: 6px; border: 1px solid #f43f5e; diff --git a/apps/studio/src/state/store.test.ts b/apps/studio/src/state/store.test.ts index a7f9fb4..096c64d 100644 --- a/apps/studio/src/state/store.test.ts +++ b/apps/studio/src/state/store.test.ts @@ -57,6 +57,22 @@ describe('studio store: ingest', () => { expect(s.stateAt(2).exec.anomalies.map((a) => a.code)).toContain('unknown_event'); }); + it('folds an invalid envelope that names its sequence as an anomaly instead of stalling', () => { + const { store, events } = openFixture(); + const bad = { ...(wire(events[2]) as object), visibility: 'internal' }; // e.g. from a newer runtime + const raws = events.map((e, i) => (i === 2 ? bad : wire(e))); + store.getState().ingest(raws, 'live'); + store.getState().ingest([bad], 'backlog'); // a backfill redelivers it: no duplicate + const s = store.getState(); + expect(s.lastSequence).toBe(events.length); + expect(s.gap).toBeNull(); + expect(s.raw).toEqual([]); + expect(s.events()[2]).toMatchObject({ sequence: 3, unknown: true, reason: 'invalid_envelope', eventId: events[2]!.eventId }); + expect(s.rawEvent(3)).toEqual(bad); + expect(s.stateAt(3).exec.anomalies.map((a) => a.code)).toEqual(['unknown_event']); + expect(s.ended).toBe(true); + }); + it('keeps the raw JSON of each event as received', () => { const { store, events } = openFixture(); const withExtra = { ...(wire(events[0]) as object), extraField: 'kept' }; @@ -174,6 +190,20 @@ describe('studio store: playback', () => { expect(store.getState().cursor).toMatchObject({ mode: 'replay', playing: true, tMs: 0 }); }); + it('ends the scrubber of a running run at the live playhead, not at predicted arrivals', () => { + const { events } = openFixture(); + const clock = fakeNow(ms(events[20]!.timestamp)); + const { store } = openFixture({ now: clock.now }); + store.getState().ingest(events.slice(0, 21).map(wire), 'live'); + const s = store.getState(); + expect(s.ended).toBe(false); + expect(s.settleMs).toBeGreaterThan(s.playheadMs()); // characters are still walking + expect(s.endMs()).toBe(s.playheadMs()); + s.seek(1e9); + expect(store.getState().cursor).toMatchObject({ mode: 'replay', tMs: s.durationMs }); + expect(store.getState().playheadSeq).toBe(21); + }); + it('hands over to live when playback catches up with a running run', () => { const { events } = openFixture(); const clock = fakeNow(ms(events[29]!.timestamp)); diff --git a/apps/studio/src/state/store.ts b/apps/studio/src/state/store.ts index 8fde231..fed3198 100644 --- a/apps/studio/src/state/store.ts +++ b/apps/studio/src/state/store.ts @@ -1,4 +1,5 @@ import { + invalidEnvelopeEvent, parseEvent, SequenceBuffer, type AnyEvent, @@ -159,6 +160,8 @@ export function createStudioStore(opts: StudioStoreOptions = {}): StudioStore { let conflictsSeen = 0; /** min(clientNow − eventTimestamp): client clock minus server clock, plus the smallest latency seen. */ let clockOffsetMs: number | null = null; + /** Latest valid event timestamp seen (stand-in time for invalid envelopes). */ + let lastTimestamp: string | null = null; let gapSince: number | null = null; let frameMemo: { seq: number; state: RunState } | null = null; const effectListeners = new Set<(events: readonly AnyEvent[]) => void>(); @@ -182,6 +185,7 @@ export function createStudioStore(opts: StudioStoreOptions = {}): StudioStore { rawSeen = new Set(); conflictsSeen = 0; clockOffsetMs = null; + lastTimestamp = null; gapSince = null; frameMemo = null; }; @@ -215,7 +219,11 @@ export function createStudioStore(opts: StudioStoreOptions = {}): StudioStore { return s.ended ? Math.min(t, s.settleMs) : t; }; - const endMsOf = (s: StudioState): number => (s.ended ? s.settleMs : Math.max(s.settleMs, livePlayheadMs(s))); + /** + * Right end of the scrubber. A finished run ends once its characters settle; + * a running run ends at its live playhead (never at a predicted future). + */ + const endMsOf = (s: StudioState): number => (s.ended ? s.settleMs : livePlayheadMs(s)); const seqAt = (cursor: Cursor): number => { if (!index) return 0; @@ -355,6 +363,14 @@ export function createStudioStore(opts: StudioStoreOptions = {}): StudioStore { for (const raw of raws) { const parsed = parseEvent(raw); if (!parsed.ok) { + // Still names its sequence: fold a placeholder (an anomaly) so later events are not held forever. + const fallback = { runId: s.runId ?? '', timestamp: lastTimestamp ?? new Date(t).toISOString() }; + const placeholder = invalidEnvelopeEvent(raw, parsed.error, fallback); + if (placeholder && (!s.runId || placeholder.runId === s.runId)) { + if (!rawById.has(placeholder.eventId)) rawById.set(placeholder.eventId, raw); + released.push(...buffer.push(placeholder)); + continue; + } const key = safeJson(raw); if (rawSeen.has(key)) continue; rawSeen.add(key); @@ -365,7 +381,10 @@ export function createStudioStore(opts: StudioStoreOptions = {}): StudioStore { const e = parsed.event; if (s.runId && e.runId !== s.runId) continue; // stale delivery from a previous run const ts = Date.parse(e.timestamp); - if (Number.isFinite(ts)) clockOffsetMs = Math.min(clockOffsetMs ?? Infinity, t - ts); + if (Number.isFinite(ts)) { + clockOffsetMs = Math.min(clockOffsetMs ?? Infinity, t - ts); + if (lastTimestamp === null || ts > Date.parse(lastTimestamp)) lastTimestamp = e.timestamp; + } if (!rawById.has(e.eventId)) rawById.set(e.eventId, raw); released.push(...buffer.push(e)); } diff --git a/apps/studio/src/state/view.test.ts b/apps/studio/src/state/view.test.ts index 4a2d9ce..d7767ff 100644 --- a/apps/studio/src/state/view.test.ts +++ b/apps/studio/src/state/view.test.ts @@ -17,7 +17,7 @@ describe('agent flow chips', () => { expect(statuses(at((e) => e.type === 'task.blocked'))).toEqual({ research: 'done', code: 'done', - test: 'in progress', // blocked counts as current + test: 'blocked', fix: 'pending', }); const final = flowChips(foldExecution(events)); @@ -25,6 +25,21 @@ describe('agent flow chips', () => { expect(final.every((c) => c.status === 'done' && c.view.icon === '✓')).toBe(true); }); + it('shows open work of an ended run as unfinished, not in progress', () => { + const toolStart = events.findIndex((e) => e.type === 'tool.call_started' && e.payload.tool === 'write_file'); + const b = runBuilder({ runId: events[0]!.runId }); + for (const e of events.slice(0, toolStart + 1)) b.add(e.type, e.payload as never); + expect(statuses(foldExecution(b.events)).code).toBe('in progress'); + b.add('run.completed', { outcome: 'stopped', summary: 'Stopped by user' }); + const chips = flowChips(foldExecution(b.events)); + expect(Object.fromEntries(chips.map((c) => [c.kind, c.view.text]))).toEqual({ + research: 'done', + code: 'unfinished', + test: 'pending', + }); + expect(chips[1]!.view.icon).toBe('■'); + }); + it('marks a failed task kind as failed with an icon and text', () => { const b = runBuilder(); b.add('run.started', { diff --git a/apps/studio/src/state/view.ts b/apps/studio/src/state/view.ts index 7bce7c1..257c7cf 100644 --- a/apps/studio/src/state/view.ts +++ b/apps/studio/src/state/view.ts @@ -29,10 +29,21 @@ export const KIND_LABEL: Record = { const FLOW_STATUS: Record = { pending: { icon: '○', text: 'pending', tone: 'muted' }, current: { icon: '◉', text: 'in progress', tone: 'live' }, + blocked: { icon: '⏳', text: 'blocked', tone: 'warn' }, done: { icon: '✓', text: 'done', tone: 'ok' }, failed: { icon: '✗', text: 'failed', tone: 'err' }, }; +/** Work left open when the run ended (stopped, interrupted, failed): nothing is in progress any more. */ +const UNFINISHED: StatusView = { icon: '■', text: 'unfinished', tone: 'muted' }; + +const RUN_ENDED: ReadonlySet = new Set(['succeeded', 'failed', 'stopped', 'interrupted']); + +/** The run has a terminal status: nothing executes any more. */ +export function runHasEnded(exec: ExecState): boolean { + return RUN_ENDED.has(exec.run.status); +} + export interface FlowChipView extends FlowChip { label: string; view: StatusView; @@ -40,7 +51,12 @@ export interface FlowChipView extends FlowChip { /** Agent Flow chips (core selectAgentFlow) with labels and status icons. */ export function flowChips(exec: ExecState): FlowChipView[] { - return selectAgentFlow(exec).map((c) => ({ ...c, label: KIND_LABEL[c.kind], view: FLOW_STATUS[c.status] })); + const ended = runHasEnded(exec); + return selectAgentFlow(exec).map((c) => ({ + ...c, + label: KIND_LABEL[c.kind], + view: ended && (c.status === 'current' || c.status === 'blocked') ? UNFINISHED : FLOW_STATUS[c.status], + })); } const RUN_STATUS: Record = { @@ -102,7 +118,9 @@ const TASK_STATUS: Record = { failed: { icon: '✗', text: 'failed', tone: 'err' }, }; -export function taskStatusView(status: TaskStatus): StatusView { +/** A task's status; open tasks of a run that has ended show as unfinished. */ +export function taskStatusView(status: TaskStatus, runEnded = false): StatusView { + if (runEnded && (status === 'assigned' || status === 'in_progress' || status === 'blocked')) return UNFINISHED; return TASK_STATUS[status]; } diff --git a/package.json b/package.json index 00a5ce5..eb54f23 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "type": "module", "packageManager": "pnpm@10.28.0", "engines": { - "node": ">=22.12" + "node": ">=22.16" }, "scripts": { "dev": "pnpm -r --parallel --filter @runloop-world/runtime --filter @runloop-world/studio dev", diff --git a/packages/core/src/format.test.ts b/packages/core/src/format.test.ts index 88e0226..245a338 100644 --- a/packages/core/src/format.test.ts +++ b/packages/core/src/format.test.ts @@ -58,6 +58,19 @@ describe('formatEvent', () => { expect(formatEvent(events.at(-1)!, s)).toMatchObject({ label: 'Run succeeded', severity: 'ok' }); }); + it('numbers retried task starts, with the state before or after the event', () => { + const starts = events.filter((x) => x.type === 'task.started' && x.payload.taskId === 'T3'); + expect(starts).toHaveLength(2); + const before = foldExecution(events.slice(0, starts[1]!.sequence - 1)); + const after = foldExecution(events.slice(0, starts[1]!.sequence)); + expect(formatEvent(starts[1]!, before).label).toBe('Started T3 (attempt 2)'); + expect(formatEvent(starts[1]!, after).label).toBe('Started T3 (attempt 2)'); + expect(formatEvent(starts[0]!, foldExecution(events.slice(0, starts[0]!.sequence))).label).toBe('Started T3'); + // Without the adjacent state the attempt is unknown, so it is left out. + expect(formatEvent(starts[1]!, s).label).toBe('Started T3'); + expect(formatEvent(starts[1]!).label).toBe('Started T3'); + }); + it('shows unknown events as warnings', () => { const u: UnknownEvent = { ...events[1]!, diff --git a/packages/core/src/format.ts b/packages/core/src/format.ts index c8606ce..dd06aba 100644 --- a/packages/core/src/format.ts +++ b/packages/core/src/format.ts @@ -87,8 +87,14 @@ function describe(e: RunEvent, s?: ExecState): Described { } case 'task.assigned': return { label: `Assigned ${e.payload.taskId} to ${e.payload.agentId}` }; - case 'task.started': - return { label: `Started ${e.payload.taskId}` }; + case 'task.started': { + // Numbered only when `s` is the state just before or just after this event. + const task = s?.tasks[e.payload.taskId]; + const after = s?.lastSequence === e.sequence; + const before = s?.lastSequence === e.sequence - 1; + const attempt = task && (after || before) ? task.attempts + (after ? 0 : 1) : 1; + return { label: attempt > 1 ? `Started ${e.payload.taskId} (attempt ${attempt})` : `Started ${e.payload.taskId}` }; + } case 'task.blocked': return { label: `Blocked ${e.payload.taskId}`, detail: e.payload.reason, severity: 'warn' }; case 'task.completed': diff --git a/packages/core/src/selectors.test.ts b/packages/core/src/selectors.test.ts index 8d0d48b..851790d 100644 --- a/packages/core/src/selectors.test.ts +++ b/packages/core/src/selectors.test.ts @@ -61,7 +61,7 @@ describe('selectors', () => { expect(kinds(after((e) => e.type === 'task.blocked'))).toEqual([ 'research:done', 'code:done', - 'test:current', + 'test:blocked', 'fix:pending', ]); expect(kinds(foldExecution(events))).toEqual(['research:done', 'code:done', 'test:done', 'fix:done']); diff --git a/packages/core/src/selectors.ts b/packages/core/src/selectors.ts index d0ab213..a48f48d 100644 --- a/packages/core/src/selectors.ts +++ b/packages/core/src/selectors.ts @@ -32,8 +32,8 @@ export function selectTaskProgress(s: ExecState, taskId: string): number | null /** * One chip per task kind, in first-seen order. A chip is `current` while any of - * its tasks is active (assigned / in progress / blocked), else `failed` if one - * failed, else `done` when all completed, else `pending`. + * its tasks is assigned or in progress, else `blocked` if one is blocked, else + * `failed` if one failed, else `done` when all completed, else `pending`. */ export function selectAgentFlow(s: ExecState): FlowChip[] { const byKind = new Map(); @@ -45,7 +45,8 @@ export function selectAgentFlow(s: ExecState): FlowChip[] { } return [...byKind].map(([kind, tasks]) => { let status: FlowChip['status'] = 'pending'; - if (tasks.some((t) => t.status === 'assigned' || t.status === 'in_progress' || t.status === 'blocked')) status = 'current'; + if (tasks.some((t) => t.status === 'assigned' || t.status === 'in_progress')) status = 'current'; + else if (tasks.some((t) => t.status === 'blocked')) status = 'blocked'; else if (tasks.some((t) => t.status === 'failed')) status = 'failed'; else if (tasks.every((t) => t.status === 'completed')) status = 'done'; return { kind, status, taskIds: tasks.map((t) => t.id) }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e71845f..233aa24 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -184,7 +184,8 @@ export interface ExecState { /** A chip in the Agent Flow row, one per task kind in first-seen order. */ export interface FlowChip { kind: TaskKind; - status: 'pending' | 'current' | 'done' | 'failed'; + /** current = a task of this kind is assigned / in progress; blocked = only blocked ones are open. */ + status: 'pending' | 'current' | 'blocked' | 'done' | 'failed'; taskIds: string[]; } diff --git a/packages/events/src/event.ts b/packages/events/src/event.ts index fb05cae..dfa4234 100644 --- a/packages/events/src/event.ts +++ b/packages/events/src/event.ts @@ -13,15 +13,16 @@ export type EventOf = EnvelopeFields & { export type RunEvent = { [T in EventType]: EventOf }[EventType]; /** - * An event with a valid envelope that this version cannot interpret: - * an unknown type, an unsupported schemaVersion, or an invalid payload. - * Reducers skip it (recording an anomaly) but the text trace still shows it. + * An event this version cannot interpret: an unknown type, an unsupported + * schemaVersion or an invalid payload, or (from invalidEnvelopeEvent) an + * invalid envelope that still names its sequence. Reducers skip it + * (recording an anomaly) but the text trace still shows it. */ export type UnknownEvent = EnvelopeFields & { type: string; payload: unknown; unknown: true; - reason: 'unknown_type' | 'unsupported_version' | 'invalid_payload'; + reason: 'unknown_type' | 'unsupported_version' | 'invalid_payload' | 'invalid_envelope'; error?: string; }; diff --git a/packages/events/src/parse.test.ts b/packages/events/src/parse.test.ts index 5a00742..b88df97 100644 --- a/packages/events/src/parse.test.ts +++ b/packages/events/src/parse.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; // events has no dependency on testkit (testkit depends on events), so tests import its source directly. import { codingMissionFixture } from '../../testkit/src/index'; import { deriveRefs, isUnknownEvent, type EventDraft } from './event'; -import { parseEvent, parseKnownEvent } from './parse'; +import { invalidEnvelopeEvent, parseEvent, parseKnownEvent } from './parse'; import { EVENT_TYPES, isEventType } from './payloads'; const fixture = codingMissionFixture(); @@ -76,6 +76,38 @@ describe('parseEvent', () => { }); }); +describe('invalidEnvelopeEvent', () => { + const fallback = { runId: '00000000-0000-4000-8000-000000000000', timestamp: '2026-10-17T09:00:00.000Z' }; + + it('turns an invalid envelope with a sequence into an UnknownEvent that keeps its place', () => { + const raw: Record = { ...sample(), visibility: 'internal' }; + const parsed = parseEvent(raw); + expect(parsed.ok).toBe(false); + if (parsed.ok) return; + const e = invalidEnvelopeEvent(raw, parsed.error, fallback)!; + expect(isUnknownEvent(e)).toBe(true); + expect(e).toMatchObject({ + eventId: raw.eventId, + runId: raw.runId, + sequence: raw.sequence, + timestamp: raw.timestamp, + type: raw.type, + visibility: 'sensitive', + reason: 'invalid_envelope', + error: parsed.error, + }); + }); + + it('fills missing fields deterministically and needs an integer sequence', () => { + const e = invalidEnvelopeEvent({ sequence: 7, timestamp: 'yesterday' }, 'bad', fallback)!; + expect(e).toMatchObject({ eventId: `invalid:${fallback.runId}:7`, runId: fallback.runId, timestamp: fallback.timestamp, type: 'invalid' }); + expect(invalidEnvelopeEvent({ sequence: 7 }, 'bad', fallback)).toEqual(e); + for (const raw of [null, 'x', {}, { sequence: 0 }, { sequence: 1.5 }, { sequence: '3' }]) { + expect(invalidEnvelopeEvent(raw, 'bad', fallback)).toBeNull(); + } + }); +}); + describe('event helpers', () => { it('isEventType knows exactly the payload schemas', () => { for (const t of EVENT_TYPES) expect(isEventType(t)).toBe(true); diff --git a/packages/events/src/parse.ts b/packages/events/src/parse.ts index 441e24c..382ee74 100644 --- a/packages/events/src/parse.ts +++ b/packages/events/src/parse.ts @@ -1,4 +1,4 @@ -import { EnvelopeSchema, SCHEMA_VERSION } from './envelope'; +import { EnvelopeSchema, SCHEMA_VERSION, VisibilitySchema } from './envelope'; import type { AnyEvent, RunEvent, UnknownEvent } from './event'; import { isEventType, payloadSchemas } from './payloads'; @@ -42,3 +42,47 @@ export function parseKnownEvent(raw: unknown): ParseResult { } return { ok: true, event: r.event }; } + +/** + * Stand-in for an item whose envelope failed to parse but that still names + * its sequence (e.g. a field value from a newer runtime). Readers fold it as + * an UnknownEvent so the sequence advances and the stream never stalls on a + * permanent gap. Returns null when there is no integer sequence ≥ 1. + * Missing or invalid fields are filled from `fallback` (runId, timestamp) or + * made up deterministically (eventId), so redeliveries dedupe. + */ +export function invalidEnvelopeEvent( + raw: unknown, + error: string, + fallback: { runId: string; timestamp: string }, +): UnknownEvent | null { + if (typeof raw !== 'object' || raw === null) return null; + const r = raw as Record; + const sequence = r.sequence; + if (typeof sequence !== 'number' || !Number.isInteger(sequence) || sequence < 1) return null; + const str = (v: unknown) => (typeof v === 'string' && v.length > 0 ? v : undefined); + const runId = str(r.runId) ?? fallback.runId; + const ts = str(r.timestamp); + const visibility = VisibilitySchema.safeParse(r.visibility); + const schemaVersion = r.schemaVersion; + const actorId = str(r.actorId); + const taskId = str(r.taskId); + const parentEventId = str(r.parentEventId); + return { + eventId: str(r.eventId) ?? `invalid:${runId}:${sequence}`, + runId, + sequence, + timestamp: ts && Number.isFinite(Date.parse(ts)) ? ts : fallback.timestamp, + type: str(r.type) ?? 'invalid', + schemaVersion: typeof schemaVersion === 'number' && Number.isInteger(schemaVersion) ? schemaVersion : SCHEMA_VERSION, + ...(actorId ? { actorId } : {}), + ...(taskId ? { taskId } : {}), + ...(parentEventId ? { parentEventId } : {}), + // An unrecognised visibility is treated as the most restrictive one. + visibility: visibility.success ? visibility.data : 'sensitive', + payload: r.payload, + unknown: true, + reason: 'invalid_envelope', + error, + }; +} diff --git a/packages/replay/src/replay.test.ts b/packages/replay/src/replay.test.ts index f36c302..b7f4915 100644 --- a/packages/replay/src/replay.test.ts +++ b/packages/replay/src/replay.test.ts @@ -167,11 +167,30 @@ describe('ReplayIndex time mapping', () => { const events = longRun(4); const idx = new ReplayIndex(world, events); for (const e of events) { - expect(Math.floor(idx.tickAtMs(idx.timeOfSeq(e.sequence)))).toBe(idx.stateAt(e.sequence).world.tick); + expect(idx.tickAtMs(idx.timeOfSeq(e.sequence))).toBe(idx.stateAt(e.sequence).world.tick); expect(idx.seqAtTime(idx.timeOfSeq(e.sequence))).toBeGreaterThanOrEqual(e.sequence); } }); + it('a re-route between ticks continues from where the playhead is (no jump)', () => { + const b = runBuilder({ startIso: '2026-10-17T09:00:00.000Z' }); + b.add('run.started', { title: 't', missionId: 'm', worldId: 'research-campus', seed: 1, adapter: { id: 'x', kind: 'scripted', label: 'X' }, workspace: 'w' }); + b.wait(100).add('agent.registered', { agentId: 't', name: 't', role: 't', model: null, tools: [], embodied: true, characterPreset: 'tester' }); + b.wait(100).add('task.created', { taskId: 'T1', title: '', kind: 'test', dependsOn: [] }); + b.wait(100).add('task.started', { taskId: 'T1', agentId: 't' }); // 300 ms: walk to the probe + b.wait(490).add('task.blocked', { taskId: 'T1', reason: 'x' }); // 790 ms, between ticks: walk back home + b.wait(1230).add('task.started', { taskId: 'T1', agentId: 't' }); // 2020 ms: and out again + const idx = new ReplayIndex(world, b.events); + const maxStep = 1 / world.def.ticksPerCell / world.def.tickHz; // cells per ms at walking speed + let prev: { x: number; y: number } | null = null; + for (let ms = 300; ms <= 6000; ms += 1) { + const c = idx.stateAt(idx.seqAtTime(ms)).world.characters.t!; + const p = presentationAt(c, idx.tickAtMs(ms), world); + if (prev) expect(Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y), `jump at ${ms} ms`).toBeLessThanOrEqual(maxStep + 1e-9); + prev = p; + } + }); + it('positions are identical between a live fold and replay at many ticks', () => { for (const events of [fixture, longRun(11)]) { const idx = new ReplayIndex(world, events); diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 4835ba0..e501ea4 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -28,6 +28,8 @@ RunManager.start ─► copy examples//repo → workspaces/ boot ─► Recorder.recoverInterrupted() (runs left "running" → interrupted) ``` +Recovery stamps its `tool.call_failed{runtime_restarted}` and `run.completed{interrupted}` events with the run's last recorded activity, not the restart time, so a replay of an interrupted run does not stretch over the downtime. The summary says when the run was recovered. + ### Recorder (`src/recorder.ts`) `append` is synchronous and runs these steps in order: @@ -45,7 +47,9 @@ Adapters may only emit `agent.*` and `task.*` events: - `run.*` comes from the run manager. - `tool.*` and `artifact.*` come from the tool invoker. -So an adapter cannot fabricate a tool result or an outcome. Every `tool.call_started` gets exactly one terminal event, linked by `parentEventId`: +So an adapter cannot fabricate a tool result or an artifact: those are produced by the runtime. Task and run outcomes are different: `task.completed`, `task.failed` and the final `run.completed` outcome and summary are the adapter's **claims**, recorded as made. The run manager only checks that a `task.completed` cites artifact ids this run actually recorded; it cannot tell whether "all tests pass" is true. Read claims against the tool results they rest on. + +Every `tool.call_started` gets exactly one terminal event, linked by `parentEventId`: - **`tool.call_completed`:** the tool ran. A failing test run is `status: error` with its real metrics. - **`tool.call_failed`:** the tool could not run (denied, timeout, aborted, spawn_error, exception or runtime_restarted). @@ -54,6 +58,8 @@ So an adapter cannot fabricate a tool result or an outcome. Every `tool.call_sta - Paths are resolved inside the workspace, and realpath catches symlink escapes. - Reads and writes are capped at 256 KB; listings at 500 entries. - `run_command` allows only the exact argv `node --test --test-reporter=tap`. It runs as `process.execPath` with `shell: false` and a minimal env, has a 30 s timeout, and its process group is SIGKILLed on timeout or abort. +- **This is not isolation.** `node --test` executes the workspace's JavaScript as your user. That code can read the runtime's environment through `/proc//environ` and write anywhere you can, including the data dir. It is fine for the scripted demo, whose code is fixed. Run commands in a real sandbox (a container, bubblewrap or nsjail without access to the data dir) before connecting an adapter that writes code of its own. +- Artifact bytes are checked against the sha256 recorded in `artifact.created` whenever they are served; changed bytes get a 409 instead of being shown. ### Scripted coding mission (`src/adapters/scripted-coding-mission.ts`) @@ -72,7 +78,7 @@ The contract is `packages/events/src/protocol.ts`. Every request needs a `localh | `GET /api/runs/:id/events?after&limit` | `{ events, nextAfter, hasMore }` (limit ≤ 1000) | | `GET /api/runs/:id/stream` | SSE. Resume with `Last-Event-ID` or `?after`. Sends `: ping` every 15 s and an `end` event when the run is terminal. | | `POST /api/runs/:id/{stop,pause,resume}` | 202 `{ run }`, or 409 if the run is not active | -| `GET /api/runs/:id/artifacts/:artifactId` | The stored bytes, with their media type | +| `GET /api/runs/:id/artifacts/:artifactId` | The stored bytes, with their media type. 409 if they no longer match the recorded sha256. | ## Tests diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts index 10929dd..497cf07 100644 --- a/packages/runtime/src/cli.ts +++ b/packages/runtime/src/cli.ts @@ -4,6 +4,7 @@ import { parseArgs } from 'node:util'; import { serve } from '@hono/node-server'; import { DEFAULT_RUNTIME_HOST, DEFAULT_RUNTIME_PORT } from '@runloop-world/events'; import { findRepoRoot, resolveDataDir } from './config'; +import { isLoopbackAddress } from './http/app'; import { createRuntime, type Runtime } from './runtime'; import { TracePrinter } from './trace-printer'; @@ -44,15 +45,26 @@ function acquireLock(dataDir: string): () => void { return () => rmSync(file, { force: true }); } +/** Grace period for open responses (SSE streams sending their `end`) when the server closes. */ +const CLOSE_GRACE_MS = 2000; + function listen(rt: Runtime, host: string, port: number): Promise<{ close(): Promise; url: string }> { return new Promise((resolve, reject) => { const server = serve({ fetch: rt.app.fetch, hostname: host, port }, (info) => { resolve({ url: `http://${host}:${info.port}`, + // Graceful: stop accepting, let open responses finish (an SSE stream ends by + // itself once its run is terminal), and only then cut what is left. close: () => new Promise((r) => { - if ('closeAllConnections' in server) server.closeAllConnections(); - server.close(() => r()); + const force = setTimeout(() => { + if ('closeAllConnections' in server) server.closeAllConnections(); + }, CLOSE_GRACE_MS); + force.unref(); + server.close(() => { + clearTimeout(force); + r(); + }); }), }); }); @@ -88,6 +100,10 @@ export async function main(argv: string[]): Promise { }); const port = parsePort(values.port); const host = values.host ?? DEFAULT_RUNTIME_HOST; + if (host !== 'localhost' && !isLoopbackAddress(host)) { + console.error(`[runtime] --host ${host} refused: the API has no authentication, so it only listens on loopback (127.0.0.1, ::1 or localhost).`); + return 1; + } const repoRoot = findRepoRoot(); const dataDir = resolveDataDir(process.env, repoRoot); @@ -101,8 +117,11 @@ export async function main(argv: string[]): Promise { const shutdown = async (code: number) => { if (shuttingDown) return; shuttingDown = true; - await rt.close(); + // Order matters: record run.completed{stopped}, let SSE clients receive it and + // their `end`, close the server, and only then close the database. + await rt.manager.shutdown(); await server?.close(); + await rt.close(); releaseLock(); process.exit(code); }; diff --git a/packages/runtime/src/http/app.ts b/packages/runtime/src/http/app.ts index 9ac5ad1..39b8c14 100644 --- a/packages/runtime/src/http/app.ts +++ b/packages/runtime/src/http/app.ts @@ -14,7 +14,7 @@ import { Hono, type Context } from 'hono'; import { cors } from 'hono/cors'; import { RUNTIME_VERSION } from '../config'; import { MissionNotFoundError } from '../mission'; -import { UnknownRunError, type Recorder } from '../recorder'; +import { ArtifactIntegrityError, UnknownRunError, type Recorder } from '../recorder'; import { RunConflictError, RunNotActiveError, type RunManager } from '../run-manager'; import { streamRunEvents } from './sse'; @@ -32,6 +32,18 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); const SAFE_WORLD_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; const MAX_PAGE = 1000; +/** True for 127.0.0.0/8 and ::1 (also IPv4-mapped and bracketed forms). */ +export function isLoopbackAddress(address: string): boolean { + const a = address.replace(/^\[|\]$/g, '').replace(/^::ffff:/i, ''); + return a === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a); +} + +/** Peer address of the socket when served by @hono/node-server (absent for in-process app.request()). */ +function remoteAddressOf(c: Context): string | undefined { + const env = c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined; + return env?.incoming?.socket?.remoteAddress; +} + function hostnameOf(c: Context): string | null { const host = c.req.header('host'); try { @@ -52,10 +64,10 @@ function errorBody(error: string): ErrorResponse { } /** - * The local HTTP API (see packages/events/src/protocol.ts). Guards: the Host - * header must be localhost/127.0.0.1 (blocks DNS rebinding), POSTs must be - * JSON (forces a CORS preflight) and come from an allowed Origin when a - * browser sends one. + * The local HTTP API (see packages/events/src/protocol.ts). It has no auth, so + * guards: the peer must be a loopback address, the Host header must be + * localhost/127.0.0.1 (blocks DNS rebinding), POSTs must be JSON (forces a + * CORS preflight) and come from an allowed Origin when a browser sends one. */ export function createApp(opts: AppOptions): Hono { const { recorder, manager, allowedOrigins } = opts; @@ -74,6 +86,8 @@ export function createApp(opts: AppOptions): Hono { const app = new Hono(); app.use('*', async (c, next) => { + const peer = remoteAddressOf(c); + if (peer !== undefined && !isLoopbackAddress(peer)) return c.json(errorBody('Only local connections are allowed'), 403); const hostname = hostnameOf(c); if (!hostname || !LOCAL_HOSTNAMES.has(hostname)) return c.json(errorBody('Host not allowed'), 403); await next(); @@ -185,7 +199,13 @@ export function createApp(opts: AppOptions): Hono { app.post('/api/runs/:id/resume', control('resume')); app.get('/api/runs/:id/artifacts/:aid', (c) => { - const artifact = recorder.getArtifact(c.req.param('id'), c.req.param('aid')); + let artifact; + try { + artifact = recorder.getArtifact(c.req.param('id'), c.req.param('aid')); + } catch (err) { + if (err instanceof ArtifactIntegrityError) return c.json(errorBody(err.message), 409); + throw err; + } if (!artifact) return c.json(errorBody('Artifact not found'), 404); const textual = /^text\/|^application\/json/.test(artifact.mediaType); return c.body(new Uint8Array(artifact.content), 200, { diff --git a/packages/runtime/src/http/sse.ts b/packages/runtime/src/http/sse.ts index 4639290..2fe40aa 100644 --- a/packages/runtime/src/http/sse.ts +++ b/packages/runtime/src/http/sse.ts @@ -85,6 +85,9 @@ export function streamRunEvents( }); if (timedOut && !stream.aborted) await stream.write(': ping\n\n'); } + } catch (err) { + // A client that went away (or a server shutting down) is not an error. + if (!stream.aborted) throw err; } finally { unsubscribe(); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 579b9a9..fe46dca 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -7,7 +7,7 @@ export * from './run-manager'; export * from './runtime'; export * from './tool-invoker'; export * from './trace-printer'; -export { createApp, type AppOptions } from './http/app'; +export { createApp, isLoopbackAddress, type AppOptions } from './http/app'; export { streamRunEvents } from './http/sse'; export { openDb, migrate, tx, type Db } from './store/db'; export { SandboxError, resolveInside } from './sandbox/paths'; diff --git a/packages/runtime/src/recorder.ts b/packages/runtime/src/recorder.ts index 5c4bd2f..1fe5d48 100644 --- a/packages/runtime/src/recorder.ts +++ b/packages/runtime/src/recorder.ts @@ -56,6 +56,16 @@ export class RunClosedError extends Error { /** The draft did not produce a valid known event. No sequence number was consumed. */ export class EventValidationError extends Error {} +/** Stored artifact bytes no longer match the sha256 recorded in their artifact.created event. */ +export class ArtifactIntegrityError extends Error { + constructor( + readonly runId: string, + readonly artifactId: string, + ) { + super(`Artifact ${artifactId} was modified after it was recorded (sha256 mismatch); refusing to serve it`); + } +} + interface RunRow { run_id: string; mission_id: string; @@ -145,10 +155,14 @@ export class Recorder { append(runId: string, draft: EventDraft): EventOf; append(runId: string, draft: EventDraft): RunEvent { + return this.write(runId, draft, this.now()); + } + + private write(runId: string, draft: EventDraft, at: Date): RunEvent { if (draft.type === 'run.started') throw new EventValidationError('run.started is only emitted by createRun()'); const state = this.state(runId); if (state.status !== 'running') throw new RunClosedError(runId, state.status); - const event = this.build(runId, state.lastSequence + 1, draft); + const event = this.build(runId, state.lastSequence + 1, draft, at); tx(this.db, () => this.persist(event)); state.lastSequence = event.sequence; if (event.type === 'run.completed') state.status = event.payload.outcome; @@ -202,6 +216,16 @@ export class Recorder { return { bytes: buf.byteLength, sha256: createHash('sha256').update(buf).digest('hex') }; } + /** Whether `artifact.created` for this artifact id was recorded in the run. */ + hasArtifact(runId: string, artifactId: string): boolean { + return this.db.prepare('SELECT 1 FROM artifacts WHERE run_id = ? AND artifact_id = ?').get(runId, artifactId) !== undefined; + } + + /** + * The artifact's metadata and bytes, or null if unknown. Throws + * ArtifactIntegrityError if the bytes on disk no longer match the recorded + * sha256 (they were changed after the fact). + */ getArtifact(runId: string, artifactId: string): StoredArtifact | null { if (!SAFE_ID.test(artifactId)) return null; const row = this.db @@ -216,6 +240,9 @@ export class Recorder { } catch { return null; } + if (createHash('sha256').update(content).digest('hex') !== row.sha256) { + throw new ArtifactIntegrityError(runId, artifactId); + } return { runId, artifactId, @@ -233,51 +260,71 @@ export class Recorder { * died. Each tool call without a terminal event gets * `tool.call_failed{runtime_restarted}`, then the run gets * `run.completed{interrupted}`. Returns the recovered run ids. + * + * The crash time is unknown, so these events are stamped with the run's last + * recorded activity (the last moment it is known to have been alive), not + * the restart time: otherwise a replay would span the whole downtime. The + * actual recovery time is kept in the run summary. */ recoverInterrupted(): string[] { const rows = this.db.prepare(`SELECT run_id FROM runs WHERE status = 'running' ORDER BY created_at`).all() as { run_id: string; }[]; + const recoveredAt = this.now().toISOString(); for (const { run_id: runId } of rows) { this.runs.delete(runId); const open = new Map>(); + let lastSeenMs = 0; for (const e of this.listAll(runId)) { + lastSeenMs = Math.max(lastSeenMs, Date.parse(e.timestamp)); if (e.type === 'tool.call_started') open.set(e.payload.callId, e); else if (e.type === 'tool.call_completed' || e.type === 'tool.call_failed') open.delete(e.payload.callId); } - const nowMs = this.now().getTime(); + const at = new Date(lastSeenMs); for (const started of open.values()) { - this.append(runId, { - type: 'tool.call_failed', - payload: { - callId: started.payload.callId, - errorType: 'runtime_restarted', - message: 'The runtime stopped before this call finished', - durationMs: Math.max(0, nowMs - Date.parse(started.timestamp)), + this.write( + runId, + { + type: 'tool.call_failed', + payload: { + callId: started.payload.callId, + errorType: 'runtime_restarted', + message: 'The runtime stopped before this call finished', + // Known running time only (up to the last recorded event). + durationMs: Math.max(0, lastSeenMs - Date.parse(started.timestamp)), + }, + actorId: started.payload.agentId, + ...(started.payload.taskId ? { taskId: started.payload.taskId } : {}), + parentEventId: started.eventId, }, - actorId: started.payload.agentId, - ...(started.payload.taskId ? { taskId: started.payload.taskId } : {}), - parentEventId: started.eventId, - }); + at, + ); } - this.append(runId, { - type: 'run.completed', - payload: { outcome: 'interrupted', summary: 'The runtime stopped while this run was active' }, - actorId: 'system', - }); + this.write( + runId, + { + type: 'run.completed', + payload: { + outcome: 'interrupted', + summary: `The runtime stopped while this run was active (recovered at ${recoveredAt})`, + }, + actorId: 'system', + }, + at, + ); } return rows.map((r) => r.run_id); } // ── internals ─────────────────────────────────────────────────── - private build(runId: string, sequence: number, draft: EventDraft): RunEvent { + private build(runId: string, sequence: number, draft: EventDraft, at: Date = this.now()): RunEvent { const { event: d } = redactEvent(draft, { secrets: this.secrets }); const raw = { eventId: this.newId(), runId, sequence, - timestamp: this.now().toISOString(), + timestamp: at.toISOString(), type: d.type, schemaVersion: SCHEMA_VERSION, ...deriveRefs(d), diff --git a/packages/runtime/src/run-manager.ts b/packages/runtime/src/run-manager.ts index dda44d8..f5cdc98 100644 --- a/packages/runtime/src/run-manager.ts +++ b/packages/runtime/src/run-manager.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { cpSync, mkdirSync } from 'node:fs'; import path from 'node:path'; -import type { RunSummary } from '@runloop-world/events'; +import type { PayloadOf, RunSummary } from '@runloop-world/events'; import { abortError, isAdapterEventType, @@ -213,6 +213,12 @@ export class RunManager { emit(type, payload, meta) { if (signal.aborted) throw abortError(); if (!isAdapterEventType(type)) throw new Error(`Adapters may not emit ${type}`); + if (type === 'task.completed') { + // A completion may only cite artifacts the tool invoker actually recorded in this run. + const cited = (payload as PayloadOf<'task.completed'>).artifactIds; + const unknown = cited.filter((id) => !recorder.hasArtifact(runId, id)); + if (unknown.length > 0) throw new Error(`task.completed cites artifacts not recorded in this run: ${unknown.join(', ')}`); + } // The generic draft type cannot be narrowed from T here; the recorder validates strictly. return recorder.append(runId, { type, payload, ...meta } as never); }, diff --git a/packages/runtime/src/sandbox/command.ts b/packages/runtime/src/sandbox/command.ts index 48c76d6..9d846dd 100644 --- a/packages/runtime/src/sandbox/command.ts +++ b/packages/runtime/src/sandbox/command.ts @@ -29,8 +29,12 @@ export function isAllowedCommand(argv: readonly string[]): boolean { } /** - * The child sees only PATH, HOME (= the workspace) and NO_COLOR: no - * NODE_OPTIONS, loaders or credentials from the runtime's environment. + * The child's environment holds only PATH, HOME (= the workspace) and + * NO_COLOR, so no NODE_OPTIONS or loaders reach it. This is not isolation: + * `node --test` runs the workspace's JS as the runtime's user, which can still + * read the parent's environment via /proc and write anywhere that user can + * (including the data dir). Fine for the scripted demo; an LLM adapter needs + * a real sandbox (container / bubblewrap / nsjail) first. */ export function minimalEnv(cwd: string): NodeJS.ProcessEnv { return { PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', HOME: cwd, NO_COLOR: '1' }; diff --git a/packages/runtime/src/trace-printer.ts b/packages/runtime/src/trace-printer.ts index b9ec630..55886e0 100644 --- a/packages/runtime/src/trace-printer.ts +++ b/packages/runtime/src/trace-printer.ts @@ -1,91 +1,30 @@ +import { formatEvent, initialExecState, reduceExecution, type ExecState } from '@runloop-world/core'; import type { AnyEvent } from '@runloop-world/events'; -function clock(ms: number): string { - const t = Math.max(0, ms); - const m = Math.floor(t / 60_000); - const s = Math.floor((t % 60_000) / 1000); - const d = Math.floor((t % 1000) / 100); - return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${d}`; -} - -function clip(text: string, max = 96): string { +function clip(text: string, max = 110): string { const one = text.replace(/\s+/g, ' ').trim(); return one.length > max ? `${one.slice(0, max - 1)}…` : one; } /** - * One text line per event, built from payload fields only, e.g. - * `#036 00:21.4 tester tool.call_completed run_command error 12 tests: 10 passed, 2 failed`. - * Keeps a little state (run start, call → tool, attempts) across events. + * The `demo` text trace: one line per recorded event, e.g. + * `#036 00:15.6 tester tool.call_completed run_command error · 10 passed, 2 failed — 12 tests: …`. + * Wording comes from core's formatEvent (the same text Studio shows); this + * class only folds the Layer-1 state it reads (run start, tool names, owners). */ export class TracePrinter { - private startMs: number | null = null; - private readonly tools = new Map(); - private readonly attempts = new Map(); - /** taskId → agent working on it, to name an actor for task.* events that carry none. */ - private readonly owners = new Map(); + private runId: string | null = null; + private state: ExecState = initialExecState(); format(e: AnyEvent): string { - const ts = Date.parse(e.timestamp); - if (this.startMs === null || e.type === 'run.started') this.startMs = ts; - if (!('unknown' in e) && (e.type === 'task.assigned' || e.type === 'task.started')) { - this.owners.set(e.payload.taskId, e.payload.agentId); - } - const actor = e.actorId ?? (e.taskId ? this.owners.get(e.taskId) : undefined) ?? '-'; - const head = `#${String(e.sequence).padStart(3, '0')} ${clock(ts - this.startMs)} ${actor.padEnd(11)} ${e.type.padEnd(20)}`; - const detail = this.detail(e); - return detail ? `${head} ${detail}` : head.trimEnd(); - } - - private detail(e: AnyEvent): string { - if ('unknown' in e) return `(unreadable event: ${e.reason})`; - switch (e.type) { - case 'run.started': - return `"${e.payload.title}" · ${e.payload.adapter.label} · ${e.payload.workspace}`; - case 'run.paused': - return e.payload.reason ?? ''; - case 'run.resumed': - return ''; - case 'run.completed': - return `${e.payload.outcome.toUpperCase()}${e.payload.summary ? ` — ${e.payload.summary}` : ''}`; - case 'agent.registered': - return `${e.payload.name} (${e.payload.role})${e.payload.tools.length ? ` tools: ${e.payload.tools.join(', ')}` : ''}${e.payload.embodied ? '' : ' [not embodied]'}`; - case 'agent.state_changed': - return `${e.payload.agentId} → ${e.payload.state}${e.payload.reason ? ` (${e.payload.reason})` : ''}`; - case 'agent.message_sent': - return `→ ${e.payload.to}: ${clip(e.payload.text)}`; - case 'task.created': - return `${e.payload.taskId} ${e.payload.kind} "${e.payload.title}"${e.payload.dependsOn.length ? ` after ${e.payload.dependsOn.join(', ')}` : ''}`; - case 'task.assigned': - return `${e.payload.taskId} → ${e.payload.agentId}`; - case 'task.started': { - const n = (this.attempts.get(e.payload.taskId) ?? 0) + 1; - this.attempts.set(e.payload.taskId, n); - return `${e.payload.taskId} attempt ${n}`; - } - case 'task.blocked': - return `${e.payload.taskId} ${e.payload.reason}${e.payload.blockedBy?.length ? ` (waiting on ${e.payload.blockedBy.join(', ')})` : ''}`; - case 'task.completed': - return `${e.payload.taskId} ${clip(e.payload.summary)}`; - case 'task.failed': - return `${e.payload.taskId} ${clip(e.payload.reason)}`; - case 'tool.call_started': - this.tools.set(e.payload.callId, e.payload.tool); - return `${e.payload.tool} ${clip(e.payload.inputSummary, 60)}`; - case 'tool.call_completed': { - const tool = this.tools.get(e.payload.callId) ?? e.payload.callId; - return `${tool} ${e.payload.status} ${e.payload.outputSummary} (${Math.round(e.payload.durationMs)} ms)`; - } - case 'tool.call_failed': { - const tool = this.tools.get(e.payload.callId) ?? e.payload.callId; - return `${tool} FAILED ${e.payload.errorType}: ${clip(e.payload.message)}`; - } - case 'tool.permission_requested': - return `${e.payload.action} ${e.payload.scope}`; - case 'tool.permission_resolved': - return `${e.payload.permissionId} ${e.payload.decision} by ${e.payload.by}`; - case 'artifact.created': - return `${e.payload.artifactId} ${e.payload.kind} ${e.payload.path ?? e.payload.name} (${e.payload.bytes} B)`; + if (e.runId !== this.runId) { + this.runId = e.runId; + this.state = initialExecState(); } + // The bus publishes each run's recorded events in sequence order. + this.state = reduceExecution(this.state, e); + const f = formatEvent(e, this.state); + const head = `#${String(f.seq).padStart(3, '0')} ${f.clock} ${f.actor.padEnd(11)} ${f.type.padEnd(20)}`; + return `${head} ${clip(f.label, 90)}${f.detail ? ` — ${clip(f.detail)}` : ''}`; } } diff --git a/packages/runtime/test/helpers.ts b/packages/runtime/test/helpers.ts index 53cbb7e..0824db9 100644 --- a/packages/runtime/test/helpers.ts +++ b/packages/runtime/test/helpers.ts @@ -33,7 +33,7 @@ export const MANIFEST: RunManifest = { }; /** A recorder on a file DB in `dataDir`; listener errors are swallowed quietly. */ -export function openRecorder(dataDir: string, opts: { secrets?: string[] } = {}) { +export function openRecorder(dataDir: string, opts: { secrets?: string[]; now?: () => Date } = {}) { const file = path.join(dataDir, 'runloop.db'); const db = openDb(file); const recorder = new Recorder({ db, dataDir, bus: new EventBus(() => {}), ...opts }); diff --git a/packages/runtime/test/http.test.ts b/packages/runtime/test/http.test.ts index 87b1215..8159521 100644 --- a/packages/runtime/test/http.test.ts +++ b/packages/runtime/test/http.test.ts @@ -1,6 +1,8 @@ import { apiPaths, type EventsPageResponse, type RunDetailResponse } from '@runloop-world/events'; import { parseWorldDefinition } from '@runloop-world/world-engine'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { main } from '../src/cli'; +import { isLoopbackAddress } from '../src/http/app'; import { createRuntime, type Runtime } from '../src/runtime'; import { MANIFEST, REPO_ROOT, openRecorder, tempDir } from './helpers'; @@ -37,6 +39,35 @@ describe('HTTP API', () => { expect((await app.request(apiPaths.health, { headers: { host: 'localhost:9999' } })).status).toBe(200); }); + it('refuses connections from non-loopback peers, even with a spoofed localhost Host', async () => { + const { app } = runtime(); + const peer = (remoteAddress: string) => ({ incoming: { socket: { remoteAddress } } }); + const spoofed = { headers: { host: 'localhost' } }; + expect((await app.request(apiPaths.health, spoofed, peer('192.0.2.2'))).status).toBe(403); + expect((await app.request(apiPaths.health, spoofed, peer('::ffff:10.0.0.7'))).status).toBe(403); + for (const local of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) { + expect((await app.request(apiPaths.health, spoofed, peer(local))).status).toBe(200); + } + }); + + it('isLoopbackAddress accepts only loopback addresses', () => { + for (const a of ['127.0.0.1', '127.1.2.3', '::1', '[::1]', '::ffff:127.0.0.1']) expect(isLoopbackAddress(a), a).toBe(true); + for (const a of ['0.0.0.0', '192.168.1.2', '::', '::ffff:10.0.0.1', 'localhost.evil', '1127.0.0.1']) { + expect(isLoopbackAddress(a), a).toBe(false); + } + }); + + it('the CLI refuses to listen on a non-loopback host', async () => { + const errors: unknown[] = []; + const spy = vi.spyOn(console, 'error').mockImplementation((...a) => void errors.push(a.join(' '))); + try { + expect(await main(['serve', '--host', '0.0.0.0', '--port', '0'])).toBe(1); + } finally { + spy.mockRestore(); + } + expect(String(errors[0])).toContain('loopback'); + }); + it('sends CORS headers only to allowed origins', async () => { const { app } = runtime(); for (const origin of ['http://localhost:3000', 'http://127.0.0.1:3000']) { diff --git a/packages/runtime/test/recorder.test.ts b/packages/runtime/test/recorder.test.ts index b89ad55..906242b 100644 --- a/packages/runtime/test/recorder.test.ts +++ b/packages/runtime/test/recorder.test.ts @@ -1,8 +1,9 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { describe, expect, it } from 'vitest'; import { collectEnvSecrets } from '../src/config'; -import { EventValidationError, RunClosedError } from '../src/recorder'; +import { ArtifactIntegrityError, EventValidationError, RunClosedError } from '../src/recorder'; import { MANIFEST, openRecorder, tempDir } from './helpers'; const RUN = '11111111-1111-4111-8111-111111111111'; @@ -140,6 +141,49 @@ describe('Recorder', () => { expect(rebooted.recorder.recoverInterrupted()).toEqual([]); }); + it('stamps recovery events at the last recorded activity, so a replay does not span the downtime', () => { + const dir = tempDir(); + const t0 = Date.parse('2026-10-17T09:00:00.000Z'); + let clock = t0; + const now = () => new Date(clock); + const crashed = openRecorder(dir, { now }); + crashed.recorder.createRun({ runId: RUN, manifest: MANIFEST }); + clock += 1000; + crashed.recorder.append(RUN, { + type: 'tool.call_started', + payload: { callId: 'call-1', agentId: 'tester', taskId: 'T3', tool: 'run_command', inputSummary: 'node --test' }, + }); + clock += 2500; + const last = crashed.recorder.append(RUN, { type: 'agent.state_changed', payload: { agentId: 'builder', state: 'idle' } }); + crashed.db.close(); + + clock += 24 * 3600_000; // restarted a day later + const rebooted = openRecorder(dir, { now }); + rebooted.recorder.recoverInterrupted(); + const [failed, completed] = rebooted.recorder.list(RUN, { after: 3 }); + expect(failed!.timestamp).toBe(last.timestamp); + expect(completed!.timestamp).toBe(last.timestamp); + expect(failed!.payload).toMatchObject({ errorType: 'runtime_restarted', durationMs: 2500 }); + expect(completed!.payload).toMatchObject({ outcome: 'interrupted', summary: expect.stringContaining(new Date(clock).toISOString()) }); + expect(rebooted.recorder.getRun(RUN)?.endedAt).toBe(last.timestamp); + }); + + it('refuses to serve artifact bytes that changed after they were recorded', () => { + const dir = tempDir(); + const { recorder } = openRecorder(dir); + recorder.createRun({ runId: RUN, manifest: MANIFEST }); + const { bytes, sha256 } = recorder.putArtifactContent(RUN, 'art-1', 'ok 1 - passes\n'); + recorder.append(RUN, { + type: 'artifact.created', + payload: { artifactId: 'art-1', kind: 'log', name: 'out.log', mediaType: 'text/plain', bytes, sha256, summary: 'log' }, + }); + expect(recorder.hasArtifact(RUN, 'art-1')).toBe(true); + expect(recorder.hasArtifact(RUN, 'art-2')).toBe(false); + expect(recorder.getArtifact(RUN, 'art-1')?.content.toString()).toBe('ok 1 - passes\n'); + writeFileSync(path.join(dir, 'artifacts', RUN, 'art-1'), 'REWRITTEN AFTER THE FACT'); + expect(() => recorder.getArtifact(RUN, 'art-1')).toThrow(ArtifactIntegrityError); + }); + it('refuses events after run.completed', () => { const { recorder } = openRecorder(tempDir()); recorder.createRun({ runId: RUN, manifest: MANIFEST }); diff --git a/packages/runtime/test/run-manager.test.ts b/packages/runtime/test/run-manager.test.ts new file mode 100644 index 0000000..831a6e0 --- /dev/null +++ b/packages/runtime/test/run-manager.test.ts @@ -0,0 +1,65 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AgentAdapter } from '../src/adapter'; +import { createRuntime, type Runtime } from '../src/runtime'; +import { REPO_ROOT, tempDir } from './helpers'; + +let rt: Runtime | null = null; +afterEach(async () => { + await rt?.close(); + rt = null; +}); + +/** A missions dir with one mission run by `adapter`. */ +function runtimeWith(adapter: AgentAdapter): Runtime { + const missions = tempDir('missions'); + const dir = path.join(missions, 'probe'); + mkdirSync(path.join(dir, 'repo'), { recursive: true }); + writeFileSync(path.join(dir, 'repo', 'README.md'), '# probe\n'); + writeFileSync( + path.join(dir, 'mission.json'), + JSON.stringify({ id: 'probe', title: 'Probe', worldId: 'research-campus', seed: 1, adapter: adapter.info.id }), + ); + rt = createRuntime({ repoRoot: REPO_ROOT, dataDir: tempDir('rm'), env: {}, missionsDir: missions, adapters: [adapter] }); + return rt; +} + +describe('RunManager adapter guards', () => { + it('refuses a task.completed that cites artifacts the run never recorded', async () => { + const liar: AgentAdapter = { + info: { id: 'liar', kind: 'llm', label: 'Never runs a tool' }, + async run(ctx) { + ctx.emit('task.created', { taskId: 'T1', title: 'Tests', kind: 'test', dependsOn: [] }); + ctx.emit('task.started', { taskId: 'T1', agentId: 'tester' }); + ctx.emit('task.completed', { taskId: 'T1', summary: 'All 12 tests pass', artifactIds: ['art-99'] }); + return { outcome: 'succeeded', summary: '12/12 tests pass' }; + }, + }; + const r = runtimeWith(liar); + const { runId } = r.manager.start({ missionId: 'probe', speed: 0 }); + const summary = await r.manager.whenDone(runId); + const events = r.recorder.listAll(runId); + expect(events.map((e) => e.type)).toEqual(['run.started', 'task.created', 'task.started', 'run.completed']); + expect(summary.status).toBe('failed'); + expect(events.at(-1)!.payload).toMatchObject({ outcome: 'failed', summary: expect.stringContaining('art-99') }); + }); + + it('accepts a task.completed citing an artifact a real tool call produced', async () => { + const honest: AgentAdapter = { + info: { id: 'honest', kind: 'llm', label: 'Writes a file' }, + async run(ctx) { + ctx.emit('task.created', { taskId: 'T1', title: 'Write', kind: 'code', dependsOn: [] }); + ctx.emit('task.started', { taskId: 'T1', agentId: 'builder' }); + const w = await ctx.tools.invoke('builder', 'T1', { tool: 'write_file', path: 'a.txt', content: 'hi\n' }); + ctx.emit('task.completed', { taskId: 'T1', summary: 'wrote a.txt', artifactIds: w.artifactIds }); + return { outcome: 'succeeded', summary: 'done' }; + }, + }; + const r = runtimeWith(honest); + const { runId } = r.manager.start({ missionId: 'probe', speed: 0 }); + expect((await r.manager.whenDone(runId)).status).toBe('succeeded'); + const done = r.recorder.listAll(runId).find((e) => e.type === 'task.completed'); + expect(done?.payload).toMatchObject({ artifactIds: ['art-1'] }); + }); +}); diff --git a/packages/runtime/test/trace-printer.test.ts b/packages/runtime/test/trace-printer.test.ts index 959bff8..816be9b 100644 --- a/packages/runtime/test/trace-printer.test.ts +++ b/packages/runtime/test/trace-printer.test.ts @@ -7,10 +7,20 @@ describe('TracePrinter', () => { const p = new TracePrinter(); const lines = codingMissionFixture().map((e) => p.format(e)); expect(lines).toHaveLength(58); - expect(lines[0]).toMatch(/^#001 00:00\.0 system {6}run\.started {10}"Build a task store/); + expect(lines[0]).toMatch(/^#001 00:00\.0 system {6}run\.started {10}Run started: Build a task store/); const failed = lines.find((l) => l.includes('run_command error'))!; - expect(failed).toMatch(/^#036 \d\d:\d\d\.\d tester {6}tool\.call_completed run_command error 12 tests: 10 passed, 2 failed/); - expect(lines.find((l) => l.includes('task.started') && l.includes('T3 attempt 2'))).toBeDefined(); - expect(lines.at(-1)).toContain('run.completed SUCCEEDED'); + expect(failed).toMatch( + /^#036 \d\d:\d\d\.\d tester {6}tool\.call_completed run_command error · 10 passed, 2 failed — 12 tests: 10 passed, 2 failed/, + ); + expect(lines.find((l) => l.includes('task.started') && l.includes('Started T3 (attempt 2)'))).toBeDefined(); + expect(lines.at(-1)).toMatch(/run\.completed {8}Run succeeded/); + }); + + it('starts over for a different run', () => { + const p = new TracePrinter(); + const a = codingMissionFixture(); + p.format(a[0]!); + const b = codingMissionFixture({ runId: 'other-run' }); + expect(p.format(b[0]!)).toMatch(/^#001 00:00\.0 system/); }); }); diff --git a/packages/world-engine/src/intent.ts b/packages/world-engine/src/intent.ts index 6ee27ff..770b4d5 100644 --- a/packages/world-engine/src/intent.ts +++ b/packages/world-engine/src/intent.ts @@ -1,4 +1,4 @@ -import type { ExecState } from '@runloop-world/core'; +import type { ExecState, RunExecStatus } from '@runloop-world/core'; import type { TaskKind } from '@runloop-world/events'; import type { ActivityRule, StationKind } from './definition'; import type { Activity, CompiledWorld } from './types'; @@ -12,6 +12,8 @@ export interface Intent { const IDLE: Intent = { activity: 'idle', taskId: null, stationKind: null }; +const RUN_ENDED: ReadonlySet = new Set(['succeeded', 'failed', 'stopped', 'interrupted']); + /** * First matching activity rule. Rules with `permission: true` only apply to a * pending permission request, and other rules only apply to work. @@ -31,7 +33,11 @@ export function matchActivityRule( /** * What the world wants an agent's character to do, from Layer-1 state only. * Priority: failed → awaiting_approval → waiting (blocked) → working → - * succeeded (last task completed, nothing current) → idle. + * succeeded (a task completed in this very event, nothing current) → idle. + * + * Once the run has ended (succeeded / failed / stopped / interrupted) nothing + * executes any more, so tasks left unfinished no longer drive work, waiting or + * approval: those characters go home idle and free their station slots. */ export function deriveIntent(exec: ExecState, agentId: string, world: CompiledWorld): Intent { const agent = exec.agents[agentId]; @@ -45,6 +51,8 @@ export function deriveIntent(exec: ExecState, agentId: string, world: CompiledWo return { activity: 'failed', taskId: task?.id ?? last?.taskId ?? null, stationKind: null }; } + if (RUN_ENDED.has(exec.run.status)) return IDLE; + const perm = agent.pendingPermissionId ? exec.permissions[agent.pendingPermissionId] : undefined; if (perm?.status === 'pending') { return { @@ -64,6 +72,9 @@ export function deriveIntent(exec: ExecState, agentId: string, world: CompiledWo }; } - if (!task && last?.status === 'completed') return { activity: 'succeeded', taskId: last.taskId, stationKind: null }; + // A reaction to the completion itself: an old completion never re-triggers it. + if (!task && last?.status === 'completed' && last.seq === exec.lastSequence) { + return { activity: 'succeeded', taskId: last.taskId, stationKind: null }; + } return IDLE; } diff --git a/packages/world-engine/src/motion.test.ts b/packages/world-engine/src/motion.test.ts index 11bcd14..6a5beea 100644 --- a/packages/world-engine/src/motion.test.ts +++ b/packages/world-engine/src/motion.test.ts @@ -72,10 +72,18 @@ describe('ticks', () => { expect(msToTick(0, 10)).toBe(0); }); - it('eventTick floors and never goes backwards', () => { + it('arrives exactly at the arrival time even after a ms ↔ tick round trip', () => { + for (let ms = 0; ms < 20_000; ms += 7) { + const motion: Motion = { path: [[0, 0], [1, 0], [2, 0], [3, 0]], startTick: msToTick(ms, 10) }; + const arrivalMs = (arrivalTick(motion, 2) / 10) * 1000; // e.g. a playhead parked at the end of the walk + expect(positionAt(motion, msToTick(arrivalMs, 10), 2).arrived, `start ${ms} ms`).toBe(true); + } + }); + + it('eventTick keeps the exact (fractional) time and never goes backwards', () => { const start = 1_000_000; - expect(eventTick(0, start + 1290, start, 10)).toBe(12); - expect(eventTick(12, start + 900, start, 10)).toBe(12); // earlier timestamp → clamped + expect(eventTick(0, start + 1290, start, 10)).toBeCloseTo(12.9); + expect(eventTick(12.9, start + 900, start, 10)).toBe(12.9); // earlier timestamp → clamped expect(eventTick(0, start - 500, start, 10)).toBe(0); expect(eventTick(12, start + 5000, start, 10)).toBe(50); }); diff --git a/packages/world-engine/src/motion.ts b/packages/world-engine/src/motion.ts index cec37eb..5903499 100644 --- a/packages/world-engine/src/motion.ts +++ b/packages/world-engine/src/motion.ts @@ -15,6 +15,13 @@ export interface MotionSample { arrived: boolean; } +/** + * Ticks are fractional, so a ms → tick → ms round trip (e.g. a playhead parked + * at a walk's arrival time) can land a hair before the end. Within this many + * steps of the last cell counts as arrived (far below anything visible). + */ +const ARRIVAL_EPS = 1e-9; + /** * Closed-form position on a motion at `tick` (fractional ticks allowed, giving * smooth continuous x/y). No tick loop: one cell per `ticksPerCell` ticks. @@ -23,7 +30,7 @@ export function positionAt(motion: Motion, tick: number, ticksPerCell: number): const { path } = motion; const last = path.length - 1; const steps = Math.max(0, tick - motion.startTick) / ticksPerCell; - if (steps >= last) { + if (steps >= last - ARRIVAL_EPS) { const end = path[last]!; return { x: end[0], y: end[1], cell: end, next: end, frac: 0, arrived: true }; } @@ -58,12 +65,13 @@ export function msToTick(ms: number, tickHz: number): number { } /** - * Integer tick of an event: floor of its time since run start, clamped so it + * Tick of an event: its exact (fractional) time since run start, clamped so it * never goes below the previous event's tick (timestamps may be non-monotonic; - * order always comes from `sequence`). + * order always comes from `sequence`). Not floored, so a new route branches + * exactly where the playhead is when the event happens (no jump). */ export function eventTick(prevTick: number, eventMs: number, runStartMs: number, tickHz: number): number { - return Math.max(prevTick, 0, Math.floor(msToTick(eventMs - runStartMs, tickHz))); + return Math.max(prevTick, 0, msToTick(eventMs - runStartMs, tickHz)); } const sameCell = (a: Cell, b: Cell) => a[0] === b[0] && a[1] === b[1]; @@ -75,7 +83,7 @@ const sameCell = (a: Cell, b: Cell) => a[0] === b[0] && a[1] === b[1]; function interruptionPoint(motion: Motion, tick: number, ticksPerCell: number) { const last = motion.path.length - 1; const steps = Math.max(0, tick - motion.startTick) / ticksPerCell; - if (steps >= last) return { i: last, midStep: false }; + if (steps >= last - ARRIVAL_EPS) return { i: last, midStep: false }; const i = Math.floor(steps); return { i, midStep: steps > i }; } diff --git a/packages/world-engine/src/reduce-world.test.ts b/packages/world-engine/src/reduce-world.test.ts index e823018..342e656 100644 --- a/packages/world-engine/src/reduce-world.test.ts +++ b/packages/world-engine/src/reduce-world.test.ts @@ -284,7 +284,8 @@ describe('reduceWorld — intents', () => { steps = foldSteps(b.events, world); w = steps.at(-1)!.world; expect(w.paused).toBe(false); - expect(w.characters.r).toMatchObject({ activity: 'succeeded', target: { kind: 'home' } }); + // The completion happened during the pause, so there is no success reaction now: just idle. + expect(w.characters.r).toMatchObject({ activity: 'idle', target: { kind: 'home' } }); expect(w.characters.r!.motion.path.at(-1)).toEqual(w.characters.r!.home); expect(w.stations.archive!.slots).toEqual([null]); expectContinuous(steps, world, ['r']); @@ -304,6 +305,74 @@ describe('reduceWorld — intents', () => { }); }); +describe('reduceWorld — ended runs and success reactions', () => { + /** Tester working on T1 at the probe, mid tool call. */ + function testerAtWork() { + const b = startRun([{ id: 't', preset: 'tester' }]); + b.wait(100).add('task.created', { taskId: 'T1', title: '', kind: 'test', dependsOn: [] }); + b.wait(100).add('task.started', { taskId: 'T1', agentId: 't' }); + b.wait(3000).add('tool.call_started', { callId: 'c1', agentId: 't', taskId: 'T1', tool: 'run_command', inputSummary: 'node --test' }); + return b; + } + + function expectSentHome(steps: Step[]) { + const w = steps.at(-1)!.world; + const c = w.characters.t!; + expect(steps.at(-1)!.exec.tasks.T1!.status).toBe('in_progress'); // Layer 1 still records the unfinished task + expect(c).toMatchObject({ activity: 'idle', taskId: null, stationId: null, target: { kind: 'home' } }); + expect(w.stations.probe!.slots).toEqual([null]); + expect(c.motion.path.at(-1)).toEqual(c.home); + expect(presentationAt(c, w.tick + 600, world).state).toBe('idle'); + expectContinuous(steps, world, ['t']); + } + + it('a stopped run sends a working character home idle and frees its slot', () => { + const b = testerAtWork(); + expect(finalWorld(b.events, world).characters.t).toMatchObject({ activity: 'working', stationId: 'probe' }); + b.wait(250).add('tool.call_failed', { callId: 'c1', errorType: 'aborted', message: 'stopped', durationMs: 250 }); + b.wait(10).add('run.completed', { outcome: 'stopped', summary: 'Stopped by user' }); + expectSentHome(foldSteps(b.events, world)); + }); + + it('an interrupted run (boot recovery) does the same', () => { + const b = testerAtWork(); + b.wait(1).add('tool.call_failed', { callId: 'c1', errorType: 'runtime_restarted', message: 'restarted', durationMs: 0 }); + b.add('run.completed', { outcome: 'interrupted', summary: 'The runtime restarted' }); + expectSentHome(foldSteps(b.events, world)); + }); + + it('shows succeeded only on the completing event, never for an old completion', () => { + const b = startRun([{ id: 'a', preset: 'builder' }, { id: 'b', preset: 'researcher' }]); + b.wait(100).add('task.created', { taskId: 'T1', title: '', kind: 'code', dependsOn: [] }); + b.wait(100).add('task.started', { taskId: 'T1', agentId: 'a' }); + b.wait(3000).add('task.completed', { taskId: 'T1', summary: '', artifactIds: [] }); + let w = finalWorld(b.events, world); + expect(w.characters.a).toMatchObject({ activity: 'succeeded', taskId: 'T1', sinceTick: w.tick }); + expect(presentationAt(w.characters.a!, w.tick + 1, world).state).toBe('succeeded'); + + // A later unrelated event keeps the reaction (and its start tick) until presentation fades it. + b.wait(500).add('agent.state_changed', { agentId: 'b', state: 'idle' }); + const kept = finalWorld(b.events, world).characters.a!; + expect(kept).toMatchObject({ activity: 'succeeded', sinceTick: w.tick }); + + // 10 s later a new task is assigned, then reassigned away: back to idle, not a fresh ✓. + b.wait(10_000).add('task.created', { taskId: 'T2', title: '', kind: 'code', dependsOn: [] }); + b.wait(100).add('task.assigned', { taskId: 'T2', agentId: 'a' }); + expect(finalWorld(b.events, world).characters.a!.activity).toBe('working'); + b.wait(2000).add('task.assigned', { taskId: 'T2', agentId: 'b' }); + w = finalWorld(b.events, world); + expect(w.characters.a).toMatchObject({ activity: 'idle', taskId: null }); + expect(presentationAt(w.characters.a!, w.tick + 1, world).state).not.toBe('succeeded'); + + // A denied permission long after a completion is not a success either. + b.wait(100).add('tool.permission_requested', { permissionId: 'p1', agentId: 'a', action: 'deploy', scope: 'prod' }); + expect(finalWorld(b.events, world).characters.a!.activity).toBe('awaiting_approval'); + b.wait(60_000).add('tool.permission_resolved', { permissionId: 'p1', decision: 'denied', by: 'user' }); + w = finalWorld(b.events, world); + expect(w.characters.a).toMatchObject({ activity: 'idle', taskId: null }); + }); +}); + describe('reduceWorld — coding mission', () => { const events = codingMissionFixture(); const steps = foldSteps(events, world); diff --git a/packages/world-engine/src/reduce-world.ts b/packages/world-engine/src/reduce-world.ts index 84034fc..2e65da6 100644 --- a/packages/world-engine/src/reduce-world.ts +++ b/packages/world-engine/src/reduce-world.ts @@ -174,6 +174,9 @@ class Sim { for (const id of this.characterOrder) { const c = this.characters[id]!; const intent = deriveIntent(exec, id, this.world); + // 'succeeded' is only entered on the completing event; it then lasts until + // something else changes (presentationAt fades it to idle after a moment). + if (c.activity === 'succeeded' && intent.activity === 'idle') continue; // With fixed rules, the station kind is a function of (activity, task). if (intent.activity !== c.activity || intent.taskId !== c.taskId) changed.push({ id, intent }); } @@ -190,7 +193,8 @@ class Sim { stationId: target.kind === 'slot' || target.kind === 'overflow' ? target.stationId : null, target, motion: this.route(c, target), - sinceTick: intent.activity !== c.activity ? this.tick : c.sinceTick, + // Entering 'succeeded' always means a fresh completion, so its reaction restarts. + sinceTick: intent.activity !== c.activity || intent.activity === 'succeeded' ? this.tick : c.sinceTick, }); } diff --git a/packages/world-engine/src/test-utils.ts b/packages/world-engine/src/test-utils.ts index a8a6f7d..0ab3f10 100644 --- a/packages/world-engine/src/test-utils.ts +++ b/packages/world-engine/src/test-utils.ts @@ -11,7 +11,11 @@ import type { CompiledWorld, WorldState } from './types'; export const campusJson = (): WorldDefinition => JSON.parse(readFileSync(new URL('../../../worlds/research-campus/world.json', import.meta.url), 'utf8')); -export const campus = (): CompiledWorld => loadWorld(campusJson()); +/** + * The campus layout with its walk speed pinned to 3 ticks per cell, so the + * tick arithmetic in tests doesn't change when world.json's speed is retuned. + */ +export const campus = (): CompiledWorld => loadWorld({ ...campusJson(), ticksPerCell: 3 }); export interface Step { event: AnyEvent; diff --git a/worlds/research-campus/world.json b/worlds/research-campus/world.json index 58c6df8..158edd1 100644 --- a/worlds/research-campus/world.json +++ b/worlds/research-campus/world.json @@ -4,7 +4,7 @@ "name": "Research Campus", "description": "Four cutaway rooms around a central hub: Research, Build, Test and Deploy.", "tickHz": 10, - "ticksPerCell": 3, + "ticksPerCell": 2, "grid": [ "........................", ".#########....#########.", From 38503ac548472fdb27d0487698adca4e46ecf109 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:36:49 +0000 Subject: [PATCH 13/18] Add README quickstart, Apache-2.0 license and CI Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- .github/workflows/ci.yml | 33 +++++++ LICENSE | 202 +++++++++++++++++++++++++++++++++++++++ README.md | 56 ++++++++++- 3 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b8df6d7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + + # Installs the pnpm version pinned in package.json ("packageManager"). + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7d0a3b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 The Runloop Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 3e7248c..73fecdb 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# runloop \ No newline at end of file +# Runloop + +A 3D living world for AI agents. Runloop runs agent work locally with real tools, records every event, and shows that work as characters moving between stations in a small diorama. You can inspect the trace and replay any run without executing anything again. + +Three layers stay strictly apart: + +1. **Execution truth.** The append-only event log. Tool results only come from tools that really ran. +2. **World simulation.** A pure function of the log and a world definition: where each character is and what it is doing. +3. **Presentation.** The Studio draws layer 2 and never writes back to it. + +Live view and replay fold the same events through the same reducers, so they always agree. + +## Quickstart + +You need Node 22.16 or newer and pnpm 10. + +```sh +pnpm install +pnpm dev # runtime API on http://127.0.0.1:4317, Studio on http://localhost:3000 +``` + +Open http://localhost:3000 and click **Run demo mission**. Three agents survey a small repo, implement a task store and run its test suite. The first test run really fails (10 passed, 2 failed), the Builder patches the bug, and the rerun passes 12/12. Click a timeline row or a character to see its events and artifacts. Runs are kept across runtime restarts and replay from the log. + +Other commands: + +```sh +pnpm demo # run the mission headless and print one trace line per event +pnpm test # unit and integration tests (vitest) +pnpm typecheck +pnpm build # static export of the Studio +``` + +Runs are stored in `.runloop/` at the repo root; set `RUNLOOP_DATA_DIR` to use another directory. + +The demo uses a scripted adapter. Its decisions are fixed in code, but every tool call (listing and reading files, writing code, running `node --test`) really executes in a per-run copy of the example repo, and the recorded results come from that execution. There is no LLM adapter yet; the adapter interface is in `packages/runtime/src/adapter.ts`. + +The runtime API has no authentication, so it only listens on loopback. `run_command` executes the workspace's tests as your user without OS-level isolation; that is safe for the fixed demo code, but read the sandbox notes in [packages/runtime/README.md](packages/runtime/README.md) before plugging in an adapter that writes its own code. + +## Repository layout + +``` +apps/studio Next.js Studio: runs, timeline, logs, replay bar and the 3D world (React Three Fiber) +packages/events event schema, parsing, redaction, SequenceBuffer, HTTP/SSE contract +packages/core layer 1: execution reducer, selectors, text formatting +packages/world-engine layer 2: world definition, grid paths, motion, character intents +packages/replay combined reducer and ReplayIndex (state at any sequence or time) +packages/runtime local runtime: SQLite recorder, tool sandbox, run manager, HTTP + SSE API, CLI +packages/testkit fixtures and helpers for tests +worlds/research-campus the default world (grid, rooms, stations) +examples/coding-mission the demo mission and its example repo +``` + +## License + +Apache-2.0. See [LICENSE](LICENSE). From c1250c376f2ad46086ad821471e4ba249cea24e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:41:11 +0000 Subject: [PATCH 14/18] Add product strategy, decision log, current state and task ledger Strategy and wedge from competitive and ecosystem research; decisions DL-001..006; a baseline with verified commands; the task ledger; and the M0 probe results from real Claude Code sessions added to the tracer plan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- TASKS.md | 73 ++++++++++++++++++++++ docs/plans/claude-code-tracer.md | 25 ++++++++ docs/product/strategy.md | 100 +++++++++++++++++++++++++++++++ docs/status/current-state.md | 49 +++++++++++++++ docs/status/decision-log.md | 94 +++++++++++++++++++++++++++++ 5 files changed, 341 insertions(+) create mode 100644 TASKS.md create mode 100644 docs/product/strategy.md create mode 100644 docs/status/current-state.md create mode 100644 docs/status/decision-log.md diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..9b11ff4 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,73 @@ +# Task ledger + +**States:** Not started · In progress · Blocked · Implemented, unverified · Verified · Deferred. + +A task is **Verified** only when its acceptance criteria were exercised and the evidence is recorded in `docs/status/current-state.md`. + +## Milestone A: Foundation + +| ID | Task | Status | Evidence / location | +|---|---|---|---| +| A1 | Event contract, Layer 1, Layer 2 and the replay engine | Verified | `packages/{events,core,world-engine,replay}`; 235 tests | +| A2 | Runtime: recorder, sandboxed tools, API, SSE, recovery | Verified | `packages/runtime`; integration tests; manual SIGINT/SIGKILL | +| A3 | Studio with live view, replay and the 3D world | Verified (prototype quality) | `apps/studio`; Playwright screenshots | +| A4 | Move the runtime port 4317 → 4380 (DL-005) | Not started | `packages/events/src/protocol.ts`, docs, studio config | +| A5 | Real command sandbox for `run_command` | Deferred | Only needed before untrusted adapters execute commands; documented in known limitations | + +## Milestone B: Claude Code tracer (the first real integration) + +Plan: `docs/plans/claude-code-tracer.md`. It depends on A4. + +| ID | Task | Status | Acceptance criteria | +|---|---|---|---| +| B0 | Probe real hook payloads (M0) | Verified | Two real `claude -p` sessions logged; findings in plan Appendix C | +| B1 | Schema: traced run mode, `external` adapter, provenance, `run.marker`, tool category, `turn`/`delegated` kinds, `unobserved` failure, usage events | Not started | Additive only; old logs parse; demo golden trace unchanged | +| B2 | Pure mapper (hook payload → event drafts) with golden tests from sanitised real fixtures | Not started | Every fixture folds with no unexpected anomalies; one terminal event per call; subagents attributed by `agent_id` | +| B3 | Ingest endpoint, token file, session manager, dedupe, idle sweep, mode-aware recovery, migration | Not started | 401 without token, 403 with an Origin, 413, idempotent retries, concurrent sessions in separate runs, scripted runs unaffected | +| B4 | Plugin (`integrations/claude-code-plugin`): async hooks, spool, `--doctor` | Not started | `claude plugin validate` passes; the script always exits 0 with empty stdout; offline spool then flush produces the same events | +| B5 | Studio: traced badge, provenance banner, inferred chips, live sessions, dynamic subagent robots, tool category → station | Not started | A traced session renders live; provenance is visible; every character is placed at a valid station | +| B6 | **End-to-end acceptance with real Claude Code** | Not started | A real `claude -p` session with the plugin produces a live run in Studio with the main agent plus subagent robots, a failing Bash test shown as an incident, survival across a page refresh, and replay without re-execution. An automated test covers the fixture path; the real path is recorded in the verification report | + +## Milestone C: Compare (the retention feature) + +| ID | Task | Status | Acceptance criteria | +|---|---|---|---| +| C1 | Pin a run as a baseline; pick any two runs to compare | Not started | Survives a restart (persisted) | +| C2 | Run diff: outcome, duration, tool calls by category, files touched, test results, tokens and cost where reported, permission waits, failures | Not started | Unit tests on the diff function; unknown values show as "not reported", never as zero | +| C3 | Compare view in Studio (side by side, with links to the differing events) | Not started | Clicking a difference opens the event in that run | + +## Milestone D: Core observability and design system + +| ID | Task | Status | Acceptance criteria | +|---|---|---|---| +| D1 | Search and filter in the timeline and logs (agent, tool, category, status, text) | Not started | Filters compose; URL-addressable; tested | +| D2 | Run metrics panel (duration, calls by category, failures, tokens where reported) | Not started | Defined calculations; missing data shown honestly | +| D3 | Design-system pass: tokens, typography, components, empty/loading/error states | Not started | Every page audited at 1440 and 1920 wide; contrast checked | +| D4 | 3D framing and readability: fit to viewport, label collision, camera presets | Not started | No clipped rooms at 1280–1920 width; labels never overlap at default zoom | + +## Milestone E: Generalisation + +| ID | Task | Status | Acceptance criteria | +|---|---|---|---| +| E1 | Documented adapter contract and event-model docs | Not started | A new integration can be written from the docs alone | +| E2 | Codex CLI hooks integration (DL-004) | Not started | Real Codex session traced end to end, or fixture-verified if Codex cannot run here | +| E3 | Minimal TypeScript SDK for custom agents | Not started | An example agent emits a traced run | + +## Milestone F: Productisation + +| ID | Task | Status | +|---|---|---| +| F1 | Threat model, data-flow and retention docs; delete-run API | Not started | +| F2 | Contribution guide, security policy, changelog | Not started | +| F3 | Reproducible demo script (real Claude Code + scripted fallback, clearly labelled) | Not started | +| F4 | Performance measurements: ingest throughput, event-to-UI latency, large traces | Not started | + +## Deferred (with reason) + +| Item | Reason | +|---|---| +| Headless A/B runner (`runloop ab`) | Needs compare (C) first; costs real tokens per run | +| OTLP/HTTP ingest bridge | OTel GenAI conventions are still in development; build on demand | +| OpenAI Agents SDK / LangGraph adapters | Different user (agent builders); revisit when demand appears | +| Hosted sharing, teams, billing | No validated demand yet; see the monetisation hypothesis | +| Multi-agent simulation experiments | Keep simulation separate from captured traces; after compare | diff --git a/docs/plans/claude-code-tracer.md b/docs/plans/claude-code-tracer.md index 6c6459c..efdbb32 100644 --- a/docs/plans/claude-code-tracer.md +++ b/docs/plans/claude-code-tracer.md @@ -643,3 +643,28 @@ Core v1 (M0–M6): about 11–14 working days. - `worlds/research-campus/world.json`: activity rules begin at about line 171 with the permission rule; `defaultPreset` is `operator`; stations are library×2, build×2, test×2, ops×1, gate×1. - Studio: `components/Sidebar.tsx` (demo button disabled when `activeRunId` is set, line 54); `state/hooks.ts` (line 49); `state/view.ts` (`KIND_LABEL`); `scene/layout.ts` (`incidentStationId` uses task kind only, lines 240–255); `components/AgentFlow.tsx` (coordinator wording); `components/WorldView.tsx` (demo copy); `state/connect.ts` (`getAllEvents`); `scene/Character.tsx` (the draft's `components/Character.tsx` path is wrong); `state/timeline.ts`, `components/MetadataPanel.tsx`, `components/LogsPanel.tsx`, `components/ReplayBar.tsx`, `components/RunHeader.tsx` all exist. - Root `package.json`: `engines.node` is `>=22.16`; `packageManager` is `pnpm@10.28.0`. `pnpm-workspace.yaml` lists `apps/*` and `packages/*`. `vitest.config.ts` lists `packages/*` and `apps/studio`. + +## Appendix C: M0 probe results (2026-10-17) + +Two real headless sessions (`claude -p`, model Haiku, Claude Code 2.1.302) ran with a probe plugin. It registered synchronous command hooks for 18 events and logged raw stdin. The raw payloads are sanitised into `packages/runtime/test/fixtures/claude-code/`. + +| Question (§12) | Answer | +|---|---| +| Do tool events inside subagents carry `agent_id` and `agent_type`? | **Yes.** `PreToolUse`, `PostToolUse` and `PostToolBatch` fired inside an Explore subagent carried both. Main-agent events carry neither. | +| Does `PostToolUse` carry `tool_use_id` and `tool_input`? | **Yes**, both. `PostToolUseFailure` also carries `tool_use_id`, `tool_input`, `error`, `is_interrupt` and `duration_ms`. | +| How does a failing Bash command arrive? | As **`PostToolUseFailure`**. `error` holds `"Exit code 1\n"`. There is no `PostToolUse`. | +| Does `PermissionRequest` carry `tool_use_id`? | **No.** It carries `tool_name`, `tool_input` and `permission_suggestions`, and is preceded by a `PreToolUse` that has the id. | +| Does a denial fire any hook (default mode, `-p`)? | **No.** After `PermissionRequest`, no `PermissionDenied`, `PostToolUse` or failure fired for the denied call. The plan's inferred close at the next boundary is required. | +| How do you link a subagent to its spawning call? | `PostToolUse(Agent).tool_response.agentId` equals `SubagentStart.agent_id`. The order was `PreToolUse(Agent)` → `SubagentStart` → the subagent's tools → `SubagentStop` → `PostToolUse(Agent)`. | +| Is subagent usage reported? | **Yes**, source-reported in `PostToolUse(Agent).tool_response`: `resolvedModel`, `usage` (input, output, cache read and cache creation tokens), `totalTokens`, `totalDurationMs`, `totalToolUseCount` and `toolStats`. | +| What are the `Agent` tool input fields? | `description`, `prompt`, `subagent_type`, `run_in_background`. | +| What does `SessionStart` carry? | `session_id`, `transcript_path`, `cwd`, `source` (`startup`). **No `model` and no `permission_mode`** (`permission_mode` first appears on `UserPromptSubmit`). | +| What is the `SessionEnd` reason for `-p`? | `other`. | +| Does `PostToolBatch` fire? | Yes, after every resolved batch (including batches of 1), with `tool_calls[]` (`tool_name`, `tool_input`, `tool_response`, `tool_use_id`). | +| Is the `ppid` stable within a session? | Yes, one value for every hook in a session. | +| Is there a `prompt_id`? | Yes, from `UserPromptSubmit` on, constant across the turn's events. | +| What was the latency? | Hook events arrived within about 100 ms of each other. The sessions took 13 s and 21 s. | + +**Still unverified (needs an interactive session):** Esc interrupts, `Notification`, approving a permission, compaction, `/clear`, resume, and async-hook delivery at exit. The mapper keeps its defensive fallbacks for these. + +**Probe gotcha:** a nested `claude` inherits `CLAUDE_CODE_SESSION_ID` from its parent session. Pass `--session-id ` to probe or test sessions. diff --git a/docs/product/strategy.md b/docs/product/strategy.md new file mode 100644 index 0000000..c841e49 --- /dev/null +++ b/docs/product/strategy.md @@ -0,0 +1,100 @@ +# Runloop product strategy + +> Status: working strategy, 2026-10-17. The claims below are marked **[V]** (verified at the cited source), **[H]** (hypothesis) or **[D]** (decision). The research that backs this is summarised in [`docs/status/decision-log.md`](../status/decision-log.md). + +## One line + +**Runloop is a local flight recorder for coding agents. Watch a run live as a 3D world, replay any moment, and diff two runs to prove which setup is better.** + +The 3D world is the signature interface: memorable, shareable, and good at showing parallel work. The engineering value is the trace, the replay and the comparison. Every piece of basic work also has a conventional 2D/text path. + +## The problem + +Heavy Claude Code users keep changing their setup: CLAUDE.md, skills, subagents, hooks, models. They have no reliable way to tell whether a change made their agent better or worse on their own repo, or why a long run went wrong. **[H]** +- Skills can hurt agents. A study of about 6,000 runs found that the best skills mainly avoided regressions **[V]** ([arXiv 2607.22520](https://arxiv.org/abs/2607.22520)). +- Setup packs with large, unmeasured claims are hugely popular (several have over 100k GitHub stars) **[V]**. +- Cost is a pain users have already validated: ccusage has 19k stars **[V]** ([repo](https://github.com/ryoppippi/ccusage)). + +## Who it is for (initial) + +An individual heavy Claude Code user who runs subagents and maintains their own setup **[D]**. + +Not yet: +- **Teams wanting adoption and cost analytics.** Anthropic's own analytics and Datadog cover that **[V]**. +- **People building agent apps.** Langfuse, LangSmith, Laminar and Braintrust are strong there **[V]**. + +## What is and isn't defensible + +**Not defensible on its own.** Treat these as table stakes: +- **Capturing telemetry.** Claude Code exports OTel natively **[V]** ([docs](https://code.claude.com/docs/en/monitoring-usage)). +- **The hooks → SQLite → live UI architecture.** It already exists in a 1.5k-star MIT project **[V]**. +- **A spatial or pixel view of agents.** Pixel Agents has 9.8k stars **[V]**. +- **Replay alone.** zoetrope and Roundtable already ship it **[V]**. + +**Defensible, because it compounds** **[H]**: +1. **Run diffs tied to outcomes:** tool calls, files touched, test results, tokens, cost, permission waits. OTel GenAI has no concept of files or test outcomes **[V]**. +2. **Local-first and live:** source code and prompts never leave the machine. Langfuse's Claude Code plugin ingests only after each response **[V]**. +3. **Cross-harness comparison:** the same task under Claude Code and Codex. Neither vendor has a reason to build this. +4. **Each user's own baseline history:** every past run becomes the baseline for the next change. + +## Activation journey (the first 10 minutes) + +1. Run one command to start Runloop. Install the Claude Code plugin. +2. Use Claude Code as normal. The session appears live: robots for the main agent and each subagent, the timeline, tool calls, failures. +3. Click a failure to reach the exact tool call and its output. Scrub the replay. +4. Pin the run as a baseline. After the next session, compare the two. + +**Retention driver [H]:** comparing against a baseline after every setup change. + +**Measure it:** +- the share of installers who come back on day 7; +- the share who use compare. + +## Build order [D] + +| Step | Scope | Status | +|---|---|---| +| 1 | Foundation: event model, recorder, replay, Studio, scripted demo | Done (verified, see `docs/status/current-state.md`) | +| 2 | **Claude Code tracer:** plugin, live ingest, subagents, provenance | Next | +| 3 | **Compare:** baselines and a side-by-side run diff (calls, files, tests, tokens, time) | Then | +| 4 | Search, filter, metrics; design-system pass on Studio and the 3D world | Then | +| 5 | **Codex CLI hooks** (second integration, same user, cross-harness); a documented adapter contract | Then | +| 6 | Headless A/B runner (`runloop ab`): N runs × setup variants, scored by tests | Later | +| 7 | OTLP/HTTP ingest bridge; OpenAI Agents SDK adapter | Later, on demand | + +## Explicitly deferred + +- Hosted cloud, accounts, billing, team workspaces. +- Production alerting and team analytics. +- LLM-judge eval frameworks; tests are the grader first. +- Re-run from a step (Laminar already does this for your own agent code **[V]**). +- New worlds and art packs, and features specific to Agent Teams (experimental, not available headless **[V]**). + +## What would change this strategy + +| If we see this | Then | +|---|---| +| Anthropic ships session compare, or extends skill-creator to whole setups | Lead with cross-harness comparison and local-first privacy | +| Fewer than ~10% of installers use compare after 2 weeks, but 3D clips get shared | Optimise for delight and distribution; accept weak monetisation | +| Demand is mostly team cost or adoption dashboards | Do not follow it; that market is served | +| Hook formats keep breaking | Make OTLP ingest the primary path | + +## Monetisation hypothesis [H] + +Everything local stays free and open source (Apache-2.0). + +Paid features would be the ones where work becomes shared and ongoing: +- hosted, redacted replay links; +- team baselines and regression history; +- a CI check that fails a PR when a setup change makes agent outcomes worse. + +Do not build billing yet. Test willingness to pay with a "share this replay" waitlist and a manual CI pilot with 3–5 teams. + +## Top risks + +1. **Platform absorption:** Anthropic ships native traces and analytics **[V]**. +2. **Hook payload churn** across Claude Code versions. +3. **Retention that rests on novelty.** +4. **Scope:** too much for a small team. +5. **Distribution** matters more than features. +6. **Secrets in traces:** redaction is required before anything is shared. diff --git a/docs/status/current-state.md b/docs/status/current-state.md new file mode 100644 index 0000000..44b65ce --- /dev/null +++ b/docs/status/current-state.md @@ -0,0 +1,49 @@ +# Current state + +> Updated 2026-10-17. **Status label: prototype.** One scripted demo runs end to end; there is no real agent integration yet. + +## What works (verified) + +| Capability | Evidence | +|---|---| +| Event contract: versioned envelope, 20 event types, lenient and strict parsing, redaction, sequence buffer | `pnpm vitest run --project events` | +| Layer 1 execution reducer: pure, idempotent, records anomalies instead of throwing | `--project core` | +| Layer 2 world engine: deterministic BFS, closed-form motion, station reservation, interruption | `--project world-engine` | +| Replay: `stateAt(N)` equals the live fold; shuffled and duplicated delivery converge | `--project replay` | +| Runtime: SQLite recorder (persist, then publish), boot recovery, sandboxed tools, Hono API, resumable SSE, loopback-only | `--project runtime`, manual SIGINT/SIGKILL checks | +| Scripted coding mission: real file edits and real `node --test` runs (10 passed / 2 failed, then 12/12) | `pnpm demo -- --speed 0` | +| Studio: live view, timeline, logs, state, artifact viewer, replay scrubber, 3D world, 2D toggle, keyboard controls | Playwright screenshots (see the verification report) | +| CI workflow: install, typecheck, test, build | `.github/workflows/ci.yml` (not yet run on GitHub) | + +**Baseline commands** (2026-10-17, all passed): +```sh +pnpm install --frozen-lockfile +pnpm typecheck # 7 projects +pnpm test # 27 files, 235 tests +pnpm build # studio static export +RUNLOOP_DATA_DIR=$(mktemp -d) pnpm demo -- --speed 0 --port 0 +``` + +## Claude Code facts verified by the M0 probe + +Two real `claude -p` sessions (Haiku, about $0.02 in total) with a logging plugin, Claude Code 2.1.302 (see `docs/plans/claude-code-tracer.md`, Appendix C): +- Tool hooks inside a subagent carry `agent_id` and `agent_type`. +- `PostToolUse` and `PostToolUseFailure` carry `tool_use_id`. +- A Bash command that exits non-zero arrives as `PostToolUseFailure` with the output in `error`. +- `PermissionRequest` has no `tool_use_id`. A denial in default mode fires no further hook. +- `PostToolUse(Agent).tool_response.agentId` links to `SubagentStart.agent_id` and includes `resolvedModel`, `usage` and `totalTokens`. +- `SessionStart` (source `startup`) carried no `model` field. `SessionEnd` reason was `other` for `-p`. + +## Known limitations + +- **No real agent integration yet.** The Claude Code tracer is the next milestone. +- **`run_command` is not isolated.** Tests run with the runtime user's rights and could rewrite `runloop.db`. This is acceptable for the scripted demo; a real sandbox is needed before adapters run untrusted commands. +- **The 3D view is off-centre at some window sizes** (the Build room is clipped on the right), and labels can overlap. The design pass is still pending. +- **No search or filter, no run comparison, no metrics view.** +- **Read endpoints are unauthenticated** for local processes; there is no retention or deletion API. +- **CI has never run on GitHub**, because nothing has been pushed to a PR yet. +- **The runtime port is still 4317**, which collides with OTLP/gRPC. It moves to 4380 per DL-005. + +## Next highest-value task + +**Milestone B, the Claude Code tracer:** the plugin, ingest endpoint and mapper, built from the real probe fixtures, with traced runs shown live in Studio. See `TASKS.md`. diff --git a/docs/status/decision-log.md b/docs/status/decision-log.md new file mode 100644 index 0000000..e7043aa --- /dev/null +++ b/docs/status/decision-log.md @@ -0,0 +1,94 @@ +# Decision log + +Short records of consequential decisions. Newest first. Each entry gives the problem, the options, the choice, why it was chosen, the trade-off, and how to reverse it. + +--- + +## DL-006 · Claude Code subagent usage comes from the `Agent` tool result (2026-10-17) + +**Problem.** Show tokens, model and cost per agent without guessing. + +**Evidence from the M0 probe** (real `claude -p` sessions, Claude Code 2.1.302): +- `PostToolUse` for the `Agent` tool includes `tool_response` with `agentId`, `resolvedModel`, `usage`, `totalTokens` and `totalDurationMs`. +- That `agentId` equals `agent_id` on `SubagentStart` and `SubagentStop`. +- No hook reports main-agent tokens. + +**Choice.** Record subagent usage as source-reported. Show the main agent's usage as "not reported by hooks". Transcript import may enrich it later, labelled as such. + +**Trade-off.** Cost per run is incomplete until the transcript importer exists. + +**Reverse.** Add an enrichment source; the event model keeps provenance per field. + +## DL-005 · Move the runtime port off 4317 to 4380 (2026-10-17) + +**Problem.** 4317 is the default OTLP/gRPC port. A user running an OTel collector would clash with Runloop, and Runloop plans an OTLP bridge itself. + +**Options.** +- Keep 4317. +- 4318 (OTLP/HTTP, also taken). +- An unassigned port: 4380. + +**Choice.** 4380. Override it with `--port` or `NEXT_PUBLIC_RUNLOOP_API`. + +**Trade-off.** It is a breaking change for anyone already running the scaffold, which is acceptable before any release. + +**Reverse.** It is one constant in `packages/events/src/protocol.ts`. + +## DL-004 · Second integration: Codex CLI hooks, not the OpenAI Agents SDK (2026-10-17) + +**Problem.** Pick the integration after Claude Code. + +**Options.** +- OpenAI Agents SDK: clean tracing processor, and it can be tested in CI with a scripted model. +- Codex CLI hooks: near-identical event names to Claude Code (`SessionStart`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart/Stop`, `Stop`, `SessionEnd`). +- LangGraph. +- An OTLP bridge. + +**Choice.** Codex CLI hooks. It serves the same user (heavy coding-agent users), reuses most of the Claude Code mapper, and enables cross-harness comparison, which hedges against platform absorption. + +**Trade-off.** It does not reach agent-app builders. The OpenAI Agents SDK stays on the roadmap for when that demand appears. + +**Reverse.** The adapter contract is source-agnostic, so either can be built next. + +## DL-003 · Wedge: coding-agent flight recorder, then run compare, then A/B runner (2026-10-17) + +**Problem.** Choose the first market. Candidates were general agent observability, Claude Code tracing, eval/regression, multi-agent experiments, or a combination. + +**Evidence.** Telemetry capture, spatial views and replay are each already available for free [V]. "Did my setup change help?" is a real, recurring question [V/H]. Run diffs tied to outcomes are not offered by the tools found [H]. + +**Choice.** Tracer first (activation), compare and baselines next (retention), headless A/B later. Details and reversal triggers are in `docs/product/strategy.md`. + +**Trade-off.** It ignores agent-app builders for now. + +**Reverse.** The event model and adapters stay general; only the order of work changes. + +## DL-002 · Tracer mode uses async command hooks with a local spool (2026-10-17) + +**Problem.** Capture Claude Code activity without ever slowing or breaking it. + +**Options.** +- HTTP hooks: synchronous, and they fail when the runtime is down. +- Async command hooks running a tiny Node script. +- Reading OTel exports: batched, and traces are in beta. +- Reading transcripts: the format is internal. + +**Choice.** Async command hooks. The script writes the event to a spool file first, then POSTs it to loopback, and always exits 0. The full design is in `docs/plans/claude-code-tracer.md`. + +**Trade-off.** One Node process per event, off the critical path. + +**Reverse.** The mapper is a pure function, so the transport can be swapped. + +## DL-001 · Local-first TypeScript monorepo with SQLite and an append-only event log (2026-10-17) + +**Problem.** Pick a foundation a small team can maintain that still supports replay. + +**Choice.** +- pnpm workspaces, TypeScript, zod-validated versioned events. +- `node:sqlite` behind a one-file wrapper. +- Hono with resumable SSE. +- Next.js static export with React Three Fiber. +- Three layers kept separate: execution truth, world simulation, presentation. + +**Trade-off.** `node:sqlite` is experimental in Node 22. Runs live on a single machine. + +**Reverse.** The store is wrapped (`packages/runtime/src/store/db.ts`), and the protocol is HTTP + SSE. From 8195e6b9941df8a4c974778e11ff41d12ac8f6c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:43:24 +0000 Subject: [PATCH 15/18] Adopt the Runloop Signature design system Founder design brief captured as an implementation-ready spec: colour, type, spacing and motion tokens, information architecture, synchronised world/timeline/inspector, named world environments, provenance labels and verification rules. Ledger and strategy updated; Geist fonts and resizable panels added to the studio. Tracer plan records the async hook findings (out-of-order delivery, Stop lost under -p). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- TASKS.md | 18 +++- apps/studio/package.json | 2 + docs/design/design-system.md | 171 +++++++++++++++++++++++++++++++ docs/plans/claude-code-tracer.md | 4 + docs/product/strategy.md | 3 +- pnpm-lock.yaml | 26 +++++ 6 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 docs/design/design-system.md diff --git a/TASKS.md b/TASKS.md index 9b11ff4..e2cc1ec 100644 --- a/TASKS.md +++ b/TASKS.md @@ -36,14 +36,21 @@ Plan: `docs/plans/claude-code-tracer.md`. It depends on A4. | C2 | Run diff: outcome, duration, tool calls by category, files touched, test results, tokens and cost where reported, permission waits, failures | Not started | Unit tests on the diff function; unknown values show as "not reported", never as zero | | C3 | Compare view in Studio (side by side, with links to the differing events) | Not started | Clicking a difference opens the event in that run | -## Milestone D: Core observability and design system +## Milestone D: Runloop Signature design system and core observability + +Spec: `docs/design/design-system.md` (founder brief). This milestone runs in parallel with B; B5 is built on top of it. | ID | Task | Status | Acceptance criteria | |---|---|---|---| -| D1 | Search and filter in the timeline and logs (agent, tool, category, status, text) | Not started | Filters compose; URL-addressable; tested | -| D2 | Run metrics panel (duration, calls by category, failures, tokens where reported) | Not started | Defined calculations; missing data shown honestly | -| D3 | Design-system pass: tokens, typography, components, empty/loading/error states | Not started | Every page audited at 1440 and 1920 wide; contrast checked | -| D4 | 3D framing and readability: fit to viewport, label collision, camera presets | Not started | No clipped rooms at 1280–1920 width; labels never overlap at default zoom | +| D1 | Tokens (dark and light), Geist and Geist Mono self-hosted, tabular numerals, motion tokens, reduced motion | Not started | `tokens.css` is the only colour source; contrast checked at AA in both themes | +| D2 | Component library `src/ui/` (sidebar, tabs, split and resizable panels, command palette, chips, skeletons, empty, error, toast, confirm) | Not started | Keyboard reachable; used by every page; no colour-only state | +| D3 | IA: Overview · Live World · Traces · Integrations · Settings (Experiments and Evaluations hidden until they work); deep links | Not started | Each page has loading, empty and error states; deep link restores run, view, event and agent | +| D4 | Synchronised selection across world, timeline and inspector; resizable inspector | Not started | Selecting in any view focuses the same entity in the others (tested in the store) | +| D5 | World refresh: Signature palette, named environments (Atrium, Observatory, Workshop, Test Chamber, Launch Bay), lime only for active execution, fit to viewport, no label collisions | Not started | No clipped rooms at 1280–1920 px; labels never overlap at default zoom; screenshots in both themes | +| D6 | 2D agent graph ("Network") view and a table fallback | Not started | Every world interaction has a 2D route | +| D7 | Search and filter in the timeline and logs (agent, tool, category, status, text) | Not started | Filters compose; URL-addressable; tested | +| D8 | Run metrics panel (duration, calls by category, failures, tokens and cost where reported) | Not started | Defined calculations; "not reported" never shown as zero | +| D9 | Visual regression screenshots of every page and state | Not started | Script in the repo; images checked on each milestone | ## Milestone E: Generalisation @@ -60,6 +67,7 @@ Plan: `docs/plans/claude-code-tracer.md`. It depends on A4. | F1 | Threat model, data-flow and retention docs; delete-run API | Not started | | F2 | Contribution guide, security policy, changelog | Not started | | F3 | Reproducible demo script (real Claude Code + scripted fallback, clearly labelled) | Not started | +| F5 | Marketing landing page in the Signature system ("Execution, made visible.") with a real recorded run | Not started | | F4 | Performance measurements: ingest throughput, event-to-UI latency, large traces | Not started | ## Deferred (with reason) diff --git a/apps/studio/package.json b/apps/studio/package.json index f50356d..14dad15 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -17,10 +17,12 @@ "@runloop-world/events": "workspace:*", "@runloop-world/replay": "workspace:*", "@runloop-world/world-engine": "workspace:*", + "geist": "1.8.1", "next": "16.4.2", "postprocessing": "6.39.5", "react": "19.3.1", "react-dom": "19.3.1", + "react-resizable-panels": "4.14.5", "three": "0.186.1", "zustand": "5.0.15" }, diff --git a/docs/design/design-system.md b/docs/design/design-system.md new file mode 100644 index 0000000..6b8dc11 --- /dev/null +++ b/docs/design/design-system.md @@ -0,0 +1,171 @@ +# Runloop Signature: design system v1 + +> Status: **committed direction** (founder brief, 2026-10-17). This spec is the source of truth for Studio, the 3D world and future marketing pages. Tokens live in `apps/studio/src/app/tokens.css`; components in `apps/studio/src/ui/`. + +## Identity + +A cinematic command centre for AI agents. Real execution becomes a living world, and every action stays precise and inspectable. + +- **Mood:** cinematic, sophisticated, tactile, quietly futuristic. +- **Personality:** ambitious but not flashy; technical without feeling sterile. +- **Rule zero:** every animation communicates a real event, a state transition, or a clearly labelled simulation. No decorative particles or glow for its own sake. + +## Colour tokens + +### Dark theme (signature) + +| Token | Value | Use | +|---|---|---| +| `--rl-obsidian` | `#101114` | App canvas | +| `--rl-graphite` | `#1B1D22` | Surfaces (panels, cards) | +| `--rl-slate` | `#292C33` | Elevated panels, hover, inputs | +| `--rl-line` | `rgba(232,235,240,0.08)` | Hairline dividers; prefer tonal contrast over outlines | +| `--rl-lime` | `#B8F36A` | **Primary accent.** Primary actions, the selected state, active execution. Use sparingly. | +| `--rl-ice` | `#E8EBF0` | Primary text | +| `--rl-muted` | `#9297A2` | Secondary text | +| `--rl-success` | `#48C78E` | Verified success only | +| `--rl-warning` | `#F5B84B` | Attention, waiting, blocked | +| `--rl-failure` | `#FF6B76` | Errors and failures | + +### Light theme (trace-heavy work and reports) + +Same semantic roles, re-derived for a light canvas. All text and icon pairs must reach WCAG AA (4.5:1 for body text, 3:1 for large text and UI glyphs). The lime accent darkens to a legible olive for text (around `#4F7A12`) and keeps `#B8F36A` only as a fill behind dark text. + +### Agent identity hues + +Characters, timeline actor dots and graph nodes use an 8-hue identity palette tuned for both themes. +- These hues must be distinguishable from the semantic colours. Identity colours never signal state. +- State is always icon plus text plus semantic colour. +- Lime is reserved; it is never an identity hue. + +## Typography + +| Role | Font | Size | Notes | +|---|---|---|---| +| Display | Geist | 36–56 px | Tight tracking (-0.02em); landing and empty states only | +| Heading | Geist | 20–28 px | Medium to semibold | +| Body | Geist | 14–16 px | Line height 1.5 | +| UI small | Geist | 12–13 px | Labels, chips | +| Mono | Geist Mono | 12–14 px | Event types, ids, paths, commands, code | + +- **Tabular numerals** (`font-variant-numeric: tabular-nums`) for every cost, duration, token count, timestamp and sequence number. +- Fonts are self-hosted through the `geist` package (SIL Open Font License), with no network font fetches, because Runloop is local-first. + +## Shape and spacing + +| Token | Value | +|---|---| +| Radius: controls | 6–8 px | +| Radius: cards | 10–12 px | +| Radius: hero panels | 16–20 px | +| Spacing unit | 4 px; compact gap 8, standard gap 16, section gap 24–32, page padding 24–32 | + +**Surfaces:** +- Use layered tonal surfaces (obsidian → graphite → slate) rather than outlines. +- Glass and blur are reserved for floating controls over the 3D world (camera toolbar, playback overlay, toasts). +- The engineering interface stays crisp and opaque. + +## Motion tokens + +| Token | Value | Use | +|---|---|---| +| `--rl-dur-instant` | 120 ms | Hover, focus | +| `--rl-dur-standard` | 200 ms | Menus, panels, tabs | +| `--rl-dur-emphasis` | 320 ms | Selection, view transitions | +| Camera move | 400–700 ms | Focusing a world entity | +| `--rl-ease-out` | `cubic-bezier(0.2, 0.8, 0.2, 1)` | Interface entrances | +| Reduced motion | ~0 ms | All of the above; nothing that carries information is lost | + +### World motion +- Agents move only when a real task transition occurs. +- Idle characters stay restrained. +- Handoffs animate along real relationships (delegation, messages). + +### Data motion +- Events arrive as restrained pulses. +- A failure is a clear state change with a path to investigation (incident marker → event), not an explosion. + +### Interface motion +- The inspector slides in subtly. +- Selection preserves context. +- Scrubbing updates the world, timeline and inspector together. + +### Replay invariants +- A paused replay shows exactly the recorded state at the playhead. +- Late live events never rewrite history that is already displayed. + +## Information architecture + +| Area | Primary job | Status | +|---|---|---| +| **Overview** | Runtime health, live sessions, recent runs, quick start | v1 | +| **Live World** | Watch and explore execution: 3D world, agent graph, timeline | v1 | +| **Traces** | Investigate one run: timeline, logs, inspector, search and filter | v1 | +| **Experiments** | Compare runs and baselines | Milestone C (hidden until it works) | +| **Evaluations** | Failure → test case; regression suites | Later (hidden until it works) | +| **Integrations** | Connect Claude Code (plugin status, install command, what is collected), the scripted demo | v1 | +| **Settings** | Theme, motion, render quality, runtime URL, privacy and retention | v1 | + +**Navigation rules:** +- **Do not show placeholder sections.** An area appears in the sidebar only when it works. +- The **world, timeline and inspector are synchronised views** of one selection state (stable ids for runs, events, agents and stations). Selecting a tool call in the timeline focuses its agent or station in the world and opens the exact event in the inspector, and the other way round. +- **Deep links:** `?run=&view=world|graph|trace&event=&agent=`. + +## The 3D world + +The world is made of named environments that share one event model: + +| Environment | Zone today | Work it hosts | +|---|---|---| +| **Atrium** | Hub | Overview of the running system; agents' home | +| **Observatory** | Research | Reading, searching, web retrieval | +| **Workshop** | Build | Editing, writing, executing commands | +| **Test Chamber** | Test | Test runs, evaluation, failure analysis | +| **Launch Bay** | Deploy | Deploy, publish, MCP and external actions | +| **Network** | *(2D agent-graph view)* | Delegation, messages, dependencies between agents | + +**Rules:** +- An agent's visual identity is stable across the world, graph and timeline. +- Silhouettes or equipment never suggest capabilities the source data cannot establish. +- The world supports focusing an agent or task, following execution, jumping to the next error, freezing at an exact recorded event, inspecting a handoff, switching to the 2D agent graph or table, and lowering detail for performance. +- **With the world disabled, the product still works completely.** +- **Palette:** obsidian and graphite architecture. Zone floors are desaturated tints. Lime marks only the active execution (working agent, running station). Semantic colours mark outcomes. Emissive glow is limited to state-carrying elements. + +## Components (`apps/studio/src/ui/`) + +| Group | Components | +|---|---| +| Layout and navigation | Sidebar, breadcrumbs, command palette (⌘K / Ctrl-K), tabs, split panels, resizable inspector | +| Execution | Agent card, event row, trace timeline, tool inspector, delegation graph, playback controls | +| Feedback | Connection status, loading skeleton, empty state, error recovery, confirmation dialog, toast | + +**Non-negotiable behaviour:** +- Every meaningful action gives visible feedback and has a recovery path. +- Search, filter and selection work by keyboard and pointer. +- Destructive actions need a confirmation that explains their impact. +- Empty states teach the next useful step. +- Errors say what failed, whether data was saved, and how to retry. +- Status is never shown by colour alone. +- Every 3D interaction has a 2D route. +- Reduced motion keeps all information. +- Mobile shows traces, summaries and inspectors, never a desktop-sized 3D scene. +- The system always says whether it is **connecting, live, paused, disconnected, replaying, or showing synthetic data.** + +## Provenance labelling (trust) + +| Label | Meaning | +|---|---| +| **Live** | Events are arriving now from a connected source | +| **Recorded** | Replay of persisted events; nothing is re-executed | +| **Traced · ** | Runloop recorded what the source reported; it did not execute or verify it | +| **Executed** | Runloop's runtime ran the tools itself (scripted demo) | +| **Inferred** | Reconstructed by Runloop (e.g. a denied permission); shown with a chip | +| **Synthetic** | Example or fixture data, never mixed with real runs | +| **Not reported** | The source does not expose this value; never shown as zero | + +## Verification + +- Visual regression screenshots of every page and key state, in both themes, at 1280, 1440 and 1920 px wide plus one mobile width. +- Contrast checked against WCAG AA. +- Reduced-motion run of the main journey. +- Keyboard-only run of the main journey. diff --git a/docs/plans/claude-code-tracer.md b/docs/plans/claude-code-tracer.md index efdbb32..0d118ad 100644 --- a/docs/plans/claude-code-tracer.md +++ b/docs/plans/claude-code-tracer.md @@ -665,6 +665,10 @@ Two real headless sessions (`claude -p`, model Haiku, Claude Code 2.1.302) ran w | Is there a `prompt_id`? | Yes, from `UserPromptSubmit` on, constant across the turn's events. | | What was the latency? | Hook events arrived within about 100 ms of each other. The sessions took 13 s and 21 s. | +**Async delivery (third session, all hooks `async: true` except SessionEnd):** +- Async hooks arrive **out of order**: `PostToolBatch` was logged before its `PostToolUse`. The mapper must tolerate this (D5). +- Under `-p` the async **`Stop` hook never ran**; only the synchronous `SessionEnd` was delivered at exit. **Decision:** register `Stop`, `StopFailure` and `SessionEnd` as synchronous with a fast spool write. Everything else stays async. + **Still unverified (needs an interactive session):** Esc interrupts, `Notification`, approving a permission, compaction, `/clear`, resume, and async-hook delivery at exit. The mapper keeps its defensive fallbacks for these. **Probe gotcha:** a nested `claude` inherits `CLAUDE_CODE_SESSION_ID` from its parent session. Pass `--session-id ` to probe or test sessions. diff --git a/docs/product/strategy.md b/docs/product/strategy.md index c841e49..e943a90 100644 --- a/docs/product/strategy.md +++ b/docs/product/strategy.md @@ -57,7 +57,8 @@ Not yet: | 1 | Foundation: event model, recorder, replay, Studio, scripted demo | Done (verified, see `docs/status/current-state.md`) | | 2 | **Claude Code tracer:** plugin, live ingest, subagents, provenance | Next | | 3 | **Compare:** baselines and a side-by-side run diff (calls, files, tests, tokens, time) | Then | -| 4 | Search, filter, metrics; design-system pass on Studio and the 3D world | Then | +| 2b | **Runloop Signature design system** (`docs/design/design-system.md`): tokens, components, IA, synchronised world/timeline/inspector, 2D graph fallback. Runs in parallel with 2 | Now | +| 4 | Search, filter, metrics | Then | | 5 | **Codex CLI hooks** (second integration, same user, cross-harness); a documented adapter contract | Then | | 6 | Headless A/B runner (`runloop ab`): N runs × setup variants, scored by tests | Later | | 7 | OTLP/HTTP ingest bridge; OpenAI Agents SDK adapter | Later, on demand | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0078b3..1982650 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@runloop-world/world-engine': specifier: workspace:* version: link:../../packages/world-engine + geist: + specifier: 1.8.1 + version: 1.8.1(next@16.4.2(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)) next: specifier: 16.4.2 version: 16.4.2(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -59,6 +62,9 @@ importers: react-dom: specifier: 19.3.1 version: 19.3.1(react@19.3.1) + react-resizable-panels: + specifier: 4.14.5 + version: 4.14.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1) three: specifier: 0.186.1 version: 0.186.1 @@ -843,6 +849,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + geist@1.8.1: + resolution: {integrity: sha512-oSBhuzTvwZqGAZVC4swTRo61G/JHojtEVNR3DCToU+4nUBkG48ndKNvj4Ma7zZ+OlJkYDLT66HS+hrFszkovTg==} + peerDependencies: + next: '>=13.2.0' + glsl-noise@0.0.0: resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} @@ -1031,6 +1042,12 @@ packages: peerDependencies: react: ^19.3.1 + react-resizable-panels@4.14.5: + resolution: {integrity: sha512-AoHgoq3sGOfciPn2ahddFrBCqsitZLse8cJIYWblvE3kQ9z39IrhdlHCjnVRrDL8XcWqvA0u/t9nLZTfWePJ5A==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-use-measure@2.1.7: resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} peerDependencies: @@ -1828,6 +1845,10 @@ snapshots: fsevents@2.3.3: optional: true + geist@1.8.1(next@16.4.2(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)): + dependencies: + next: 16.4.2(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + glsl-noise@0.0.0: {} hls.js@1.7.3: {} @@ -1985,6 +2006,11 @@ snapshots: react: 19.3.1 scheduler: 0.28.0 + react-resizable-panels@4.14.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-use-measure@2.1.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: react: 19.3.1 From 274b923a92db6bd8368b08fdd67b84908e3aff19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:49:17 +0000 Subject: [PATCH 16/18] Add the Claude Code tracer - events: traced run mode and source, external adapter, provenance on the envelope, run.marker, agent.usage_reported, tool categories, turn and delegated task kinds, unobserved failures, ingest DTOs; runtime port moved to 4380 (DL-005). All additive; old logs parse unchanged. - core/world-engine: markers, reported usage, tool-category activity rules, dynamic subagent presets; world zones renamed to the design system's environments. - runtime: token-protected ingest endpoint, pure hook mapper, session manager with dedupe, idle sweep and continuation segments, all-or- nothing recordBatch, mode-aware recovery, migration 1. - integrations/claude-code-plugin: async hooks (Stop, StopFailure and SessionEnd synchronous), spool-first delivery, redaction, --doctor; marketplace manifest. - Fixtures sanitised from real claude -p sessions plus synthetic edge cases, golden mapper tests, and a plugin end-to-end test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- .claude-plugin/marketplace.json | 18 + .../.claude-plugin/plugin.json | 28 + integrations/claude-code-plugin/README.md | 121 ++ .../claude-code-plugin/hooks/hooks.json | 251 ++++ integrations/claude-code-plugin/package.json | 12 + .../scripts/runloop-hook.mjs | 855 +++++++++++++ .../claude-code-plugin/test/hook.test.ts | 401 ++++++ integrations/claude-code-plugin/tsconfig.json | 8 + .../claude-code-plugin/vitest.config.ts | 9 + packages/core/src/format.test.ts | 20 + packages/core/src/format.ts | 32 +- packages/core/src/id-map.ts | 85 ++ packages/core/src/index.ts | 3 +- packages/core/src/reduce-execution.test.ts | 165 ++- packages/core/src/reduce-execution.ts | 291 +++-- packages/core/src/selectors.ts | 2 +- packages/core/src/types.ts | 69 +- packages/events/src/claude-code.ts | 33 + packages/events/src/envelope.ts | 9 + packages/events/src/event.ts | 14 +- packages/events/src/index.ts | 1 + packages/events/src/parse.test.ts | 66 +- packages/events/src/payloads.ts | 114 +- packages/events/src/protocol.ts | 55 +- packages/events/src/redact.test.ts | 62 +- packages/events/src/redact.ts | 60 +- packages/replay/src/replay-index.ts | 43 +- packages/replay/src/replay.test.ts | 71 ++ packages/runtime/README.md | 36 +- packages/runtime/src/cli.ts | 12 +- packages/runtime/src/http/app.ts | 26 +- packages/runtime/src/http/ingest.ts | 105 ++ packages/runtime/src/index.ts | 14 + packages/runtime/src/ingest-token.ts | 89 ++ packages/runtime/src/recorder.ts | 311 ++++- packages/runtime/src/runtime.ts | 49 +- packages/runtime/src/store/migrations.ts | 21 + .../src/tracers/claude-code/hook-input.ts | 276 +++++ .../runtime/src/tracers/claude-code/ids.ts | 51 + .../runtime/src/tracers/claude-code/mapper.ts | 1094 +++++++++++++++++ .../tracers/claude-code/session-manager.ts | 185 +++ .../src/tracers/claude-code/summarize.ts | 352 ++++++ packages/runtime/test/claude-code-helpers.ts | 132 ++ .../test/claude-code-mapper-cases.test.ts | 254 ++++ .../runtime/test/claude-code-mapper.test.ts | 186 +++ .../test/claude-code-plugin.e2e.test.ts | 189 +++ .../runtime/test/claude-code-units.test.ts | 197 +++ .../test/fixtures/claude-code/README.md | 31 + .../fixtures/claude-code/s1-basic.golden.txt | 26 + .../test/fixtures/claude-code/s1-basic.jsonl | 19 + .../claude-code/s2-subagents.golden.txt | 44 + .../fixtures/claude-code/s2-subagents.jsonl | 27 + .../fixtures/claude-code/s3-async.golden.txt | 27 + .../test/fixtures/claude-code/s3-async.jsonl | 20 + .../synthetic-no-session-end.golden.txt | 22 + .../synthetic-no-session-end.jsonl | 7 + .../synthetic-out-of-order.golden.txt | 31 + .../claude-code/synthetic-out-of-order.jsonl | 15 + ...tic-permission-denied-then-stop.golden.txt | 24 + ...ynthetic-permission-denied-then-stop.jsonl | 13 + .../synthetic-post-before-pre.golden.txt | 18 + .../synthetic-post-before-pre.jsonl | 10 + packages/runtime/test/http.test.ts | 7 +- packages/runtime/test/ingest.test.ts | 350 ++++++ packages/testkit/src/builder.ts | 3 + packages/world-engine/src/category.test.ts | 98 ++ packages/world-engine/src/definition.ts | 7 +- packages/world-engine/src/index.ts | 2 +- packages/world-engine/src/intent.ts | 30 +- .../world-engine/src/presentation.test.ts | 1 + .../world-engine/src/reduce-world.test.ts | 3 +- packages/world-engine/src/reduce-world.ts | 8 +- packages/world-engine/src/types.ts | 2 + pnpm-workspace.yaml | 1 + vitest.config.ts | 2 +- worlds/research-campus/world.json | 99 +- 76 files changed, 7196 insertions(+), 228 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 integrations/claude-code-plugin/.claude-plugin/plugin.json create mode 100644 integrations/claude-code-plugin/README.md create mode 100644 integrations/claude-code-plugin/hooks/hooks.json create mode 100644 integrations/claude-code-plugin/package.json create mode 100644 integrations/claude-code-plugin/scripts/runloop-hook.mjs create mode 100644 integrations/claude-code-plugin/test/hook.test.ts create mode 100644 integrations/claude-code-plugin/tsconfig.json create mode 100644 integrations/claude-code-plugin/vitest.config.ts create mode 100644 packages/core/src/id-map.ts create mode 100644 packages/events/src/claude-code.ts create mode 100644 packages/runtime/src/http/ingest.ts create mode 100644 packages/runtime/src/ingest-token.ts create mode 100644 packages/runtime/src/tracers/claude-code/hook-input.ts create mode 100644 packages/runtime/src/tracers/claude-code/ids.ts create mode 100644 packages/runtime/src/tracers/claude-code/mapper.ts create mode 100644 packages/runtime/src/tracers/claude-code/session-manager.ts create mode 100644 packages/runtime/src/tracers/claude-code/summarize.ts create mode 100644 packages/runtime/test/claude-code-helpers.ts create mode 100644 packages/runtime/test/claude-code-mapper-cases.test.ts create mode 100644 packages/runtime/test/claude-code-mapper.test.ts create mode 100644 packages/runtime/test/claude-code-plugin.e2e.test.ts create mode 100644 packages/runtime/test/claude-code-units.test.ts create mode 100644 packages/runtime/test/fixtures/claude-code/README.md create mode 100644 packages/runtime/test/fixtures/claude-code/s1-basic.golden.txt create mode 100644 packages/runtime/test/fixtures/claude-code/s1-basic.jsonl create mode 100644 packages/runtime/test/fixtures/claude-code/s2-subagents.golden.txt create mode 100644 packages/runtime/test/fixtures/claude-code/s2-subagents.jsonl create mode 100644 packages/runtime/test/fixtures/claude-code/s3-async.golden.txt create mode 100644 packages/runtime/test/fixtures/claude-code/s3-async.jsonl create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-no-session-end.golden.txt create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-no-session-end.jsonl create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-out-of-order.golden.txt create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-out-of-order.jsonl create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-permission-denied-then-stop.golden.txt create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-permission-denied-then-stop.jsonl create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-post-before-pre.golden.txt create mode 100644 packages/runtime/test/fixtures/claude-code/synthetic-post-before-pre.jsonl create mode 100644 packages/runtime/test/ingest.test.ts create mode 100644 packages/world-engine/src/category.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b4611ec --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "runloop", + "owner": { "name": "Runloop" }, + "metadata": { + "description": "Runloop: a local flight recorder for coding agents.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "runloop-tracer", + "source": "./integrations/claude-code-plugin", + "description": "Record Claude Code sessions into a local Runloop world and replay them. Observe-only.", + "version": "0.1.0", + "license": "Apache-2.0", + "category": "monitoring" + } + ] +} diff --git a/integrations/claude-code-plugin/.claude-plugin/plugin.json b/integrations/claude-code-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..75b86c6 --- /dev/null +++ b/integrations/claude-code-plugin/.claude-plugin/plugin.json @@ -0,0 +1,28 @@ +{ + "name": "runloop-tracer", + "version": "0.1.0", + "description": "Record Claude Code sessions (prompts, tool calls, subagents, permissions) into a local Runloop world and replay them. Observe-only: it never blocks or changes Claude Code.", + "author": { "name": "Runloop" }, + "license": "Apache-2.0", + "keywords": ["observability", "tracing", "replay", "subagents"], + "userConfig": { + "runtime_url": { + "type": "string", + "title": "Runloop runtime URL", + "description": "Where the local Runloop runtime listens. Loopback addresses only; anything else is refused.", + "default": "http://127.0.0.1:4380" + }, + "enabled": { + "type": "boolean", + "title": "Trace sessions", + "description": "Turn tracing off without uninstalling the plugin.", + "default": true + }, + "trace_prompts": { + "type": "boolean", + "title": "Record prompt text", + "description": "Store your prompts (marked sensitive). Off stores a placeholder instead.", + "default": true + } + } +} diff --git a/integrations/claude-code-plugin/README.md b/integrations/claude-code-plugin/README.md new file mode 100644 index 0000000..91a9ecd --- /dev/null +++ b/integrations/claude-code-plugin/README.md @@ -0,0 +1,121 @@ +# Runloop tracer for Claude Code + +A Claude Code plugin that records your sessions into a local [Runloop](../../README.md) runtime: prompts, tool calls, subagents, permission requests and replies. Studio shows the session live as a 3D world and a timeline, and you can replay it later. + +It only observes. It never blocks, approves or changes anything in Claude Code: every hook exits 0 and prints nothing. Its time budget counts from process start: asynchronous hooks give up after 1.5 s, and the synchronous `Stop`, `StopFailure` and `SessionEnd` after 1 s (they skip the POST and only spool when Node's start-up already used most of it). Measured on a 4-core Linux machine: `Stop` takes about 0.13 s with the runtime up and 0.57 s with a runtime that accepts connections but never answers (0.73 s with 20,000 spooled events), and about 0.6 s with 64 other hook processes starting at the same time. A machine so loaded that Node itself needs more than the hook's timeout to start can still exceed it; the event is then lost only if the spool write never ran. + +## Requirements + +- The Runloop runtime running on the same machine (`pnpm serve` or `pnpm dev` in the Runloop repo). It listens on `http://127.0.0.1:4380` and creates the ingest token the plugin reads. +- `node` (22 or later) on the `PATH` that Claude Code sees. If it is not, hooks fail silently. + +## Install + +**From a checkout (development):** + +```sh +pnpm serve # in the Runloop repo +claude --plugin-dir ~/code/runloop/integrations/claude-code-plugin +``` + +After editing the plugin, run `/reload-plugins` inside Claude Code. + +**From the marketplace:** + +``` +/plugin marketplace add argahv/runloop +/plugin install runloop-tracer@runloop +``` + +Or from the shell: `claude plugin install runloop-tracer@runloop --scope user`. On Claude Code 2.1.275 or later, `/plugin install runloop-tracer --marketplace argahv/runloop` does both in one step. + +Install it once: with both `--plugin-dir` and the marketplace copy, every event is sent twice (most are deduplicated, markers are not). + +**Check the setup:** + +```sh +node /scripts/runloop-hook.mjs --doctor +``` + +It prints the runtime URL, whether the token file exists (and warns when other users can read it, or it belongs to another user) and whether the runtime answers. For each of the plugin's data directories, it prints the spool size, the last accepted event and the last error, and it sends anything waiting in the spool. From a shell it checks every `~/.claude/plugins/data/runloop-tracer*` directory. With `--plugin-dir`, Claude Code uses `runloop-tracer-inline` (verified); an installed copy gets its own `runloop-tracer…` directory. `CLAUDE_PLUGIN_DATA` overrides the search. + +## Configuration + +Environment variables win over plugin options, which win over defaults. + +| Setting | Environment | Plugin option | Default | +|---|---|---|---| +| Runtime URL | `RUNLOOP_URL` | `runtime_url` | `http://127.0.0.1:4380`. Loopback addresses only; anything else is refused. | +| Token file | `RUNLOOP_INGEST_TOKEN_FILE` | none | `$XDG_CONFIG_HOME/runloop/ingest-token`, else `~/.config/runloop/ingest-token` (`%APPDATA%\runloop\ingest-token` on Windows) | +| Tracing on | `RUNLOOP_TRACE=0` turns it off | `enabled` | on | +| Prompt text | `RUNLOOP_TRACE_PROMPTS=0` stores a placeholder | `trace_prompts` | recorded, marked sensitive | +| Debug | `RUNLOOP_TRACE_DEBUG=1` writes errors to stderr | none | off | + +## What is collected + +Sent to the local runtime only, per hook: +- the session id, the hook name, the project's working directory (Runloop stores only its base name, e.g. `demo`) and the permission mode; +- prompts and Claude's final replies (marked sensitive; prompts can be turned off); +- tool names and inputs (commands, file paths, search patterns), capped at 4 KB per string; +- for tool results, only sizes and counts (lines read, lines added and removed, files found), the last 2 KB of command output (used to read test counts such as `12 tests: 10 passed, 2 failed`), and, for subagents, the model and token usage Claude Code reports. + +Not collected: file contents, diffs, full command output, Claude Code's transcript files and their paths. Absolute paths are made relative to the project (or shortened to `~/…`) before anything is stored. + +Secrets are masked twice, in the plugin before anything is written or sent, and again in the runtime. Each long string is masked whole before it is cut to size. What is recognised: +- token formats: OpenAI/Anthropic `sk-…`, Stripe `sk_live_…`/`rk_live_…`, GitHub `ghp_`/`gho_`/`ghs_`/`ghu_`/`ghr_`/`github_pat_`, GitLab `glpat-`, AWS `AKIA…`/`ASIA…`, Slack `xox…` and webhook URLs, Google `AIza…`, npm `npm_…`, Docker `dckr_pat_…`; +- by position: the password in `scheme://user:password@host`, `Authorization: `, `Bearer `, `X-…-Key`/`X-…-Token` headers, `name=value` or `name: value` where the name ends in key, token, secret, password, pwd, passphrase or credentials (assignments, form data, query strings, JSON), `curl -u user:password` and `mysql -p`; +- in shell commands also: `NAME_KEY=value` in any case, `--password`/`--token`/`--secret`/`--api-key` values, `login … -p ` and `sshpass -p `, and a long value after a credential-like word (`aws configure set aws_secret_access_key `); +- the values of credential-like environment variables (Claude Code's in the plugin, the runtime's in the runtime), and every value found above wherever else the same hook repeats it (for example a password echoed in an error). + +Anything else is not recognised: a password given as a bare argument (`tool login hunter2`) or a secret in a format not listed is stored as is. Shell commands are therefore marked *sensitive* in the trace. + +## Privacy and security + +- **Local only.** The plugin posts only to loopback addresses; the runtime only listens on loopback. +- **Authenticated writes.** Each request carries the per-user token from the token file (mode 0600; the runtime tightens an existing file to 0600 at start-up and warns if another user owns it). Requests from browsers (any `Origin` header) are rejected, so web pages cannot inject events. +- **Reads are local and unauthenticated.** Any process of yours can read traced runs from the runtime's API, the same as its database file. Traced runs contain prompts and commands. +- **Delete.** Remove the runtime's `runloop.db` (and `artifacts/`), and the plugin's spool directory. + +## Turning it off + +- For one shell: `RUNLOOP_TRACE=0 claude …` +- Without uninstalling: set the plugin option `enabled` to false, or `claude plugin disable runloop-tracer`. +- Uninstall: `claude plugin uninstall runloop-tracer`. Claude Code deletes the plugin's data directory (and the spool) with it. + +`claude --bare` and `--safe-mode` sessions skip plugins, so they are never traced. `claude -p` and Agent SDK sessions are traced while the plugin is enabled; set `RUNLOOP_TRACE=0` in CI. + +## How it works + +``` +Claude Code hook ─► node scripts/runloop-hook.mjs + read stdin → slim (drop tool output, keep counts) → redact + → write spool//