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
18 changes: 18 additions & 0 deletions db/migrations/20260525120000_watcher_execution_config.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- migrate:up

-- Per-watcher local-CLI execution settings for device-worker runs (the Mac
-- app's WatcherDispatcher spawns `claude -p`/`codex` — see
-- packages/owletto/apps/mac/Owletto/WatcherDispatcher.swift). NULL = all
-- defaults (timeout falls back to the dispatcher's 600s cap; every other
-- field is omitted from the spawn args, leaving the CLI default).
--
-- Shape: { timeout_seconds, max_budget_usd, model, permission_mode, effort }.
-- A single jsonb (mirroring watchers.model_config) so future knobs don't need
-- a migration. Validated on write by the manage_watchers TypeBox schema.
ALTER TABLE public.watchers ADD COLUMN IF NOT EXISTS execution_config jsonb;

COMMENT ON COLUMN public.watchers.execution_config IS 'Per-watcher device-worker CLI execution settings: { timeout_seconds, max_budget_usd, model, permission_mode, effort }. NULL = defaults.';

-- migrate:down

ALTER TABLE public.watchers DROP COLUMN IF EXISTS execution_config;
2 changes: 1 addition & 1 deletion packages/owletto
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,35 @@ describe('POST /api/workers/me/watchers/:watcher_id/trigger', () => {
});
expect(response.status).toBe(404);
});

