diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..433d468 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.orchestrator/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c32f9c1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpSpawn + +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. diff --git a/README.md b/README.md index 1ed0a32..3a743f7 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,22 @@ Built by an AI agent ([OpSpawn](https://opspawn.com)) to coordinate its own work - **Knowledge Base**: File-based knowledge sharing between agents - **Cycle Runner**: Generate plans, briefs, and collect results +## Install + +```bash +# Install from GitHub +npm install opspawn/orchestrator + +# Or clone directly +git clone https://github.com/opspawn/orchestrator.git +cd orchestrator +``` + ## Quick Start ```bash # View system status -node cli.js status +npx orchestrator status # Create workstreams node cli.js ws create revenue "Revenue generation" 1 @@ -45,7 +56,7 @@ node runner.js plan ## API (Node.js) ```javascript -const orc = require('./orchestrator'); +const orc = require('@opspawn/orchestrator'); // Workstreams orc.createWorkstream('build', { description: 'Build things', priority: 1 }); diff --git a/orchestrator.js b/orchestrator.js index 5d3a68f..0749445 100644 --- a/orchestrator.js +++ b/orchestrator.js @@ -28,15 +28,40 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const STATE_PATH = path.join(__dirname, 'state.json'); -const EVENTS_PATH = path.join(__dirname, 'events.jsonl'); -const KNOWLEDGE_DIR = path.join(__dirname, 'knowledge'); +// Data directory: ORCHESTRATOR_DIR env var, or .orchestrator/ in cwd, or __dirname for backwards compat +const DATA_DIR = process.env.ORCHESTRATOR_DIR || ( + fs.existsSync(path.join(__dirname, 'state.json')) ? __dirname : + path.join(process.cwd(), '.orchestrator') +); -// Ensure knowledge dir exists +const STATE_PATH = path.join(DATA_DIR, 'state.json'); +const EVENTS_PATH = path.join(DATA_DIR, 'events.jsonl'); +const KNOWLEDGE_DIR = path.join(DATA_DIR, 'knowledge'); + +// Ensure data directories exist +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} if (!fs.existsSync(KNOWLEDGE_DIR)) { fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true }); } +// Initialize state file if it doesn't exist +if (!fs.existsSync(STATE_PATH)) { + fs.writeFileSync(STATE_PATH, JSON.stringify({ + version: 0, + updated_at: new Date().toISOString(), + workstreams: {}, + agents: {}, + locks: {} + }, null, 2) + '\n'); +} + +// Initialize events file if it doesn't exist +if (!fs.existsSync(EVENTS_PATH)) { + fs.writeFileSync(EVENTS_PATH, ''); +} + function loadState() { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } diff --git a/package.json b/package.json index b0363a8..d1772f5 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,42 @@ { - "name": "orchestrator", + "name": "@opspawn/orchestrator", "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "description": "Lightweight agent coordination system with shared state, task management, event sourcing, and resource locking", + "main": "orchestrator.js", + "bin": { + "orchestrator": "./cli.js", + "orchestrator-runner": "./runner.js" }, - "keywords": [], - "author": "", - "license": "ISC" + "scripts": { + "test": "node test.js" + }, + "keywords": [ + "ai-agent", + "orchestrator", + "coordination", + "multi-agent", + "task-management", + "event-sourcing", + "agent-infrastructure" + ], + "author": "OpSpawn (https://opspawn.com)", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/opspawn/orchestrator.git" + }, + "homepage": "https://github.com/opspawn/orchestrator", + "bugs": { + "url": "https://github.com/opspawn/orchestrator/issues" + }, + "engines": { + "node": ">=16.0.0" + }, + "files": [ + "orchestrator.js", + "cli.js", + "runner.js", + "README.md", + "LICENSE" + ] } diff --git a/test.js b/test.js new file mode 100644 index 0000000..d07342f --- /dev/null +++ b/test.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * Basic tests for OpSpawn Orchestrator + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Use a temp directory for test data +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-test-')); +process.env.ORCHESTRATOR_DIR = testDir; + +// Now require the module (it will use the temp dir) +const orc = require('./orchestrator'); + +let passed = 0; +let failed = 0; + +function assert(condition, msg) { + if (condition) { + passed++; + console.log(` PASS: ${msg}`); + } else { + failed++; + console.error(` FAIL: ${msg}`); + } +} + +function test(name, fn) { + console.log(`\n${name}`); + try { + fn(); + } catch (err) { + failed++; + console.error(` ERROR: ${err.message}`); + } +} + +// --- Tests --- + +test('State initialization', () => { + const state = orc.loadState(); + assert(state.version === 0, 'Initial version is 0'); + assert(Object.keys(state.workstreams).length === 0, 'No workstreams initially'); + assert(Object.keys(state.agents).length === 0, 'No agents initially'); + assert(Object.keys(state.locks).length === 0, 'No locks initially'); +}); + +test('Workstream CRUD', () => { + const ws = orc.createWorkstream('test-ws', { description: 'Test workstream', priority: 1 }); + assert(ws.description === 'Test workstream', 'Workstream has description'); + assert(ws.priority === 1, 'Workstream has priority'); + + const list = orc.listWorkstreams(); + assert(list.length === 1, 'One workstream listed'); + assert(list[0].name === 'test-ws', 'Workstream name matches'); + + let threw = false; + try { orc.createWorkstream('test-ws'); } catch (e) { threw = true; } + assert(threw, 'Cannot create duplicate workstream'); +}); + +test('Task lifecycle', () => { + const task = orc.addTask('test-ws', { title: 'Test task', description: 'A test', priority: 2 }); + assert(task.id.length === 8, 'Task has 8-char ID'); + assert(task.status === 'pending', 'Task starts pending'); + assert(task.title === 'Test task', 'Task title matches'); + + const claimed = orc.claimTask('test-ws', task.id, 'agent-test'); + assert(claimed.status === 'in_progress', 'Claimed task is in_progress'); + assert(claimed.assigned_to === 'agent-test', 'Task assigned to agent'); + + let threw = false; + try { orc.claimTask('test-ws', task.id, 'agent-2'); } catch (e) { threw = true; } + assert(threw, 'Cannot claim already-claimed task'); + + const completed = orc.completeTask('test-ws', task.id, 'Done!'); + assert(completed.status === 'done', 'Completed task is done'); + assert(completed.result === 'Done!', 'Result recorded'); +}); + +test('getNextTask', () => { + orc.addTask('test-ws', { title: 'Task A', priority: 3 }); + orc.addTask('test-ws', { title: 'Task B', priority: 1 }); + + const next = orc.getNextTask('test-ws', 'agent-auto'); + assert(next.title === 'Task B', 'Gets highest priority (lowest number) task'); + assert(next.status === 'in_progress', 'Auto-claimed'); +}); + +test('Agent registration', () => { + const agent = orc.registerAgent('test-agent', { type: 'worker', capabilities: ['code'] }); + assert(agent.type === 'worker', 'Agent type set'); + assert(agent.status === 'active', 'Agent starts active'); + + orc.heartbeat('test-agent'); + const state = orc.loadState(); + assert(state.agents['test-agent'].status === 'active', 'Heartbeat keeps active'); +}); + +test('Resource locking', () => { + const got = orc.acquireLock('git-repo', 'agent-1', 5000); + assert(got === true, 'Lock acquired'); + + const denied = orc.acquireLock('git-repo', 'agent-2', 5000); + assert(denied === false, 'Lock denied to other agent'); + + const reacquire = orc.acquireLock('git-repo', 'agent-1', 5000); + assert(reacquire === true, 'Same agent can re-acquire'); + + const released = orc.releaseLock('git-repo', 'agent-1'); + assert(released === true, 'Lock released'); + + const wrongRelease = orc.releaseLock('git-repo', 'agent-2'); + assert(wrongRelease === false, 'Cannot release lock not held'); +}); + +test('Knowledge base', () => { + orc.writeKnowledge('test-topic', '# Test\nSome knowledge here.'); + const content = orc.readKnowledge('test-topic'); + assert(content.includes('Some knowledge here'), 'Knowledge written and read'); + + const missing = orc.readKnowledge('nonexistent'); + assert(missing === null, 'Missing topic returns null'); + + const topics = orc.listKnowledge(); + assert(topics.includes('test-topic'), 'Topic listed'); +}); + +test('Event log', () => { + orc.logEvent('test-agent', 'test_action', { data: 'hello' }); + const events = orc.getEvents({ agent: 'test-agent', action: 'test_action' }); + assert(events.length >= 1, 'Event logged and retrieved'); + assert(events[events.length - 1].data === 'hello', 'Event data preserved'); + + const recent = orc.getEvents({ last: 3 }); + assert(recent.length <= 3, 'Last N filter works'); +}); + +test('Status dashboard', () => { + const s = orc.status(); + assert(s.workstreams.length > 0, 'Status shows workstreams'); + assert(Array.isArray(s.agents), 'Status shows agents'); + assert(typeof s.version === 'number', 'Status shows version'); + + const text = orc.statusText(); + assert(text.includes('OpSpawn Orchestrator Status'), 'Status text has header'); + assert(text.includes('test-ws'), 'Status text shows workstream'); +}); + +// --- Cleanup --- +fs.rmSync(testDir, { recursive: true, force: true }); + +// --- Summary --- +console.log(`\n${'='.repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) { + process.exit(1); +} else { + console.log('All tests passed!'); +}