diff --git a/packages/core/src/control/action.test.ts b/packages/core/src/control/action.test.ts new file mode 100644 index 0000000..ffe078a --- /dev/null +++ b/packages/core/src/control/action.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { action } from './action.js'; + +describe('action', () => { + it('returns definition as-is with type inference', () => { + const def = action<{ name: string }, { id: string }, { step: number }>({ + manifest: { + name: 'test:echo', + version: '1.0', + title: 'Echo', + description: 'Echoes name', + scope: 'machine', + capability: 'test.read', + idempotent: true, + }, + async *execute(_ctx, input) { + yield { step: 1 }; + return { id: input.name }; + }, + }); + + expect(def.manifest.name).toBe('test:echo'); + expect(typeof def.execute).toBe('function'); + }); +}); diff --git a/packages/core/src/control/action.ts b/packages/core/src/control/action.ts new file mode 100644 index 0000000..d8f5fe1 --- /dev/null +++ b/packages/core/src/control/action.ts @@ -0,0 +1,6 @@ +import type { ActionDefinition } from './types.js'; + +/** Define a control-plane action. Identity fn — returns config as-is for type inference. */ +export const action = ( + def: ActionDefinition +): ActionDefinition => def; diff --git a/packages/core/src/control/actions/actions.test.ts b/packages/core/src/control/actions/actions.test.ts new file mode 100644 index 0000000..c2e059b --- /dev/null +++ b/packages/core/src/control/actions/actions.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest'; +import { output, result } from '../../events.js'; +import { createRegistry } from '../registry.js'; +import type { InvokeEvent } from '../types.js'; +import { createAgentInstallAction } from './agent-install.js'; +import { createCliUpdateAction } from './cli-update.js'; +import { createProjectAddFromOriginAction } from './project-add-from-origin.js'; +import type { ShellExec } from './types.js'; + +const fakeShell = (success = true, observe?: (cmd: string) => void): ShellExec => + async function* (command) { + observe?.(command); + yield output(`running: ${command}`); + yield result(success, success ? 'ok' : 'fail'); + }; + +const collect = async (gen: AsyncGenerator): Promise => { + const events: InvokeEvent[] = []; + for await (const e of gen) events.push(e); + return events; +}; + +describe('cli:update action', () => { + it('validates target as semver or latest', async () => { + const reg = createRegistry(); + reg.register( + createCliUpdateAction({ shell: fakeShell(), readCurrentVersion: async () => '0.17.0' }) + ); + const bad = await collect( + reg.invoke({ + action: 'cli:update', + input: { target: 'master' }, + callerId: 'c', + capabilities: ['cli.write'], + }) + ); + expect(bad.at(-1)).toMatchObject({ type: 'error', code: 'INVALID_INPUT' }); + }); + + it('runs happy path with version bump and returns result envelope', async () => { + const observed: string[] = []; + const reg = createRegistry(); + reg.register( + createCliUpdateAction({ + shell: fakeShell(true, (cmd) => observed.push(cmd)), + readCurrentVersion: async () => '0.17.0', + }) + ); + const events = await collect( + reg.invoke({ + action: 'cli:update', + input: { target: '0.18.0' }, + callerId: 'c', + capabilities: ['cli.write'], + }) + ); + expect(observed).toEqual(['npm install -g @agentage/cli@0.18.0']); + expect(events.at(-1)).toMatchObject({ + type: 'result', + data: { installed: '0.18.0', from: '0.17.0' }, + }); + }); + + it('surfaces EXECUTION_FAILED when shell fails', async () => { + const reg = createRegistry(); + reg.register( + createCliUpdateAction({ shell: fakeShell(false), readCurrentVersion: async () => '0.17.0' }) + ); + const events = await collect( + reg.invoke({ + action: 'cli:update', + input: { target: 'latest' }, + callerId: 'c', + capabilities: ['cli.write'], + }) + ); + expect(events.at(-1)).toMatchObject({ type: 'error', code: 'EXECUTION_FAILED' }); + }); +}); + +describe('project:addFromOrigin action', () => { + it('derives name from remote and clones into parentDir', async () => { + const observed: string[] = []; + const reg = createRegistry(); + reg.register( + createProjectAddFromOriginAction({ shell: fakeShell(true, (cmd) => observed.push(cmd)) }) + ); + const events = await collect( + reg.invoke({ + action: 'project:addFromOrigin', + input: { remote: 'git@github.com:agentage/cli.git', parentDir: '/tmp/projects' }, + callerId: 'c', + capabilities: ['project.write'], + }) + ); + expect(observed).toEqual(['git clone git@github.com:agentage/cli.git /tmp/projects/cli']); + expect(events.at(-1)).toMatchObject({ + type: 'result', + data: { name: 'cli', path: '/tmp/projects/cli', remote: 'git@github.com:agentage/cli.git' }, + }); + }); + + it('rejects non-absolute parentDir', async () => { + const reg = createRegistry(); + reg.register(createProjectAddFromOriginAction({ shell: fakeShell() })); + const events = await collect( + reg.invoke({ + action: 'project:addFromOrigin', + input: { remote: 'git@github.com:a/b.git', parentDir: 'projects' }, + callerId: 'c', + capabilities: ['project.write'], + }) + ); + expect(events.at(-1)).toMatchObject({ type: 'error', code: 'INVALID_INPUT' }); + }); + + it('passes branch flag when provided', async () => { + const spy = vi.fn(); + const reg = createRegistry(); + reg.register(createProjectAddFromOriginAction({ shell: fakeShell(true, spy) })); + await collect( + reg.invoke({ + action: 'project:addFromOrigin', + input: { + remote: 'https://github.com/agentage/cli.git', + parentDir: '/tmp', + branch: 'develop', + }, + callerId: 'c', + capabilities: ['project.write'], + }) + ); + expect(spy).toHaveBeenCalledWith( + 'git clone -b develop https://github.com/agentage/cli.git /tmp/cli' + ); + }); +}); + +describe('agent:install action', () => { + it('runs npm install with the given spec in workspaceDir', async () => { + const spy = vi.fn(); + const reg = createRegistry(); + reg.register(createAgentInstallAction({ shell: fakeShell(true, spy) })); + const events = await collect( + reg.invoke({ + action: 'agent:install', + input: { spec: '@agentage/agent-pr@1.0.0', workspaceDir: '/home/me/agents' }, + callerId: 'c', + capabilities: ['agent.write'], + }) + ); + expect(spy).toHaveBeenCalledWith('npm install @agentage/agent-pr@1.0.0'); + expect(events.at(-1)).toMatchObject({ + type: 'result', + data: { spec: '@agentage/agent-pr@1.0.0' }, + }); + }); +}); + +describe('registry list surface', () => { + it('exposes all three manifests for host-UI discovery', () => { + const reg = createRegistry(); + reg.register( + createCliUpdateAction({ shell: fakeShell(), readCurrentVersion: async () => '0.0.0' }) + ); + reg.register(createProjectAddFromOriginAction({ shell: fakeShell() })); + reg.register(createAgentInstallAction({ shell: fakeShell() })); + + const names = reg + .list() + .map((m) => m.name) + .sort(); + expect(names).toEqual(['agent:install', 'cli:update', 'project:addFromOrigin']); + for (const m of reg.list()) { + expect(m.capability).toMatch(/\.(read|write)$/); + expect(m.scope).toBe('machine'); + } + }); +}); diff --git a/packages/core/src/control/actions/agent-install.ts b/packages/core/src/control/actions/agent-install.ts new file mode 100644 index 0000000..c7ba6e9 --- /dev/null +++ b/packages/core/src/control/actions/agent-install.ts @@ -0,0 +1,60 @@ +import { action } from '../action.js'; +import { ActionError } from '../errors.js'; +import type { ActionDefinition } from '../types.js'; +import type { ActionProgress, ShellExec } from './types.js'; + +export interface AgentInstallInput { + /** npm package spec, e.g. "@agentage/agent-foo@1.2.3" or a directory path */ + spec: string; + /** Workspace directory (agents repo root) where the agent should be added */ + workspaceDir: string; +} + +export interface AgentInstallOutput { + spec: string; + workspaceDir: string; + command: string; +} + +const validateAgentInstallInput = (raw: unknown): AgentInstallInput => { + if (!raw || typeof raw !== 'object') throw new Error('input must be an object'); + const { spec, workspaceDir } = raw as Record; + if (typeof spec !== 'string' || spec.length === 0) + throw new Error('spec must be a non-empty string'); + if (typeof workspaceDir !== 'string' || !workspaceDir.startsWith('/')) { + throw new Error('workspaceDir must be an absolute path'); + } + return { spec, workspaceDir }; +}; + +export const createAgentInstallAction = (deps: { + shell: ShellExec; +}): ActionDefinition => + action({ + manifest: { + name: 'agent:install', + version: '1.0', + title: 'Install agent', + description: 'Install an agent package into the agents workspace', + scope: 'machine', + capability: 'agent.write', + idempotent: false, + }, + validateInput: validateAgentInstallInput, + async *execute(ctx, input): AsyncGenerator { + const command = `npm install ${input.spec}`; + yield { step: 'install', detail: command }; + + let failed = false; + for await (const event of deps.shell(command, { + signal: ctx.signal, + cwd: input.workspaceDir, + })) { + if (event.data.type === 'result' && !event.data.success) failed = true; + } + if (failed) + throw new ActionError('EXECUTION_FAILED', `npm install failed: ${input.spec}`, true); + + return { spec: input.spec, workspaceDir: input.workspaceDir, command }; + }, + }); diff --git a/packages/core/src/control/actions/cli-update.ts b/packages/core/src/control/actions/cli-update.ts new file mode 100644 index 0000000..c4824db --- /dev/null +++ b/packages/core/src/control/actions/cli-update.ts @@ -0,0 +1,67 @@ +import { action } from '../action.js'; +import { ActionError } from '../errors.js'; +import type { ActionDefinition } from '../types.js'; +import type { ActionProgress, ShellExec } from './types.js'; + +export interface CliUpdateInput { + /** Exact semver to install, or "latest" */ + target: string; + /** Package manager used to publish the CLI — currently only npm */ + via?: 'npm'; +} + +export interface CliUpdateOutput { + installed: string; + from: string; + command: string; +} + +const SEMVER_OR_LATEST = /^(?:latest|\d+\.\d+\.\d+(?:-[\w.]+)?)$/; + +const validateCliUpdateInput = (raw: unknown): CliUpdateInput => { + if (!raw || typeof raw !== 'object') throw new Error('input must be an object'); + const { target, via } = raw as { target?: unknown; via?: unknown }; + if (typeof target !== 'string' || !SEMVER_OR_LATEST.test(target)) { + throw new Error('target must be "latest" or a semver string like "1.2.3"'); + } + if (via !== undefined && via !== 'npm') throw new Error('via must be "npm" when set'); + return { target, via: 'npm' }; +}; + +export const createCliUpdateAction = (deps: { + shell: ShellExec; + readCurrentVersion: () => Promise; +}): ActionDefinition => + action({ + manifest: { + name: 'cli:update', + version: '1.0', + title: 'Update CLI', + description: 'Install a specific version of @agentage/cli globally via npm', + scope: 'machine', + capability: 'cli.write', + idempotent: false, + }, + validateInput: validateCliUpdateInput, + async *execute(ctx, input): AsyncGenerator { + const from = await deps.readCurrentVersion(); + yield { step: 'resolve', detail: `current=${from} target=${input.target}` }; + + const pkg = + input.target === 'latest' ? '@agentage/cli@latest' : `@agentage/cli@${input.target}`; + const command = `npm install -g ${pkg}`; + yield { step: 'install', detail: command }; + + let lastError: string | undefined; + for await (const event of deps.shell(command, { signal: ctx.signal })) { + if (event.data.type === 'error') { + lastError = `${event.data.code}: ${event.data.message}`; + } + if (event.data.type === 'result' && !event.data.success) { + throw new ActionError('EXECUTION_FAILED', lastError ?? 'npm install failed', true); + } + } + + return { installed: input.target, from, command }; + }, + }); diff --git a/packages/core/src/control/actions/index.ts b/packages/core/src/control/actions/index.ts new file mode 100644 index 0000000..d9a99b2 --- /dev/null +++ b/packages/core/src/control/actions/index.ts @@ -0,0 +1,10 @@ +export { createCliUpdateAction } from './cli-update.js'; +export type { CliUpdateInput, CliUpdateOutput } from './cli-update.js'; + +export { createProjectAddFromOriginAction } from './project-add-from-origin.js'; +export type { ProjectAddInput, ProjectAddOutput } from './project-add-from-origin.js'; + +export { createAgentInstallAction } from './agent-install.js'; +export type { AgentInstallInput, AgentInstallOutput } from './agent-install.js'; + +export type { ActionProgress, ShellExec } from './types.js'; diff --git a/packages/core/src/control/actions/project-add-from-origin.ts b/packages/core/src/control/actions/project-add-from-origin.ts new file mode 100644 index 0000000..80cf885 --- /dev/null +++ b/packages/core/src/control/actions/project-add-from-origin.ts @@ -0,0 +1,76 @@ +import { action } from '../action.js'; +import { ActionError } from '../errors.js'; +import type { ActionDefinition } from '../types.js'; +import type { ActionProgress, ShellExec } from './types.js'; + +export interface ProjectAddInput { + /** Git remote URL (ssh or https) */ + remote: string; + /** Destination parent dir on disk */ + parentDir: string; + /** Optional branch to clone */ + branch?: string; + /** Override target folder name (defaults to repo name from URL) */ + name?: string; +} + +export interface ProjectAddOutput { + name: string; + path: string; + remote: string; + branch: string; +} + +const REMOTE = /^(?:git@|https?:\/\/)[\w.@:/\-~]+\.git$/; + +const deriveName = (remote: string): string => { + const match = /([^/]+?)(?:\.git)?$/.exec(remote); + if (!match?.[1]) throw new Error(`cannot derive name from remote: ${remote}`); + return match[1]; +}; + +const validateProjectAddInput = (raw: unknown): ProjectAddInput => { + if (!raw || typeof raw !== 'object') throw new Error('input must be an object'); + const { remote, parentDir, branch, name } = raw as Record; + if (typeof remote !== 'string' || !REMOTE.test(remote)) { + throw new Error('remote must be a valid git URL (git@ or https://, ending in .git)'); + } + if (typeof parentDir !== 'string' || !parentDir.startsWith('/')) { + throw new Error('parentDir must be an absolute path'); + } + if (branch !== undefined && typeof branch !== 'string') throw new Error('branch must be string'); + if (name !== undefined && typeof name !== 'string') throw new Error('name must be string'); + return { remote, parentDir, branch, name }; +}; + +export const createProjectAddFromOriginAction = (deps: { + shell: ShellExec; +}): ActionDefinition => + action({ + manifest: { + name: 'project:addFromOrigin', + version: '1.0', + title: 'Add project from git remote', + description: 'Clone a git remote into parentDir and register as a project', + scope: 'machine', + capability: 'project.write', + idempotent: true, + }, + validateInput: validateProjectAddInput, + async *execute(ctx, input): AsyncGenerator { + const name = input.name ?? deriveName(input.remote); + const path = `${input.parentDir.replace(/\/$/, '')}/${name}`; + const branchFlag = input.branch ? ` -b ${input.branch}` : ''; + const command = `git clone${branchFlag} ${input.remote} ${path}`; + yield { step: 'clone', detail: command }; + + let failed = false; + for await (const event of deps.shell(command, { signal: ctx.signal })) { + if (event.data.type === 'result' && !event.data.success) failed = true; + } + if (failed) throw new ActionError('EXECUTION_FAILED', `git clone failed: ${command}`, true); + + yield { step: 'register', detail: path }; + return { name, path, remote: input.remote, branch: input.branch ?? 'default' }; + }, + }); diff --git a/packages/core/src/control/actions/types.ts b/packages/core/src/control/actions/types.ts new file mode 100644 index 0000000..213129d --- /dev/null +++ b/packages/core/src/control/actions/types.ts @@ -0,0 +1,12 @@ +import type { RunEvent } from '../../types.js'; + +/** Shell-like dependency: takes a command string, yields run events. Stubbable in tests. */ +export type ShellExec = ( + command: string, + options?: { signal?: AbortSignal; timeoutMs?: number; cwd?: string } +) => AsyncIterable; + +export interface ActionProgress { + step: string; + detail?: string; +} diff --git a/packages/core/src/control/errors.ts b/packages/core/src/control/errors.ts new file mode 100644 index 0000000..ee02636 --- /dev/null +++ b/packages/core/src/control/errors.ts @@ -0,0 +1,15 @@ +import type { ActionErrorCode } from './types.js'; + +export class ActionError extends Error { + readonly code: ActionErrorCode; + readonly retryable: boolean; + + constructor(code: ActionErrorCode, message: string, retryable = false) { + super(message); + this.name = 'ActionError'; + this.code = code; + this.retryable = retryable; + } +} + +export const isActionError = (err: unknown): err is ActionError => err instanceof ActionError; diff --git a/packages/core/src/control/index.ts b/packages/core/src/control/index.ts new file mode 100644 index 0000000..968dfe0 --- /dev/null +++ b/packages/core/src/control/index.ts @@ -0,0 +1,31 @@ +export { action } from './action.js'; +export { createRegistry } from './registry.js'; +export { ActionError, isActionError } from './errors.js'; +export type { + ActionContext, + ActionDefinition, + ActionErrorCode, + ActionLogger, + ActionManifest, + ActionRegistry, + ActionScope, + InvokeEvent, + InvokeRequest, +} from './types.js'; + +// Reference actions — factories that bind to an injected shell. Host wires these in. +export { + createCliUpdateAction, + createProjectAddFromOriginAction, + createAgentInstallAction, +} from './actions/index.js'; +export type { + ActionProgress, + AgentInstallInput, + AgentInstallOutput, + CliUpdateInput, + CliUpdateOutput, + ProjectAddInput, + ProjectAddOutput, + ShellExec, +} from './actions/index.js'; diff --git a/packages/core/src/control/registry.test.ts b/packages/core/src/control/registry.test.ts new file mode 100644 index 0000000..6f76abd --- /dev/null +++ b/packages/core/src/control/registry.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { action } from './action.js'; +import { createRegistry } from './registry.js'; +import type { ActionDefinition, InvokeEvent, InvokeRequest } from './types.js'; + +const echo = action<{ msg: string }, { echoed: string }, { step: number }>({ + manifest: { + name: 'test:echo', + version: '1.0', + title: 'Echo', + description: 'Echo a message', + scope: 'machine', + capability: 'test.read', + idempotent: true, + }, + async *execute(_ctx, input) { + yield { step: 1 }; + yield { step: 2 }; + return { echoed: input.msg }; + }, +}); + +const boom = action, never, never>({ + manifest: { + name: 'test:boom', + version: '1.0', + title: 'Boom', + description: 'Throws', + scope: 'machine', + capability: 'test.write', + idempotent: false, + }, + async *execute() { + yield undefined as never; + throw new Error('kaboom'); + }, +}); + +const collect = async (gen: AsyncGenerator): Promise => { + const events: InvokeEvent[] = []; + for await (const e of gen) events.push(e); + return events; +}; + +const req = (overrides: Partial = {}): InvokeRequest => ({ + action: 'test:echo', + input: { msg: 'hi' }, + callerId: 'caller-1', + capabilities: ['test.read'], + ...overrides, +}); + +describe('createRegistry', () => { + it('registers + lists actions', () => { + const reg = createRegistry(); + reg.register(echo); + expect(reg.list()).toHaveLength(1); + expect(reg.list()[0]!.name).toBe('test:echo'); + }); + + it('rejects duplicate name+version', () => { + const reg = createRegistry(); + reg.register(echo); + expect(() => reg.register(echo)).toThrow(/already registered/); + }); + + it('resolves latest version when multiple registered', () => { + const reg = createRegistry(); + reg.register(echo); + reg.register({ + ...echo, + manifest: { ...echo.manifest, version: '2.0' }, + } as ActionDefinition); + expect(reg.get('test:echo')!.manifest.version).toBe('2.0'); + expect(reg.get('test:echo', '1.0')!.manifest.version).toBe('1.0'); + }); + + it('streams accepted → progress* → result for a successful invocation', async () => { + const reg = createRegistry({ idGenerator: () => 'inv-1' }); + reg.register(echo); + const events = await collect(reg.invoke(req())); + expect(events).toEqual([ + { type: 'accepted', invocationId: 'inv-1' }, + { type: 'progress', data: { step: 1 } }, + { type: 'progress', data: { step: 2 } }, + { type: 'result', data: { echoed: 'hi' } }, + ]); + }); + + it('emits UNKNOWN_ACTION when action name is not registered', async () => { + const reg = createRegistry(); + const events = await collect(reg.invoke(req({ action: 'nope:nope' }))); + expect(events).toEqual([ + { type: 'error', code: 'UNKNOWN_ACTION', message: expect.any(String), retryable: false }, + ]); + }); + + it('emits UNKNOWN_VERSION when a specific version is missing', async () => { + const reg = createRegistry(); + reg.register(echo); + const events = await collect(reg.invoke(req({ version: '9.9' }))); + expect(events[0]).toMatchObject({ type: 'error', code: 'UNKNOWN_VERSION' }); + }); + + it('emits UNAUTHORIZED when caller lacks capability', async () => { + const reg = createRegistry({ idGenerator: () => 'inv-2' }); + reg.register(echo); + const events = await collect(reg.invoke(req({ capabilities: [] }))); + expect(events).toEqual([ + { type: 'accepted', invocationId: 'inv-2' }, + { type: 'error', code: 'UNAUTHORIZED', message: expect.any(String), retryable: false }, + ]); + }); + + it('accepts wildcard "*" capability', async () => { + const reg = createRegistry(); + reg.register(echo); + const events = await collect(reg.invoke(req({ capabilities: ['*'] }))); + expect(events.at(-1)).toMatchObject({ type: 'result' }); + }); + + it('emits INVALID_INPUT when validateInput throws', async () => { + const reg = createRegistry(); + reg.register( + action<{ n: number }, { n: number }, never>({ + manifest: { ...echo.manifest, name: 'test:num', capability: 'test.read' }, + validateInput: (raw) => { + if (typeof (raw as { n: unknown }).n !== 'number') throw new Error('n must be number'); + return raw as { n: number }; + }, + async *execute(_ctx, input) { + yield undefined as never; + return input; + }, + }) + ); + const events = await collect(reg.invoke(req({ action: 'test:num', input: { n: 'no' } }))); + expect(events.at(-1)).toMatchObject({ type: 'error', code: 'INVALID_INPUT' }); + }); + + it('emits EXECUTION_FAILED for thrown errors', async () => { + const reg = createRegistry(); + reg.register(boom); + const events = await collect( + reg.invoke(req({ action: 'test:boom', input: {}, capabilities: ['test.write'] })) + ); + expect(events.at(-1)).toMatchObject({ + type: 'error', + code: 'EXECUTION_FAILED', + message: 'kaboom', + }); + }); + + it('dedups on idempotencyKey — second call yields DUPLICATE_INVOCATION', async () => { + const reg = createRegistry(); + reg.register(echo); + const first = await collect(reg.invoke(req({ idempotencyKey: 'k1' }))); + expect(first.at(-1)).toMatchObject({ type: 'result' }); + const second = await collect(reg.invoke(req({ idempotencyKey: 'k1' }))); + expect(second.at(-1)).toMatchObject({ type: 'error', code: 'DUPLICATE_INVOCATION' }); + }); + + it('stops and emits CANCELED when signal aborts mid-stream', async () => { + const reg = createRegistry(); + reg.register( + action, { done: true }, { step: number }>({ + manifest: { ...echo.manifest, name: 'test:slow' }, + async *execute(ctx) { + for (let i = 0; i < 10; i++) { + if (ctx.signal.aborted) return { done: true }; + yield { step: i }; + await new Promise((r) => setTimeout(r, 0)); + } + return { done: true }; + }, + }) + ); + + const ac = new AbortController(); + const gen = reg.invoke(req({ action: 'test:slow', input: {} }), ac.signal); + const events: InvokeEvent[] = []; + let i = 0; + for await (const e of gen) { + events.push(e); + if (i++ === 2) ac.abort(); + } + expect(events.at(-1)).toMatchObject({ type: 'error', code: 'CANCELED' }); + }); + + it('emits DEPRECATED when action is marked deprecated', async () => { + const reg = createRegistry(); + reg.register({ + ...echo, + manifest: { ...echo.manifest, deprecatedSince: '2026-04-01' }, + } as ActionDefinition); + const events = await collect(reg.invoke(req())); + expect(events.at(-1)).toMatchObject({ type: 'error', code: 'DEPRECATED' }); + }); +}); diff --git a/packages/core/src/control/registry.ts b/packages/core/src/control/registry.ts new file mode 100644 index 0000000..c067019 --- /dev/null +++ b/packages/core/src/control/registry.ts @@ -0,0 +1,175 @@ +import { ActionError } from './errors.js'; +import type { + ActionContext, + ActionDefinition, + ActionManifest, + ActionRegistry, + InvokeEvent, + InvokeRequest, +} from './types.js'; + +const LATEST = 'latest'; + +const makeKey = (name: string, version: string): string => `${name}@${version}`; + +const pickLatest = (versions: Map): ActionDefinition | undefined => { + let chosen: { key: string; def: ActionDefinition } | undefined; + for (const [key, def] of versions) { + if (!chosen || key.localeCompare(chosen.key, undefined, { numeric: true }) > 0) { + chosen = { key, def }; + } + } + return chosen?.def; +}; + +const validateInput = (def: ActionDefinition, input: unknown): I => { + if (!def.validateInput) return input as I; + try { + return def.validateInput(input); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new ActionError('INVALID_INPUT', message); + } +}; + +const assertAuthorized = (need: string, have: ReadonlySet): void => { + if (!have.has(need) && !have.has('*')) { + throw new ActionError('UNAUTHORIZED', `Missing capability: ${need}`); + } +}; + +const assertNotDeprecated = (manifest: ActionManifest): void => { + if (manifest.deprecatedSince) { + throw new ActionError( + 'DEPRECATED', + `${manifest.name}@${manifest.version} deprecated since ${manifest.deprecatedSince}` + ); + } +}; + +interface InvocationRecord { + invocationId: string; + key: string; + result: Promise; +} + +export const createRegistry = (options?: { idGenerator?: () => string }): ActionRegistry => { + const actions = new Map>(); + const idempotency = new Map(); + const nextId = options?.idGenerator ?? (() => crypto.randomUUID()); + + const resolve = (name: string, version?: string): ActionDefinition | undefined => { + const versions = actions.get(name); + if (!versions) return undefined; + if (!version || version === LATEST) return pickLatest(versions); + return versions.get(version); + }; + + return { + register(def: ActionDefinition): void { + const { name, version } = def.manifest; + let versions = actions.get(name); + if (!versions) { + versions = new Map(); + actions.set(name, versions); + } + if (versions.has(version)) { + throw new Error(`Action already registered: ${makeKey(name, version)}`); + } + versions.set(version, def as ActionDefinition); + }, + + list(): ActionManifest[] { + const out: ActionManifest[] = []; + for (const versions of actions.values()) { + for (const def of versions.values()) out.push(def.manifest); + } + return out; + }, + + get(name, version) { + return resolve(name, version); + }, + + async *invoke(req: InvokeRequest, signal?: AbortSignal): AsyncGenerator { + const def = resolve(req.action, req.version); + if (!def) { + yield { + type: 'error', + code: req.version ? 'UNKNOWN_VERSION' : 'UNKNOWN_ACTION', + message: `Action not found: ${req.action}${req.version ? `@${req.version}` : ''}`, + retryable: false, + }; + return; + } + + const invocationId = nextId(); + const idemKey = req.idempotencyKey + ? `${def.manifest.name}@${def.manifest.version}#${req.idempotencyKey}` + : undefined; + + if (idemKey && idempotency.has(idemKey)) { + const prev = idempotency.get(idemKey)!; + yield { type: 'accepted', invocationId: prev.invocationId }; + yield { + type: 'error', + code: 'DUPLICATE_INVOCATION', + message: 'Replay of prior invocation', + retryable: false, + }; + return; + } + + yield { type: 'accepted', invocationId }; + + try { + assertNotDeprecated(def.manifest); + assertAuthorized(def.manifest.capability, new Set(req.capabilities)); + const input = validateInput(def, req.input); + + const ctx: ActionContext = { + invocationId, + callerId: req.callerId, + capabilities: new Set(req.capabilities), + idempotencyKey: req.idempotencyKey, + signal: signal ?? new AbortController().signal, + }; + + const gen = def.execute(ctx, input); + let finalResult: unknown; + while (true) { + if (ctx.signal.aborted) { + yield { + type: 'error', + code: 'CANCELED', + message: 'Invocation canceled', + retryable: true, + }; + return; + } + const step = await gen.next(); + if (step.done) { + finalResult = step.value; + break; + } + yield { type: 'progress', data: step.value }; + } + if (idemKey) { + idempotency.set(idemKey, { + invocationId, + key: idemKey, + result: Promise.resolve(finalResult), + }); + } + yield { type: 'result', data: finalResult }; + } catch (err) { + if (err instanceof ActionError) { + yield { type: 'error', code: err.code, message: err.message, retryable: err.retryable }; + return; + } + const message = err instanceof Error ? err.message : String(err); + yield { type: 'error', code: 'EXECUTION_FAILED', message, retryable: true }; + } + }, + }; +}; diff --git a/packages/core/src/control/types.ts b/packages/core/src/control/types.ts new file mode 100644 index 0000000..769454a --- /dev/null +++ b/packages/core/src/control/types.ts @@ -0,0 +1,70 @@ +import type { JsonSchema } from '../types.js'; + +export type ActionScope = 'machine' | 'hub' | 'project'; + +export interface ActionManifest { + name: `${string}:${string}`; + version: string; + title: string; + description: string; + scope: ActionScope; + capability: string; + idempotent: boolean; + inputSchema?: JsonSchema; + outputSchema?: JsonSchema; + progressSchema?: JsonSchema; + deprecatedSince?: string; +} + +export interface ActionLogger { + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; +} + +export interface ActionContext { + invocationId: string; + callerId: string; + capabilities: ReadonlySet; + idempotencyKey?: string; + signal: AbortSignal; + logger?: ActionLogger; +} + +export interface ActionDefinition { + manifest: ActionManifest; + execute(ctx: ActionContext, input: I): AsyncGenerator; + validateInput?(input: unknown): I; +} + +export interface InvokeRequest { + action: string; + version?: string; + input: unknown; + idempotencyKey?: string; + callerId: string; + capabilities: string[]; +} + +export type InvokeEvent = + | { type: 'accepted'; invocationId: string } + | { type: 'progress'; data: unknown } + | { type: 'result'; data: unknown } + | { type: 'error'; code: ActionErrorCode; message: string; retryable: boolean }; + +export type ActionErrorCode = + | 'UNKNOWN_ACTION' + | 'UNKNOWN_VERSION' + | 'INVALID_INPUT' + | 'UNAUTHORIZED' + | 'DEPRECATED' + | 'DUPLICATE_INVOCATION' + | 'EXECUTION_FAILED' + | 'CANCELED'; + +export interface ActionRegistry { + register(def: ActionDefinition): void; + list(): ActionManifest[]; + get(name: string, version?: string): ActionDefinition | undefined; + invoke(req: InvokeRequest, signal?: AbortSignal): AsyncGenerator; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9db1d56..91bcbde 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,5 +46,19 @@ export { mcp } from './mcp.js'; export { shell, claude, copilot } from './adapters/index.js'; export type { ClaudeOptions, CopilotOptions } from './adapters/index.js'; +// Control plane — action registry + envelope for host UI → local env dispatch +export { action, createRegistry, ActionError, isActionError } from './control/index.js'; +export type { + ActionContext, + ActionDefinition, + ActionErrorCode, + ActionLogger, + ActionManifest, + ActionRegistry, + ActionScope, + InvokeEvent, + InvokeRequest, +} from './control/index.js'; + // Note: the `defineAgent` Zod helper lives at `@agentage/core/zod` (subpath export). // Import from there when you have `zod >= 4` installed as a peer.