it('poll payload carries the watcher execution_config', async () => {
const ctx = await setupDevicePinnedWatcher({ workerId: 'mac-poll-exec' });
const execCfg = { timeout_seconds: 1800, model: 'opus', permission_mode: 'plan' };
await ctx.sql`
UPDATE watchers SET execution_config = ${ctx.sql.json(execCfg)} WHERE id = ${ctx.watcherId}
`;
const { token } = await createWorkerBoundPat(
ctx.workspace.users.owner.id,
ctx.workspace.org.id,
'mac-poll-exec'
);

// Trigger queues a pending run pinned to this device.
const trig = await post(`/api/workers/me/watchers/${ctx.watcherId}/trigger`, { token });
expect(trig.status).toBe(200);

// The device poll claims it and gets the payload envelope the dispatcher
// builds its CLI invocation from — execution_config must round-trip.
const pollRes = await post('/api/workers/poll', {
token,
body: { worker_id: 'mac-poll-exec', capabilities: {} },
});
expect(pollRes.status).toBe(200);
const job = (await pollRes.json()) as {
run_type?: string;
payload?: { watcher?: { execution_config?: Record<string, unknown> } };
};
expect(job.run_type).toBe('watcher');
expect(job.payload?.watcher?.execution_config).toEqual(execCfg);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,123 @@ describe('watcher CRUD', () => {
expect(list.watchers?.some((w) => w.watcher_id === watcherId)).toBe(false);
});

it('round-trips execution_config through create → list → update', async () => {
const created = (await owner.watchers.create({
entity_id: entityId,
slug: 'exec-config-watcher',
name: 'Exec Config Watcher',
prompt: 'Track things.',
extraction_schema: { type: 'object', properties: {} },
agent_id: agentId,
execution_config: {
timeout_seconds: 1800,
max_budget_usd: 2.5,
model: 'opus',
permission_mode: 'acceptEdits',
effort: 'high',
},
})) as { watcher_id: string };
const watcherId = created.watcher_id;

const findRow = (
res: {
watchers?: Array<{ watcher_id: string; execution_config?: Record<string, unknown> | null }>;
},
id: string
) => res.watchers?.find((w) => String(w.watcher_id) === String(id));

const list = (await owner.watchers.list({ entity_id: entityId })) as {
watchers?: Array<{ watcher_id: string; execution_config?: Record<string, unknown> }>;
};
expect(findRow(list, watcherId)?.execution_config).toEqual({
timeout_seconds: 1800,
max_budget_usd: 2.5,
model: 'opus',
permission_mode: 'acceptEdits',
effort: 'high',
});

// Update replaces the whole jsonb; a partial object is stored verbatim.
await owner.watchers.update({
watcher_id: watcherId,
execution_config: { timeout_seconds: 300 },
});
const after = (await owner.watchers.list({ entity_id: entityId })) as {
watchers?: Array<{ watcher_id: string; execution_config?: Record<string, unknown> }>;
};
expect(findRow(after, watcherId)?.execution_config).toEqual({ timeout_seconds: 300 });

// Passing null clears the saved config back to NULL/defaults.
await owner.watchers.update({ watcher_id: watcherId, execution_config: null });
const cleared = (await owner.watchers.list({ entity_id: entityId })) as {
watchers?: Array<{ watcher_id: string; execution_config?: Record<string, unknown> | null }>;
};
expect(findRow(cleared, watcherId)?.execution_config ?? null).toBeNull();

await owner.watchers.delete([watcherId]);
});

it('leaves execution_config null when unset', async () => {
const created = (await owner.watchers.create({
entity_id: entityId,
slug: 'no-exec-config-watcher',
name: 'No Exec Config',
prompt: 'Track things.',
extraction_schema: { type: 'object', properties: {} },
agent_id: agentId,
})) as { watcher_id: string };

const list = (await owner.watchers.list({ entity_id: entityId })) as {
watchers?: Array<{ watcher_id: string; execution_config?: Record<string, unknown> | null }>;
};
const row = list.watchers?.find(
(w) => String(w.watcher_id) === String(created.watcher_id)
);
expect(row).toBeDefined();
expect(row?.execution_config ?? null).toBeNull();

await owner.watchers.delete([created.watcher_id]);
});

it('rejects an invalid execution_config (type/range/unknown-key)', async () => {
const base = {
entity_id: entityId,
name: 'Bad Exec',
prompt: 'x',
extraction_schema: { type: 'object', properties: {} },
agent_id: agentId,
};
// timeout_seconds below minimum
await expect(
owner.watchers.create({ ...base, slug: 'bad-1', execution_config: { timeout_seconds: 0 } })
).rejects.toThrow(/execution_config/i);
// wrong type (string where integer expected) — would otherwise brick the
// Swift payload decode at run time.
await expect(
owner.watchers.create({
...base,
slug: 'bad-2',
execution_config: { timeout_seconds: '600' },
} as never)
).rejects.toThrow(/execution_config/i);
// unknown key (additionalProperties: false)
await expect(
owner.watchers.create({
...base,
slug: 'bad-3',
execution_config: { bogus: true },
} as never)
).rejects.toThrow(/execution_config/i);
// above maximum
await expect(
owner.watchers.create({
...base,
slug: 'bad-4',
execution_config: { timeout_seconds: 999_999 },
})
).rejects.toThrow(/execution_config/i);
});

it('creates an org-scoped watcher with no entity_id', async () => {
const created = (await owner.watchers.create({
slug: 'org-scoped-watcher',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Unit coverage for execution_config validation + the owner/admin gate on
* elevated permission modes. The end-to-end persistence/round-trip is covered
* in __tests__/integration/watchers/watchers-crud.test.ts; this pins the
* validation rules and the privilege gate without the integration harness.
*/

import { describe, expect, it } from 'bun:test';
import {
assertValidExecutionConfig,
type ExecutionConfigCaller,
} from '../../tools/admin/watcher-execution-config';

const owner: ExecutionConfigCaller = { memberRole: 'owner', userId: 'u1', isAuthenticated: true };
const admin: ExecutionConfigCaller = { memberRole: 'admin', userId: 'u2', isAuthenticated: true };
const member: ExecutionConfigCaller = { memberRole: 'member', userId: 'u3', isAuthenticated: true };
// apply / automation / default-provisioning: authenticated, no user/role.
const system: ExecutionConfigCaller = { memberRole: null, userId: null, isAuthenticated: true };

describe('assertValidExecutionConfig — passthrough', () => {
it('accepts undefined (unchanged) and null (clear)', () => {
expect(() => assertValidExecutionConfig(undefined, member)).not.toThrow();
expect(() => assertValidExecutionConfig(null, member)).not.toThrow();
});

it('accepts a valid full config', () => {
expect(() =>
assertValidExecutionConfig(
{
timeout_seconds: 1800,
max_budget_usd: 2.5,
model: 'opus',
permission_mode: 'plan',
effort: 'high',
},
owner
)
).not.toThrow();
});
});

describe('assertValidExecutionConfig — schema validation', () => {
it('rejects a non-object', () => {
expect(() => assertValidExecutionConfig('nope', owner)).toThrow(/must be a JSON object/i);
expect(() => assertValidExecutionConfig([1, 2], owner)).toThrow(/must be a JSON object/i);
});

it('rejects out-of-range timeout_seconds', () => {
expect(() => assertValidExecutionConfig({ timeout_seconds: 0 }, owner)).toThrow(
/execution_config/i
);
expect(() => assertValidExecutionConfig({ timeout_seconds: 999_999 }, owner)).toThrow(
/execution_config/i
);
});

it('rejects a wrong-typed field (string where integer expected)', () => {
// This is the silent-brick case: an unvalidated string would fail the
// device-worker's strict payload decode and disable every run.
expect(() => assertValidExecutionConfig({ timeout_seconds: '600' }, owner)).toThrow(
/execution_config/i
);
});

it('rejects unknown keys (additionalProperties: false)', () => {
expect(() => assertValidExecutionConfig({ bogus: true }, owner)).toThrow(/execution_config/i);
});

it('rejects an invalid permission_mode enum value', () => {
expect(() => assertValidExecutionConfig({ permission_mode: 'yolo' }, owner)).toThrow(
/execution_config/i
);
});
});

describe('assertValidExecutionConfig — elevated permission_mode gate', () => {
for (const mode of ['bypassPermissions', 'dontAsk']) {
it(`blocks a member from setting ${mode}`, () => {
expect(() => assertValidExecutionConfig({ permission_mode: mode }, member)).toThrow(
/owner or admin/i
);
});
it(`allows an owner to set ${mode}`, () => {
expect(() => assertValidExecutionConfig({ permission_mode: mode }, owner)).not.toThrow();
});
it(`allows an admin to set ${mode}`, () => {
expect(() => assertValidExecutionConfig({ permission_mode: mode }, admin)).not.toThrow();
});
it(`allows a system/internal caller to set ${mode}`, () => {
expect(() => assertValidExecutionConfig({ permission_mode: mode }, system)).not.toThrow();
});
}

it('allows a member to set non-elevated modes', () => {
for (const mode of ['default', 'plan', 'auto', 'acceptEdits']) {
expect(() => assertValidExecutionConfig({ permission_mode: mode }, member)).not.toThrow();
}
});
});
27 changes: 27 additions & 0 deletions packages/server/src/sandbox/namespaces/watchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ type WatcherActionInput = Omit<ManageWatchersArgs, "action" | "watcher_id"> & {
watcher_id?: WatcherId;
};

/**
* Per-watcher device-worker CLI execution settings (mirrors the
* `watchers.execution_config` jsonb and the manage_watchers TypeBox schema).
* Every field is optional; omitted fields fall back to dispatcher/CLI defaults.
*/
export interface WatcherExecutionConfig {
/** Wall-clock cap in seconds for the device-worker CLI run (default 600). */
timeout_seconds?: number;
/** Per-run dollar ceiling (claude: --max-budget-usd). */
max_budget_usd?: number;
/** Model alias/id passed to the CLI (--model). */
model?: string;
/** Tool permission mode (claude: --permission-mode). */
permission_mode?:
| "acceptEdits"
| "auto"
| "bypassPermissions"
| "default"
| "dontAsk"
| "plan";
/** Reasoning effort (claude: --effort). */
effort?: "low" | "medium" | "high";
}

export interface WatcherListFilter {
entity_id?: number;
status?: "active" | "paused" | "draft";
Expand All @@ -49,6 +73,7 @@ export interface WatcherCreateInput {
agent_id?: string;
scheduler_client_id?: string;
model_config?: Record<string, unknown>;
execution_config?: WatcherExecutionConfig;
tags?: string[];
}

Expand All @@ -58,6 +83,8 @@ export interface WatcherUpdateInput {
agent_id?: string;
scheduler_client_id?: string;
model_config?: Record<string, unknown>;
/** `null` clears a previously-saved config back to NULL/defaults. */
execution_config?: WatcherExecutionConfig | null;
sources?: Source[];
}

Expand Down
Loading
Loading