mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-22 11:02:59 -05:00
* refactor(repo): monorepo groundwork for an easier first run Trying OMA for the first time costs more than it should: a lot to read before anything runs, and no one-command way to watch a team actually work. The plan is to fix that with a `npm create oma` scaffold (a multi-agent demo that runs on the first try) and an npm README that leads with getting started. Neither lands cleanly until core lives in its own package, so this PR does that part first. This is groundwork, not the payoff: nothing changes for anyone installing `@open-multi-agent/core` today. It turns the repo into an npm-workspaces monorepo with core in `packages/core/`, and splits the README into a GitHub landing page (root) and the npm package page (`packages/core/`). Doing it now, while the repo is small, avoids migrating twice. The published package is byte-identical: same name, same three runtime dependencies, same entry points, guarded by an isolated-install smoke test. `npm ci` + lint + test (1060) + build pass; the tarball still ships only `dist/`, `README.md`, `LICENSE`, and `package.json`. * build: restore cross-platform optional deps in package-lock.json Regenerating the lockfile on macOS pruned the non-host rollup/esbuild optional binaries (npm/cli#4828), so Linux CI could not load @rollup/rollup-linux-x64-gnu and vitest failed to start. Rebuild the lock from the pre-migration base so every platform variant is present again. Verified with npm ci + lint + build + test (1060). * fix(examples,docs): correct monorepo paths and anchors after the packages/core move with-vercel-ai-sdk example (pre-existing, surfaced by the move): - package.json: the @open-multi-agent/core file: link was file:../../, which resolves to packages/core/examples (no manifest). Corrected to file:../../.. (packages/core); predev cd updated to match. - package-lock.json regenerated: was linking ../.. and pinning a stale core 1.4.0; now links ../../.. at 1.7.0. - README: setup paths and the file: note updated. docs missed by the move: - docs/cli.md: repo-clone build output is now packages/core/dist/cli/oma.js; the quick-start anchor now lives in the package README. - CONTRIBUTING.md: the architecture anchor and src/ entry-point paths now live under packages/core/.
246 lines
7.5 KiB
TypeScript
246 lines
7.5 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { TaskQueue } from '../src/task/queue.js'
|
|
import { createTask } from '../src/task/task.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Create a simple task with a predictable id. */
|
|
function task(id: string, opts: { dependsOn?: string[]; assignee?: string } = {}) {
|
|
const t = createTask({ title: id, description: `task ${id}`, assignee: opts.assignee })
|
|
// Override the random UUID so tests can reference tasks by name.
|
|
return { ...t, id, dependsOn: opts.dependsOn } as ReturnType<typeof createTask>
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('TaskQueue', () => {
|
|
// -------------------------------------------------------------------------
|
|
// Basic add & query
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('adds a task and lists it', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a'))
|
|
expect(q.list()).toHaveLength(1)
|
|
expect(q.list()[0].id).toBe('a')
|
|
expect(q.get('a')?.title).toBe('a')
|
|
})
|
|
|
|
it('fires task:ready for a task with no dependencies', () => {
|
|
const q = new TaskQueue()
|
|
const handler = vi.fn()
|
|
q.on('task:ready', handler)
|
|
|
|
q.add(task('a'))
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
|
expect(handler.mock.calls[0][0].id).toBe('a')
|
|
})
|
|
|
|
it('blocks a task whose dependency is not yet completed', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a'))
|
|
q.add(task('b', { dependsOn: ['a'] }))
|
|
|
|
const b = q.list().find((t) => t.id === 'b')!
|
|
expect(b.status).toBe('blocked')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Dependency resolution
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('unblocks a dependent task when its dependency completes', () => {
|
|
const q = new TaskQueue()
|
|
const readyHandler = vi.fn()
|
|
q.on('task:ready', readyHandler)
|
|
|
|
q.add(task('a'))
|
|
q.add(task('b', { dependsOn: ['a'] }))
|
|
|
|
// 'a' fires task:ready, 'b' is blocked
|
|
expect(readyHandler).toHaveBeenCalledTimes(1)
|
|
|
|
q.complete('a', 'done')
|
|
|
|
// 'b' should now be unblocked → fires task:ready
|
|
expect(readyHandler).toHaveBeenCalledTimes(2)
|
|
expect(readyHandler.mock.calls[1][0].id).toBe('b')
|
|
expect(q.list().find((t) => t.id === 'b')!.status).toBe('pending')
|
|
})
|
|
|
|
it('keeps a task blocked until ALL dependencies complete', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a'))
|
|
q.add(task('b'))
|
|
q.add(task('c', { dependsOn: ['a', 'b'] }))
|
|
|
|
q.complete('a')
|
|
|
|
const cAfterA = q.list().find((t) => t.id === 'c')!
|
|
expect(cAfterA.status).toBe('blocked')
|
|
|
|
q.complete('b')
|
|
|
|
const cAfterB = q.list().find((t) => t.id === 'c')!
|
|
expect(cAfterB.status).toBe('pending')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Cascade failure
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('cascades failure to direct dependents', () => {
|
|
const q = new TaskQueue()
|
|
const failHandler = vi.fn()
|
|
q.on('task:failed', failHandler)
|
|
|
|
q.add(task('a'))
|
|
q.add(task('b', { dependsOn: ['a'] }))
|
|
|
|
q.fail('a', 'boom')
|
|
|
|
expect(failHandler).toHaveBeenCalledTimes(2) // a + b
|
|
expect(q.list().find((t) => t.id === 'b')!.status).toBe('failed')
|
|
expect(q.list().find((t) => t.id === 'b')!.result).toContain('dependency')
|
|
})
|
|
|
|
it('cascades failure transitively (a → b → c)', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a'))
|
|
q.add(task('b', { dependsOn: ['a'] }))
|
|
q.add(task('c', { dependsOn: ['b'] }))
|
|
|
|
q.fail('a', 'boom')
|
|
|
|
expect(q.list().every((t) => t.status === 'failed')).toBe(true)
|
|
})
|
|
|
|
it('does not cascade failure to independent tasks', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a'))
|
|
q.add(task('b'))
|
|
q.add(task('c', { dependsOn: ['a'] }))
|
|
|
|
q.fail('a', 'boom')
|
|
|
|
expect(q.list().find((t) => t.id === 'b')!.status).toBe('pending')
|
|
expect(q.list().find((t) => t.id === 'c')!.status).toBe('failed')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Completion
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('fires all:complete when every task reaches a terminal state', () => {
|
|
const q = new TaskQueue()
|
|
const allComplete = vi.fn()
|
|
q.on('all:complete', allComplete)
|
|
|
|
q.add(task('a'))
|
|
q.add(task('b'))
|
|
|
|
q.complete('a')
|
|
expect(allComplete).not.toHaveBeenCalled()
|
|
|
|
q.complete('b')
|
|
expect(allComplete).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('fires all:complete when mix of completed and failed', () => {
|
|
const q = new TaskQueue()
|
|
const allComplete = vi.fn()
|
|
q.on('all:complete', allComplete)
|
|
|
|
q.add(task('a'))
|
|
q.add(task('b', { dependsOn: ['a'] }))
|
|
|
|
q.fail('a', 'err') // cascades to b
|
|
expect(allComplete).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('isComplete returns true for an empty queue', () => {
|
|
const q = new TaskQueue()
|
|
expect(q.isComplete()).toBe(true)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Query: next / nextAvailable
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('next() returns a pending task for the given assignee', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a', { assignee: 'alice' }))
|
|
q.add(task('b', { assignee: 'bob' }))
|
|
|
|
expect(q.next('bob')?.id).toBe('b')
|
|
})
|
|
|
|
it('next() returns undefined when no pending task matches', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a', { assignee: 'alice' }))
|
|
expect(q.next('bob')).toBeUndefined()
|
|
})
|
|
|
|
it('nextAvailable() prefers unassigned tasks', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('assigned', { assignee: 'alice' }))
|
|
q.add(task('unassigned'))
|
|
|
|
expect(q.nextAvailable()?.id).toBe('unassigned')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Progress
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('getProgress() returns correct counts', () => {
|
|
const q = new TaskQueue()
|
|
q.add(task('a'))
|
|
q.add(task('b'))
|
|
q.add(task('c', { dependsOn: ['a'] }))
|
|
|
|
q.complete('a')
|
|
|
|
const p = q.getProgress()
|
|
expect(p.total).toBe(3)
|
|
expect(p.completed).toBe(1)
|
|
expect(p.pending).toBe(2) // b + c (unblocked)
|
|
expect(p.blocked).toBe(0)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Event unsubscribe
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('unsubscribe stops receiving events', () => {
|
|
const q = new TaskQueue()
|
|
const handler = vi.fn()
|
|
const off = q.on('task:ready', handler)
|
|
|
|
q.add(task('a'))
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
|
|
|
off()
|
|
q.add(task('b'))
|
|
expect(handler).toHaveBeenCalledTimes(1) // no new call
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Error cases
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('throws when completing a non-existent task', () => {
|
|
const q = new TaskQueue()
|
|
expect(() => q.complete('ghost')).toThrow('not found')
|
|
})
|
|
|
|
it('throws when failing a non-existent task', () => {
|
|
const q = new TaskQueue()
|
|
expect(() => q.fail('ghost', 'err')).toThrow('not found')
|
|
})
|
|
})
|