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
23 changes: 21 additions & 2 deletions apps/agent-forge/server/__tests__/channelTriggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,14 @@ interface Harness {
host: {
runsOf: ReturnType<typeof vi.fn<[agentId: string], HostRun[]>>;
cancel: ReturnType<typeof vi.fn<[runKey: string, reason: string], 'queued' | 'running' | undefined>>;
isEnabled: ReturnType<typeof vi.fn<[agentId: string], boolean>>;
};
baseDir: string;
}

async function harness(
spec: AgentSpecV2,
options: { secrets?: Record<string, string>; agent?: SimpleAgent; runs?: HostRun[] } = {}
options: { secrets?: Record<string, string>; agent?: SimpleAgent; runs?: HostRun[]; enabled?: boolean } = {}
): Promise<Harness> {
const baseDir = tmpDir();
const repo = new AgentRepo(baseDir);
Expand All @@ -103,6 +104,7 @@ async function harness(
const host = {
runsOf: vi.fn<[agentId: string], HostRun[]>(() => options.runs ?? []),
cancel: vi.fn<[runKey: string, reason: string], 'queued' | 'running' | undefined>(() => 'queued'),
isEnabled: vi.fn<[agentId: string], boolean>(() => options.enabled ?? true),
};
const channels = new ChannelTriggers({
host: host as unknown as TriggerHost,
Expand Down Expand Up @@ -218,6 +220,23 @@ describe('ChannelTriggers.mount / status', () => {
]);
});

it('answers 200 skipped disabled for a turned-off agent and never calls the SDK handler (R09)', async () => {
const agent = createAgent({ provider: mockModel(['Pong']) });
const { app } = await harness(specWith([HTTP_TRIGGER]), {
agent: agent as unknown as SimpleAgent,
enabled: false,
});

const res = await request(app)
.post(`/hooks/${AGENT}/channel/http`)
.set('content-type', 'application/json')
.send({ sessionKey: 'u1', input: 'ping' });

expect(res.status).toBe(200);
// The SDK handler's `{ text }` reply never happens - platforms see the skip.
expect(res.body).toEqual({ ok: true, skipped: 'disabled' });
});

it('answers 404-shaped fallthrough for a channel path with no mounted trigger', async () => {
const { app } = await harness(specWith([HTTP_TRIGGER]));
const res = await request(app).post(`/hooks/${AGENT}/channel/telegram`).send('{}');
Expand Down Expand Up @@ -414,7 +433,7 @@ describe('channel route inside createApp', () => {
const repo = new AgentRepo(baseDir);
await repo.writeSpec(AGENT, specWith([HTTP_TRIGGER]));
const channels = new ChannelTriggers({
host: { runsOf: () => [], cancel: () => undefined } as unknown as TriggerHost,
host: { runsOf: () => [], cancel: () => undefined, isEnabled: () => true } as unknown as TriggerHost,
repo,
buildAgent: async () => createAgent({ provider: mockModel(['ViaApp']) }) as unknown as SimpleAgent,
resolve: resolver({}),
Expand Down
17 changes: 17 additions & 0 deletions apps/agent-forge/server/__tests__/cronTriggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,23 @@ describe('CronTriggers', () => {
expect(log).toHaveBeenCalledWith('a', 'Cron trigger t1 fired');
});

it('skips a disabled agent, logs the contract line and still advances the cursor (R09)', async () => {
const { cron, dispatch, log } = make({ a: [CRON] }, { ok: false, reason: 'disabled' });
await cron.tick();
now = new Date('2026-01-01T10:05:00.000Z');
await cron.tick();

expect(dispatch).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith('a', 'Cron trigger t1 skipped: the agent is turned off', 'warn');
// The claim moved the cursor before the dispatch, so re-enabling later
// never bursts the missed window.
expect(readState(baseDir, 'a').t1.lastRunAt).toBe('2026-01-01T10:05:00.000Z');

now = new Date('2026-01-01T10:10:00.000Z');
await cron.tick();
expect(dispatch).toHaveBeenCalledTimes(2);
});

it('deletes state entries for triggers removed from the spec', async () => {
const specs: Record<string, TriggerSpec[]> = { a: [CRON, CRON2] };
const { cron, triggersOf } = make(specs);
Expand Down
29 changes: 29 additions & 0 deletions apps/agent-forge/server/__tests__/hooksRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function appWith(
dispatch,
runsOf: () => [],
cancel: () => undefined,
isEnabled: () => true,
log: () => {},
...hostExtra,
} as unknown as TriggerHost;
Expand Down Expand Up @@ -309,4 +310,32 @@ describe('POST /hooks/:agentId/<path>', () => {
expect(res.body.cancelled).toEqual([]);
expect(cancel).not.toHaveBeenCalled();
});

it('answers 409 LOUSHO_AGENT_DISABLED for a turned-off agent after auth (R09)', async () => {
const { app, dispatch } = appWith([SIGNED], undefined, { isEnabled: () => false });
const body = JSON.stringify({ action: 'deploy' });
const res = await request(app)
.post(`/hooks/${AGENT}/hooks/t1`)
.set('content-type', 'application/json')
.set('x-signature-256', sign(body))
.send(body);

expect(res.status).toBe(409);
expect(res.body.code).toBe('LOUSHO_AGENT_DISABLED');
expect(res.body.error).toBe('Agent is turned off');
expect(dispatch).not.toHaveBeenCalled();
});

it('still rejects an unauthenticated call on a disabled agent before the enabled gate (R09)', async () => {
// Auth runs first: an unsigned remote call gets 403, never the 409 that
// would confirm the agent exists.
const { app } = appWith([OPEN], undefined, { isEnabled: () => false });
const res = await request(app)
.post(`/hooks/${AGENT}/hooks/open`)
.set('host', 'staging.example.com')
.send('x');

expect(res.status).toBe(403);
expect(res.body.error).toBe('This webhook has no secret, so it only accepts local calls.');
});
});
64 changes: 61 additions & 3 deletions apps/agent-forge/server/__tests__/triggerHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,26 @@ class FakeRuns extends EventEmitter {
}
}

function repoWith(specs: Record<string, AgentSpecV2 | undefined>): AgentRepo {
function repoWith(specs: Record<string, AgentSpecV2 | undefined>, metas: Record<string, { enabled: boolean } | undefined> = {}): AgentRepo {
return {
listIds: () => Object.keys(specs),
readSpec: async (id: string) => specs[id],
readMeta: async (id: string) => ({ enabled: metas[id]?.enabled ?? true }) as never,
} as unknown as AgentRepo;
}

const made: TriggerHost[] = [];

function makeHost(specs: Record<string, AgentSpecV2 | undefined>, queueCap?: number) {
function makeHost(
specs: Record<string, AgentSpecV2 | undefined>,
queueCap?: number,
metas: Record<string, { enabled: boolean } | undefined> = {}
) {
const runs = new FakeRuns();
const events = new StudioEventBus();
const logs: string[] = [];
const host = new TriggerHost({
repo: repoWith(specs),
repo: repoWith(specs, metas),
runManager: runs as unknown as RunManager,
events,
log: (_agentId, message) => logs.push(message),
Expand Down Expand Up @@ -243,4 +248,57 @@ describe('TriggerHost', () => {
expect(d2.ok).toBe(true);
expect(host.cancel('missing:key', 'x')).toBeUndefined();
});

it('rejects dispatches for a disabled agent and isEnabled reflects meta (R09)', async () => {
const metas = { a: { enabled: false } };
const { host, runs } = makeHost({ a: specWith([WEBHOOK]) }, undefined, metas);
await host.start();

expect(host.isEnabled('a')).toBe(false);
expect(host.isEnabled('missing')).toBe(true);
expect(host.dispatch({ agentId: 'a', triggerId: 't1', input: 'x', event: {} })).toEqual({
ok: false,
reason: 'disabled',
});
expect(host.runsOf('a')).toHaveLength(0);
expect(runs.calls).toHaveLength(0);
});

it('drops queued runs and logs the count when the agent turns off; the running run finishes (R09)', async () => {
const metas = { a: { enabled: true } };
const { host, runs, logs } = makeHost({ a: specWith([WEBHOOK, CRON]) }, undefined, metas);
await host.start();

host.dispatch({ agentId: 'a', triggerId: 't1', input: 'one', event: {} });
host.dispatch({ agentId: 'a', triggerId: 'c1', input: 'two', event: {} });
host.dispatch({ agentId: 'a', triggerId: 'c1', input: 'three', event: {} });
await flush();
expect(host.runsOf('a').map((r) => r.status)).toEqual(['running', 'queued', 'queued']);

metas.a.enabled = false;
await host.reload('a');

expect(host.isEnabled('a')).toBe(false);
// Only the in-flight run survives; both queued requests are gone.
expect(host.runsOf('a').map((r) => r.status)).toEqual(['running']);
expect(logs).toContain('Dropped 2 queued runs: the agent was turned off');
expect(runs.calls).toHaveLength(1);

// The running run still ends normally and reports its RunEnd.
const ends: RunEnd[] = [];
host.onRunEnd((end) => ends.push(end));
runs.emitStatus('a', 'done', { resultText: 'ok' });
expect(ends).toHaveLength(1);
expect(ends[0].status).toBe('done');
});

it('does not re-drop or re-log while the agent stays disabled (R09)', async () => {
const metas = { a: { enabled: false } };
const { host, logs } = makeHost({ a: specWith([WEBHOOK]) }, undefined, metas);
await host.start();
await host.reload('a');
await host.reload('a');

expect(logs.filter((m) => m.includes('turned off'))).toHaveLength(0);
});
});
8 changes: 8 additions & 0 deletions apps/agent-forge/server/triggers/channelTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,14 @@ export class ChannelTriggers {
return;
}

// R09: a turned-off agent's channels answer 200 skipped so platforms
// do not retry; the SDK handler (which would run the agent) is never
// called, and abort listeners do not run either.
if (!this.deps.host.isEnabled(route.agentId)) {
res.status(200).json({ ok: true, skipped: 'disabled' });
return;
}

let body: Buffer;
try {
body = await readBody(req);
Expand Down
36 changes: 34 additions & 2 deletions apps/agent-forge/server/triggers/triggerHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ const END_STATUSES = new Set(['done', 'error', 'stopped']);
export class TriggerHost {
private readonly queueCap: number;
private readonly agents = new Map<string, AgentTriggers>();
/** `meta.enabled` per agent, refreshed on every reload (R09); absent means enabled. */
private readonly enabled = new Map<string, boolean>();
private readonly endListeners = new Set<(end: RunEnd) => void>();
private unsubscribers: Array<() => void> = [];
private stopped = false;
Expand Down Expand Up @@ -137,6 +139,7 @@ export class TriggerHost {
const triggers = (spec as AgentSpecV2 | undefined)?.triggers ?? [];
const state = this.stateFor(id);
state.triggers = triggers;
await this.refreshEnabled(id, state);
const known = new Set(triggers.map((t) => t.id));
const dropped = state.queue.filter((run) => !known.has(run.triggerId));
if (dropped.length > 0) {
Expand All @@ -158,10 +161,39 @@ export class TriggerHost {
return this.agents.get(agentId)?.triggers ?? [];
}

/**
* `meta.enabled` for the agent (R09): false rejects every trigger
* dispatch while manual runs stay untouched. Unknown agents read as
* enabled, matching `defaultMeta`.
*/
isEnabled(agentId: string): boolean {
return this.enabled.get(agentId) ?? true;
}

/**
* Re-reads `meta.enabled` into the cache; on a transition to disabled
* every queued host run is dropped (a running one finishes - R09).
*/
private async refreshEnabled(agentId: string, state: AgentTriggers): Promise<void> {
// Test fakes may omit readMeta; a missing meta reads as enabled.
const meta =
this.deps.repo.readMeta === undefined
? undefined
: await this.deps.repo.readMeta(agentId).catch(() => undefined);
const nowEnabled = meta?.enabled ?? true;
const wasEnabled = this.enabled.get(agentId) ?? true;
this.enabled.set(agentId, nowEnabled);
if (wasEnabled && !nowEnabled && state.queue.length > 0) {
const dropped = state.queue.splice(0);
this.deps.log(agentId, `Dropped ${dropped.length} queued runs: the agent was turned off`, 'warn');
}
}

/** Accepts a trigger request: enqueues FIFO, starts immediately when the agent is free. */
dispatch(req: DispatchRequest): DispatchResult {
// R09 seam: the enabled-only gate lands here - reject with 'disabled'
// when the agent's meta.enabled is false before touching the queue.
// R09: a turned-off agent starts no triggered run (webhook/channel map
// this to their own skip responses; cron/chain log the skip).
if (!this.isEnabled(req.agentId)) return { ok: false, reason: 'disabled' };
const trigger = this.triggersOf(req.agentId).find((t) => t.id === req.triggerId);
if (!trigger) return { ok: false, reason: 'unknown_trigger' };

Expand Down
16 changes: 14 additions & 2 deletions apps/agent-forge/server/triggers/webhookRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
*
* POST /hooks/:agentId/<trigger.path> -> 202 { queued, runKey, started, cancelled }
* | 202 { skipped: 'filter' }
* | 400/401/403/404/422/429
* | 400/401/403/404/409/422/429
*
* `cancelled` lists the runKeys abort listeners (R03b) stopped for this
* event, evaluated after signature verification and before the filter.
* A turned-off agent (`meta.enabled === false`, R09) answers 409
* `LOUSHO_AGENT_DISABLED` right after auth - no event processing beyond it.
*
* `cancelled` lists the runKeys abort listeners (R03b) stopped for this
* event, evaluated after signature verification and before the filter.
Expand Down Expand Up @@ -117,6 +122,13 @@ export function registerWebhookRoutes(app: Express, deps: WebhookRouteDeps): voi
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!(await checkSignature(req, res, trigger, rawBody, deps.resolveSecret))) return;

// R09: a turned-off agent does no event processing beyond auth -
// abort listeners, filter and dispatch all wait for it to be on.
if (!ws.triggers.isEnabled(agentId)) {
sendError(res, 409, 'Agent is turned off', 'LOUSHO_AGENT_DISABLED');
return;
}

const event = eventOf(rawBody);

// Abort listeners run after signature verification but before the
Expand Down Expand Up @@ -158,7 +170,7 @@ export function registerWebhookRoutes(app: Express, deps: WebhookRouteDeps): voi
return;
}
if (result.reason === 'disabled') {
sendError(res, 403, `Trigger '${trigger.id}' is disabled.`, 'LOUSHO_TRIGGER_DISABLED');
sendError(res, 409, 'Agent is turned off', 'LOUSHO_AGENT_DISABLED');
return;
}
sendError(res, 404, `No webhook trigger '${trigger.id}' for agent '${agentId}'`);
Expand Down
11 changes: 10 additions & 1 deletion apps/agent-forge/src/canvas/NodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface CanvasNodeData extends Record<string, unknown> {
graph?: Graph;
/** Group nodes only: how many descendants the group hides while collapsed. */
insideCount?: number;
/** `meta.enabled` of the open agent (R09); false dims non-chat trigger nodes. Absent reads enabled. */
agentEnabled?: boolean;
/**
* True on a canvas that shows a graph it does not own (X06b's code-agent
* view): node affordances that would write through `AppState.setGraph`
Expand Down Expand Up @@ -196,9 +198,16 @@ export function NodeCard({
const chips = [...(chip !== undefined ? [chip] : []), ...(extraChips ?? [])];
// A proposal-edited node carries an "edited" marker chip (C21).
const edited = graphNode.proposed === 'edit';
// R09: a turned-off agent's non-chat trigger nodes dim with the contract tooltip.
const triggerOff = data.agentEnabled === false && graphNode.kind === 'trigger' && graphNode.data.item.type !== 'chat';

return (
<div className={cardClassName('rf-node', selected, data)} data-node-kind={graphNode.kind} data-node-id={graphNode.id}>
<div
className={`${cardClassName('rf-node', selected, data)}${triggerOff ? ' node-trigger-off' : ''}`}
data-node-kind={graphNode.kind}
data-node-id={graphNode.id}
title={triggerOff ? 'Off — triggers won\'t fire' : undefined}
>
{view.ports.in && <Handle type="target" position={Position.Left} className="rf-port rf-port-in" />}
{view.ports.out && <Handle type="source" position={Position.Right} className="rf-port rf-port-out" />}
<NodeCardHead
Expand Down
Loading
Loading