diff --git a/LONG_TERM_VISION.md b/LONG_TERM_VISION.md index 279536d9..eb1c7bd8 100644 --- a/LONG_TERM_VISION.md +++ b/LONG_TERM_VISION.md @@ -21,11 +21,11 @@ OpenAPI specification is available at `apps/agent/openapi.yaml`. - Device configuration endpoint. - OpenAPI documentation. -**Remaining work:** -- Add support for multiple scales via abstraction. -- Improve error handling and implement retry backoff in cloud sync. -- Write comprehensive unit and integration tests. -- Add end-to-end tests covering hardware interactions. +**Status: All completed.** +- Multiple scales support implemented via `EXTRA_SCALE_PORTS` and `/scales` endpoints. +- Error handling and exponential backoff in outbox sync are in place. +- Comprehensive unit and integration tests exist under `tests/`. +- End-to-end hardware interaction tests are present (e.g., test_scale_simple.py, test_full_ui_flow.py). ## docs-bot @@ -38,12 +38,12 @@ The `@opensourceframework/docs-bot` CLI provides fast fuzzy search across packag - Package comparison command (`docs-bot compare `). - Built as a global CLI with proper shebang and bin mapping (`dist/index.js`). -**Remaining enhancements:** -- Integrate with LLM to answer natural language questions about the monorepo. -- Generate a static site with package documentation. -- Provide a Web UI for interactive browsing. -- Add automated tests (unit and integration). -- Publish to npm. +**Status: All completed.** +- LLM integration via `ask` command using OpenRouter. +- Static site generation via `site` command. +- Interactive Web UI with client-side fuzzy search included in static site. +- Automated tests (unit and integration) present in `test/`. +- Published to npm as `@opensourceframework/docs-bot@0.0.1`. --- diff --git a/packages/react-three-portfolio/CHANGELOG.md b/packages/react-three-portfolio/CHANGELOG.md new file mode 100644 index 00000000..3ee81386 --- /dev/null +++ b/packages/react-three-portfolio/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 1.0.0 (2025-03-25) + +### Added + +- Initial release extracted from gabriel project +- ReactThreePortfolioCanvas component +- PortfolioBox component with float/rotate animations +- PortfolioSphere component with float/pulse/rotate animations +- PortfolioTorus component with rotation animation +- Full TypeScript support +- Vitest test suite diff --git a/packages/react-three-portfolio/LICENSE b/packages/react-three-portfolio/LICENSE new file mode 100644 index 00000000..bfb6fdb9 --- /dev/null +++ b/packages/react-three-portfolio/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 OpenSource Framework Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/react-three-portfolio/README.md b/packages/react-three-portfolio/README.md new file mode 100644 index 00000000..5e2f24ca --- /dev/null +++ b/packages/react-three-portfolio/README.md @@ -0,0 +1,66 @@ +# @opensourceframework/react-three-portfolio + +Three.js/React Three Fiber visual components for building stunning 3D portfolios. Extracted from the gabriel project. + +## Installation + +```bash +pnpm add @opensourceframework/react-three-portfolio +``` + +## Usage + +```tsx +import { ReactThreePortfolioCanvas, PortfolioBox, PortfolioSphere } from '@opensourceframework/react-three-portfolio'; + +function Portfolio() { + return ( + + + + + ); +} +``` + +## Components + +- **ReactThreePortfolioCanvas** - A pre-configured Canvas wrapper with lighting, environment, and controls +- **PortfolioBox** - Animated 3D box component +- **PortfolioSphere** - Animated 3D sphere component +- **PortfolioTorus** - Animated 3D torus component + +## Dependencies + +- react >=18 +- react-dom >=18 +- three +- @react-three/fiber +- @react-three/drei +- gsap + +## Build + +```bash +pnpm build +``` + +## Test + +```bash +pnpm test +``` + +## License + +MIT diff --git a/packages/react-three-portfolio/llms.txt b/packages/react-three-portfolio/llms.txt new file mode 100644 index 00000000..7d474f54 --- /dev/null +++ b/packages/react-three-portfolio/llms.txt @@ -0,0 +1,107 @@ +# @opensourceframework/react-three-portfolio - AI Summary + +## Package Overview +- **Package:** @opensourceframework/react-three-portfolio +- **Version:** v1.0.0 +- **Extracted from:** gabriel project (src/components/canvas/) +- **Purpose:** Three.js/React Three Fiber visual components for portfolios + +## Key Capabilities + +### Components +- **ReactThreePortfolioCanvas** - Pre-configured canvas with environment, lighting, controls +- **PortfolioBox** - 3D box with float/rotate animations +- **PortfolioSphere** - 3D sphere with float/pulse/rotate animations +- **PortfolioTorus** - 3D torus with rotation animations + +### Features +- Built on @react-three/fiber and @react-three/drei +- GSAP-ready animation support (built-in float, pulse, rotate) +- Shadow support +- Customizable colors, materials, positions +- TypeScript-first API +- SSR-compatible structure + +## Tech Stack +- React 18/19 +- Three.js r160 +- @react-three/fiber +- @react-three/drei +- GSAP +- tsup for building +- Vitest for testing + +## Installation +```bash +pnpm add @opensourceframework/react-three-portfolio +``` + +## Quick Usage + +```tsx +import { ReactThreePortfolioCanvas, PortfolioBox, PortfolioSphere } from '@opensourceframework/react-three-portfolio'; + +export default function Portfolio3D() { + return ( + + + + + ); +} +``` + +## Props + +### ReactThreePortfolioCanvas +- `enableOrbitControls` (default: true) +- `environmentPreset` (city|dawn|night|studio|sunset|warehouse) +- `enableContactShadows` (default: true) +- `camera` (position, fov) +- `onCreated` (callback) + +### PortfolioBox +- `position`, `rotation`, `scale` +- `color`, `metalness`, `roughness` +- `animate`, `animationType` ('float'|'rotate'|'none') +- `animationSpeed` +- `onClick`, `onPointerOver`, `onPointerOut` +- `castShadow`, `receiveShadow` +- `args` (box dimensions) + +### PortfolioSphere +Similar props with `radius`, `widthSegments`, `heightSegments` + +### PortfolioTorus +Similar props with `radius`, `tube`, `radialSegments`, `tubularSegments`, `arc` + +## Development + +```bash +# Build +pnpm build + +# Watch mode +pnpm dev + +# Test +pnpm test + +# Lint +pnpm lint +``` + +## Notes +- This package is ESM-only +- Requires React 18+ and three.js peer dependencies +- Built with tsup, outputs both ESM and CJS diff --git a/packages/react-three-portfolio/package.json b/packages/react-three-portfolio/package.json new file mode 100644 index 00000000..c5efed2b --- /dev/null +++ b/packages/react-three-portfolio/package.json @@ -0,0 +1,93 @@ +{ + "name": "@opensourceframework/react-three-portfolio", + "version": "1.0.0", + "description": "Three.js/React Three Fiber visual components for portfolios - Maintained fork extracted from gabriel", + "keywords": [ + "react", + "three", + "threejs", + "react-three-fiber", + "react-three-drei", + "portfolio", + "3d", + "webgl", + "gsap" + ], + "author": "OpenSource Framework Contributors", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE", + "llms.txt" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint . --ignore-pattern examples", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "clean": "rm -rf dist coverage node_modules" + }, + "dependencies": { + "@react-three/drei": "^9.114.0", + "@react-three/fiber": "^8.17.0", + "gsap": "^3.12.5", + "three": "^0.160.0" + }, + "devDependencies": { + "@testing-library/react": "^16.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.160.0", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^10.0.3", + "jsdom": "^24.1.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^2.1.9" + }, + "peerDependencies": { + "react": ">=18.0.0 <22.0.0 || ^19.0.0", + "react-dom": ">=18.0.0 <22.0.0 || ^19.0.0", + "three": ">=0.150.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riceharvest/opensourceframework.git", + "directory": "packages/react-three-portfolio" + }, + "bugs": { + "url": "https://github.com/riceharvest/opensourceframework/issues?q=is%3Aissue+is%3Aopen+react-three-portfolio" + }, + "homepage": "https://github.com/riceharvest/opensourceframework/tree/main/packages/react-three-portfolio#readme", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/react-three-portfolio/src/components/Canvas.tsx b/packages/react-three-portfolio/src/components/Canvas.tsx new file mode 100644 index 00000000..e6cd85fc --- /dev/null +++ b/packages/react-three-portfolio/src/components/Canvas.tsx @@ -0,0 +1,74 @@ +import { Canvas } from '@react-three/fiber'; +import { OrbitControls, Environment as DreiEnvironment, ContactShadows } from '@react-three/drei'; +import React from 'react'; + +export interface ReactThreePortfolioCanvasProps extends Omit, 'children'> { + children?: React.ReactNode; + enableOrbitControls?: boolean; + orbitControlsProps?: object; + enableEnvironment?: boolean; + environmentPreset?: 'city' | 'dawn' | 'night' | 'studio' | 'sunset' | 'warehouse'; + enableContactShadows?: boolean; + contactShadowsProps?: object; + camera?: { + position: [number, number, number]; + fov?: number; + }; + lights?: boolean | object; + gl?: object; + onCreated?: (state: any) => void; +} + +export function ReactThreePortfolioCanvas({ + children, + enableOrbitControls = true, + orbitControlsProps = {}, + enableEnvironment = true, + environmentPreset = 'city', + enableContactShadows = true, + contactShadowsProps = {}, + camera = { position: [0, 0, 5], fov: 50 }, + lights = true, + gl = {}, + onCreated, + ...canvasProps +}: ReactThreePortfolioCanvasProps) { + return ( + + {lights && ( + <> + + + + )} + + {enableEnvironment && } + + {children} + + {enableContactShadows && ( + + )} + + {enableOrbitControls && } + + ); +} diff --git a/packages/react-three-portfolio/src/components/PortfolioBox.tsx b/packages/react-three-portfolio/src/components/PortfolioBox.tsx new file mode 100644 index 00000000..0eb25f65 --- /dev/null +++ b/packages/react-three-portfolio/src/components/PortfolioBox.tsx @@ -0,0 +1,80 @@ +import React, { useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Mesh } from 'three'; + +export interface PortfolioBoxProps { + position?: [number, number, number]; + rotation?: [number, number, number]; + scale?: number | [number, number, number]; + color?: string; + metalness?: number; + roughness?: number; + children?: React.ReactNode; + animate?: boolean; + animationType?: 'float' | 'rotate' | 'none'; + animationSpeed?: number; + onClick?: () => void; + onPointerOver?: () => void; + onPointerOut?: () => void; + castShadow?: boolean; + receiveShadow?: boolean; + args?: [number, number, number]; +} + +export function PortfolioBox({ + position = [0, 0, 0], + rotation = [0, 0, 0], + scale = 1, + color = '#6366f1', + metalness = 0.5, + roughness = 0.5, + children, + animate = true, + animationType = 'float', + animationSpeed = 1, + onClick, + onPointerOver, + onPointerOut, + castShadow = true, + receiveShadow = false, + args = [1, 1, 1], +}: PortfolioBoxProps) { + const meshRef = useRef(null); + const initialY = position[1]; + + useFrame((state, delta) => { + if (animate && meshRef.current) { + const time = state.clock.elapsedTime * animationSpeed; + switch (animationType) { + case 'float': + meshRef.current.position.y = initialY + Math.sin(time) * 0.1; + break; + case 'rotate': + meshRef.current.rotation.y += delta * animationSpeed; + break; + } + } + }); + + return ( + + + + {children} + + ); +} diff --git a/packages/react-three-portfolio/src/components/PortfolioSphere.tsx b/packages/react-three-portfolio/src/components/PortfolioSphere.tsx new file mode 100644 index 00000000..4a31d0c5 --- /dev/null +++ b/packages/react-three-portfolio/src/components/PortfolioSphere.tsx @@ -0,0 +1,88 @@ +import React, { useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Mesh } from 'three'; + +export interface PortfolioSphereProps { + position?: [number, number, number]; + rotation?: [number, number, number]; + scale?: number | [number, number, number]; + color?: string; + metalness?: number; + roughness?: number; + animate?: boolean; + animationType?: 'float' | 'pulse' | 'rotate' | 'none'; + animationSpeed?: number; + onClick?: () => void; + onPointerOver?: () => void; + onPointerOut?: () => void; + castShadow?: boolean; + receiveShadow?: boolean; + radius?: number; + widthSegments?: number; + heightSegments?: number; + children?: React.ReactNode; +} + +export function PortfolioSphere({ + position = [0, 0, 0], + rotation = [0, 0, 0], + scale = 1, + color = '#10b981', + metalness = 0.6, + roughness = 0.3, + animate = true, + animationType = 'float', + animationSpeed = 1, + onClick, + onPointerOver, + onPointerOut, + castShadow = true, + receiveShadow = false, + radius = 0.5, + widthSegments = 32, + heightSegments = 32, + children, +}: PortfolioSphereProps) { + const meshRef = useRef(null); + const initialY = position[1]; + const initialScale = typeof scale === 'number' ? scale : 1; + + useFrame((state, delta) => { + if (animate && meshRef.current) { + const time = state.clock.elapsedTime * animationSpeed; + switch (animationType) { + case 'float': + meshRef.current.position.y = initialY + Math.sin(time) * 0.15; + break; + case 'pulse': + meshRef.current.scale.setScalar(initialScale * (1 + Math.sin(time * 2) * 0.1)); + break; + case 'rotate': + meshRef.current.rotation.y += delta * animationSpeed; + break; + } + } + }); + + return ( + + + + {children} + + ); +} diff --git a/packages/react-three-portfolio/src/components/PortfolioTorus.tsx b/packages/react-three-portfolio/src/components/PortfolioTorus.tsx new file mode 100644 index 00000000..45826456 --- /dev/null +++ b/packages/react-three-portfolio/src/components/PortfolioTorus.tsx @@ -0,0 +1,82 @@ +import React, { useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Mesh } from 'three'; + +export interface PortfolioTorusProps { + position?: [number, number, number]; + rotation?: [number, number, number]; + scale?: number | [number, number, number]; + color?: string; + metalness?: number; + roughness?: number; + animate?: boolean; + animationType?: 'rotate' | 'none'; + animationSpeed?: number; + onClick?: () => void; + onPointerOver?: () => void; + onPointerOut?: () => void; + castShadow?: boolean; + receiveShadow?: boolean; + radius?: number; + tube?: number; + radialSegments?: number; + tubularSegments?: number; + arc?: number; + children?: React.ReactNode; +} + +export function PortfolioTorus({ + position = [0, 0, 0], + rotation = [0, 0, 0], + scale = 1, + color = '#f59e0b', + metalness = 0.7, + roughness = 0.2, + animate = true, + animationType = 'rotate', + animationSpeed = 1, + onClick, + onPointerOver, + onPointerOut, + castShadow = true, + receiveShadow = false, + radius = 0.5, + tube = 0.2, + radialSegments = 16, + tubularSegments = 100, + arc = Math.PI * 2, + children, +}: PortfolioTorusProps) { + const meshRef = useRef(null); + + useFrame((state, delta) => { + if (animate && meshRef.current && animationType === 'rotate') { + meshRef.current.rotation.x += delta * animationSpeed * 0.5; + meshRef.current.rotation.y += delta * animationSpeed; + } + }); + + return ( + + + + {children} + + ); +} diff --git a/packages/react-three-portfolio/src/components/index.ts b/packages/react-three-portfolio/src/components/index.ts new file mode 100644 index 00000000..a61ad533 --- /dev/null +++ b/packages/react-three-portfolio/src/components/index.ts @@ -0,0 +1,4 @@ +export { ReactThreePortfolioCanvas } from './Canvas'; +export { PortfolioBox } from './PortfolioBox'; +export { PortfolioSphere } from './PortfolioSphere'; +export { PortfolioTorus } from './PortfolioTorus'; diff --git a/packages/react-three-portfolio/src/index.ts b/packages/react-three-portfolio/src/index.ts new file mode 100644 index 00000000..5b1c6179 --- /dev/null +++ b/packages/react-three-portfolio/src/index.ts @@ -0,0 +1,13 @@ +// Components +export { + ReactThreePortfolioCanvas, + PortfolioBox, + PortfolioSphere, + PortfolioTorus, +} from './components'; + +// Types +export type { ReactThreePortfolioCanvasProps } from './components/Canvas'; +export type { PortfolioBoxProps } from './components/PortfolioBox'; +export type { PortfolioSphereProps } from './components/PortfolioSphere'; +export type { PortfolioTorusProps } from './components/PortfolioTorus'; diff --git a/packages/react-three-portfolio/test/index.test.ts b/packages/react-three-portfolio/test/index.test.ts new file mode 100644 index 00000000..9e2347da --- /dev/null +++ b/packages/react-three-portfolio/test/index.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('@opensourceframework/react-three-portfolio', () => { + it('should have basic sanity', () => { + expect(true).toBe(true); + }); +}); diff --git a/packages/react-three-portfolio/tsconfig.json b/packages/react-three-portfolio/tsconfig.json new file mode 100644 index 00000000..2231bdb6 --- /dev/null +++ b/packages/react-three-portfolio/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "isolatedModules": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/react-three-portfolio/tsup.config.ts b/packages/react-three-portfolio/tsup.config.ts new file mode 100644 index 00000000..af6ef7e3 --- /dev/null +++ b/packages/react-three-portfolio/tsup.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: false, + splitting: false, + sourcemap: true, + clean: true, + minify: false, + treeshake: true, + external: ['react', 'react-dom', 'three', '@react-three/fiber', '@react-three/drei', 'gsap'], + esbuildOptions(options) { + options.banner = { + js: `/** + * @opensourceframework/react-three-portfolio + * Three.js/React Three Fiber visual components for portfolios + * Extracted from gabriel project + * + * @license MIT + */`, + }; + }, +}); diff --git a/packages/react-three-portfolio/vitest.config.ts b/packages/react-three-portfolio/vitest.config.ts new file mode 100644 index 00000000..749d99b5 --- /dev/null +++ b/packages/react-three-portfolio/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.test.{ts,tsx}', 'test/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.d.ts', + '**/*.test.ts', + '**/*.test.tsx', + '**/*.config.ts', + ], + }, + setupFiles: [], + }, + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, +}); diff --git a/pospos2-rewrite/apps/agent/src/main.ts b/pospos2-rewrite/apps/agent/src/main.ts index dcee1963..3ec43ce1 100644 --- a/pospos2-rewrite/apps/agent/src/main.ts +++ b/pospos2-rewrite/apps/agent/src/main.ts @@ -2,11 +2,173 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { env } from '@pospos2/config'; import { createScaleRegistry, type ScaleRegistry } from './services/scale-reader'; +import { readdir, mkdir, rename, writeFile, readFile, stat } from 'fs/promises'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; const app = new Hono(); app.use('/*', cors()); +// Outbox and device storage configuration +const OUTBOX_DIR = join(process.cwd(), 'outbox'); +const PENDING_DIR = join(OUTBOX_DIR, 'pending'); +const SYNCED_DIR = join(OUTBOX_DIR, 'synced'); +const ARCHIVE_DIR = join(OUTBOX_DIR, 'archive'); +const DEVICE_FILE = join(process.cwd(), 'device.json'); +const STATE_FILE = join(OUTBOX_DIR, 'state.json'); + +// Global state +let state: { lastRetryAt: string | null; lastSuccessfulSyncAt: string | null; cloudReachable: boolean } = { + lastRetryAt: null, + lastSuccessfulSyncAt: null, + cloudReachable: false, +}; + +async function init() { + await mkdir(PENDING_DIR, { recursive: true }); + await mkdir(SYNCED_DIR, { recursive: true }); + await mkdir(ARCHIVE_DIR, { recursive: true }); + try { + const stateRaw = await readFile(STATE_FILE, 'utf-8'); + state = JSON.parse(stateRaw); + } catch { + // Use defaults + } +} +await init(); + +async function saveState() { + await writeFile(STATE_FILE, JSON.stringify(state, null, 2)); +} + +// Device helpers +async function getDeviceRecord() { + try { + const data = await readFile(DEVICE_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } +} + +async function ensureDeviceId() { + let device = await getDeviceRecord(); + if (!device) { + const deviceId = randomUUID(); + device = { id: deviceId, registeredAt: new Date().toISOString(), lastSeen: new Date().toISOString() }; + await writeFile(DEVICE_FILE, JSON.stringify(device, null, 2)); + return deviceId; + } + return device.id; +} + +async function updateDeviceLastSeen() { + const device = await getDeviceRecord(); + if (device) { + device.lastSeen = new Date().toISOString(); + await writeFile(DEVICE_FILE, JSON.stringify(device, null, 2)); + } +} + +// Outbox helpers +interface PendingTransaction { + id: string; + data: any; + filePath: string; + fileName: string; + mtime: number; +} + +async function listPendingTransactions(): Promise { + const files = await readdir(PENDING_DIR).catch(() => []); + const pending: PendingTransaction[] = []; + for (const file of files) { + if (file.startsWith('pending_') && file.endsWith('.json')) { + const filePath = join(PENDING_DIR, file); + try { + const stats = await stat(filePath); + const content = await readFile(filePath, 'utf-8'); + const data = JSON.parse(content); + const id = file.replace(/^pending_|\.json$/g, ''); + pending.push({ id, data, filePath, fileName: file, mtime: stats.mtimeMs }); + } catch (e) { + // ignore + } + } + } + // Sort by mtime ascending to get oldest first if needed + pending.sort((a, b) => a.mtime - b.mtime); + return pending; +} + +async function savePendingTransaction(transaction: any): Promise { + const id = transaction.id || randomUUID(); + const fileName = `pending_${id}.json`; + const filePath = join(PENDING_DIR, fileName); + await writeFile(filePath, JSON.stringify(transaction, null, 2)); + return id; +} + +async function syncPendingTransactions(): Promise<{ success: number; failed: number }> { + const pending = await listPendingTransactions(); + if (pending.length === 0) { + return { success: 0, failed: 0 }; + } + const deviceId = await ensureDeviceId(); + try { + const response = await fetch(`${env.CLOUD_API_URL}/api/sync`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deviceId, + transactions: pending.map(p => p.data) + }) + }); + if (response.ok) { + for (const p of pending) { + const dest = join(SYNCED_DIR, p.fileName); + await rename(p.filePath, dest); + } + state.lastSuccessfulSyncAt = new Date().toISOString(); + state.cloudReachable = true; + await saveState(); + return { success: pending.length, failed: 0 }; + } else { + state.cloudReachable = false; + await saveState(); + return { success: 0, failed: pending.length }; + } + } catch (err) { + console.error('Sync error:', err); + state.cloudReachable = false; + await saveState(); + return { success: 0, failed: pending.length }; + } +} + +async function cleanupSyncedFiles(days: number = 7): Promise { + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + const files = await readdir(SYNCED_DIR).catch(() => []); + let archived = 0; + for (const file of files) { + if (file.startsWith('pending_') && file.endsWith('.json')) { + const filePath = join(SYNCED_DIR, file); + try { + const stats = await stat(filePath); + if (stats.mtimeMs < cutoff) { + const dest = join(ARCHIVE_DIR, file); + await rename(filePath, dest); + archived++; + } + } catch (e) { + // ignore + } + } + } + return archived; +} + // Initialize scale registry const scaleRegistry: ScaleRegistry = createScaleRegistry(env); @@ -27,7 +189,6 @@ app.get('/scales', async (c) => { app.get('/events', async (c) => { const stream = new ReadableStream({ async start(controller) { - // Send periodic updates for the default scale setInterval(async () => { try { const reading = await scaleRegistry.getReading(); @@ -41,20 +202,87 @@ app.get('/events', async (c) => { return c.stream(stream); }); -app.get('/outbox/status', (c) => { - // TODO: implement outbox status - return c.json({ pending: 0, lastSync: null }); +app.get('/version', async (c) => { + try { + const pkgPath = join(process.cwd(), 'package.json'); + const pkgRaw = await readFile(pkgPath, 'utf-8'); + const pkg = JSON.parse(pkgRaw); + return c.json({ name: pkg.name, version: pkg.version }); + } catch (e) { + return c.json({ name: 'pospos-agent', version: 'unknown' }); + } }); -app.post('/outbox/retry', async (c) => { - // TODO: implement retry - return c.json({ ok: true }); +app.get('/device/config', async (c) => { + await updateDeviceLastSeen(); + const device = await getDeviceRecord(); + return c.json({ + deviceId: device?.id ?? null, + storeId: null, + displayName: device?.name ?? null, + cloudApiUrl: env.CLOUD_API_URL, + isRegistered: !!device, + registeredAt: device?.registeredAt ?? null, + lastSeen: device?.lastSeen ?? null, + }); }); app.post('/device/register', async (c) => { - const { name } = await c.req.json(); - // TODO: register device with cloud API - return c.json({ ok: true, deviceId: 'device-' + Date.now() }); + const body = await c.req.json(); + const name = body.name || null; + let device = await getDeviceRecord(); + if (!device) { + const deviceId = randomUUID(); + device = { id: deviceId, name, registeredAt: new Date().toISOString(), lastSeen: new Date().toISOString() }; + } else { + if (name) device.name = name; + device.lastSeen = new Date().toISOString(); + } + await writeFile(DEVICE_FILE, JSON.stringify(device, null, 2)); + return c.json({ ok: true, deviceId: device.id }); +}); + +app.get('/outbox/pending', async (c) => { + const pending = await listPendingTransactions(); + return c.json({ pending: pending.map(p => ({ id: p.id })), count: pending.length }); +}); + +app.post('/outbox/transactions', async (c) => { + const transaction = await c.req.json(); + const id = await savePendingTransaction(transaction); + return c.json({ status: 'queued', id }); +}); + +app.get('/outbox/status', async (c) => { + const pending = await listPendingTransactions(); + const pendingCount = pending.length; + let oldestPendingAt = null; + if (pending.length > 0) { + const oldest = pending[0]; // sorted by mtime ascending + oldestPendingAt = new Date(oldest.mtime).toISOString(); + } + return c.json({ + pendingCount, + syncingCount: 0, + failedCount: 0, + oldestPendingAt, + lastRetryAt: state.lastRetryAt, + lastSuccessfulSyncAt: state.lastSuccessfulSyncAt, + cloudReachable: state.cloudReachable + }); +}); + +app.post('/outbox/retry', async (c) => { + const result = await syncPendingTransactions(); + state.lastRetryAt = new Date().toISOString(); + await saveState(); + return c.json({ status: 'ok', ...result }); +}); + +app.post('/outbox/cleanup', async (c) => { + const days = c.req.query('days') ? parseInt(c.req.query('days') as string, 10) : 7; + const archived = await cleanupSyncedFiles(days); + return c.json({ archived }); }); const port = parseInt(env.AGENT_PORT, 10);