Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"prepublishOnly": "npm run verify"
},
"dependencies": {
"@agentage/core": "0.9.0",
"@agentage/core": "^0.10.0",
"@agentage/platform": "^0.5.0",
"@supabase/supabase-js": "2.103.3",
"ajv": "8.18.0",
Expand Down
42 changes: 42 additions & 0 deletions src/daemon/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';

vi.mock('../utils/version.js', () => ({ VERSION: '0.99.9' }));
import type * as CoreModule from '@agentage/core';

vi.mock('@agentage/core', async (importActual) => {
const actual = await importActual<typeof CoreModule>();
return {
...actual,
shell: vi.fn(),
};
});

import { getActionRegistry, resetActionRegistry } from './actions.js';

describe('action registry bootstrap', () => {
beforeEach(() => {
resetActionRegistry();
});

it('registers the three built-in actions with expected manifests', () => {
const names = getActionRegistry()
.list()
.map((m) => m.name)
.sort();
expect(names).toEqual(['agent:install', 'cli:update', 'project:addFromOrigin']);
});

it('each action declares a distinct capability and machine scope', () => {
const manifests = getActionRegistry().list();
const caps = new Set(manifests.map((m) => m.capability));
expect(caps.size).toBe(manifests.length);
for (const m of manifests) {
expect(m.scope).toBe('machine');
expect(m.capability).toMatch(/\.(read|write)$/);
}
});

it('singleton returns the same registry across calls', () => {
expect(getActionRegistry()).toBe(getActionRegistry());
});
});
36 changes: 36 additions & 0 deletions src/daemon/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createRegistry, shell, type ActionRegistry } from '@agentage/core';
import { VERSION } from '../utils/version.js';
import { createAgentInstallAction } from './actions/agent-install.js';
import { createCliUpdateAction } from './actions/cli-update.js';
import { createProjectAddFromOriginAction } from './actions/project-add-from-origin.js';
import type { ShellExec } from './actions/types.js';

const shellExec: ShellExec = (command, options) => shell(command, options);

const readCliVersion = async (): Promise<string> => VERSION;

let registrySingleton: ActionRegistry | null = null;

/**
* Build the daemon's action registry with built-in control-plane actions.
* Reference actions all declare scope='machine' + require explicit capability;
* the transport layer decides which capabilities to grant per caller.
*/
export const getActionRegistry = (): ActionRegistry => {
if (registrySingleton) return registrySingleton;

const registry = createRegistry();
registry.register(
createCliUpdateAction({ shell: shellExec, readCurrentVersion: readCliVersion })
);
registry.register(createProjectAddFromOriginAction({ shell: shellExec }));
registry.register(createAgentInstallAction({ shell: shellExec }));

registrySingleton = registry;
return registry;
};

/** Test-only: reset between tests. */
export const resetActionRegistry = (): void => {
registrySingleton = null;
};
119 changes: 119 additions & 0 deletions src/daemon/actions/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { createRegistry, output, result, type InvokeEvent } from '@agentage/core';
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<InvokeEvent>): Promise<InvokeEvent[]> => {
const events: InvokeEvent[] = [];
for await (const e of gen) events.push(e);
return events;
};

describe('cli-update', () => {
it('installs target version and returns bump envelope', async () => {
const observed: string[] = [];
const reg = createRegistry();
reg.register(
createCliUpdateAction({
shell: fakeShell(true, (c) => observed.push(c)),
readCurrentVersion: async () => '0.17.0',
})
);
const events = await collect(
reg.invoke({
action: 'cli:update',
input: { target: '0.18.0' },
callerId: 'test',
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('rejects non-semver target', async () => {
const reg = createRegistry();
reg.register(
createCliUpdateAction({ shell: fakeShell(), readCurrentVersion: async () => '0.17.0' })
);
const events = await collect(
reg.invoke({
action: 'cli:update',
input: { target: 'master' },
callerId: 'test',
capabilities: ['cli.write'],
})
);
expect(events.at(-1)).toMatchObject({ type: 'error', code: 'INVALID_INPUT' });
});
});

describe('project-add-from-origin', () => {
it('derives project name from remote and passes branch flag', async () => {
const spy = vi.fn();
const reg = createRegistry();
reg.register(createProjectAddFromOriginAction({ shell: fakeShell(true, spy) }));
await collect(
reg.invoke({
action: 'project:addFromOrigin',
input: {
remote: 'git@github.com:agentage/cli.git',
parentDir: '/tmp/projects',
branch: 'develop',
},
callerId: 'test',
capabilities: ['project.write'],
})
);
expect(spy).toHaveBeenCalledWith(
'git clone -b develop git@github.com:agentage/cli.git /tmp/projects/cli'
);
});
});

describe('agent-install', () => {
it('runs npm install 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: 'test',
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' },
});
});

it('emits EXECUTION_FAILED when install fails', async () => {
const reg = createRegistry();
reg.register(createAgentInstallAction({ shell: fakeShell(false) }));
const events = await collect(
reg.invoke({
action: 'agent:install',
input: { spec: 'bad-pkg', workspaceDir: '/tmp' },
callerId: 'test',
capabilities: ['agent.write'],
})
);
expect(events.at(-1)).toMatchObject({ type: 'error', code: 'EXECUTION_FAILED' });
});
});
56 changes: 56 additions & 0 deletions src/daemon/actions/agent-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { action, ActionError, type ActionDefinition } from '@agentage/core';
import type { ActionProgress, ShellExec } from './types.js';

export interface AgentInstallInput {
spec: string;
workspaceDir: string;
}

export interface AgentInstallOutput {
spec: string;
workspaceDir: string;
command: string;
}

const validate = (raw: unknown): AgentInstallInput => {
if (!raw || typeof raw !== 'object') throw new Error('input must be an object');
const { spec, workspaceDir } = raw as Record<string, unknown>;
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<AgentInstallInput, AgentInstallOutput, ActionProgress> =>
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: validate,
async *execute(ctx, input): AsyncGenerator<ActionProgress, AgentInstallOutput, void> {
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 };
},
});
63 changes: 63 additions & 0 deletions src/daemon/actions/cli-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { action, ActionError, type ActionDefinition } from '@agentage/core';
import type { ActionProgress, ShellExec } from './types.js';

export interface CliUpdateInput {
target: string;
via?: 'npm';
}

export interface CliUpdateOutput {
installed: string;
from: string;
command: string;
}

const SEMVER_OR_LATEST = /^(?:latest|\d+\.\d+\.\d+(?:-[\w.]+)?)$/;

const validate = (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<string>;
}): ActionDefinition<CliUpdateInput, CliUpdateOutput, ActionProgress> =>
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: validate,
async *execute(ctx, input): AsyncGenerator<ActionProgress, CliUpdateOutput, void> {
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 };
},
});
10 changes: 10 additions & 0 deletions src/daemon/actions/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading