Repository navigation
feat(omni): unified executor layer — merge World B into World A #1062
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9382f29
5136c8d
fe81d12
70e2c93
6fc4376
415f993
69fafca
ed48772
b04becc
26d33e2
19f0313
e35b73f
d18e697
ca47bdf
47f4364
f9b06ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| -- Partial index for omni-sourced executor lookups. | ||
| -- Covers: findLatestByMetadata in executor-registry (lazy resume), | ||
| -- bridge's find-or-create on inbound NATS message, | ||
| -- genie ls --source omni / genie sessions list --source omni. | ||
| CREATE INDEX IF NOT EXISTS executors_omni_lookup | ||
| ON executors ( | ||
| agent_id, | ||
| (metadata->>'source'), | ||
| (metadata->>'chat_id') | ||
| ) | ||
| WHERE ended_at IS NULL; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; | ||
| import { resolveExecutorType } from '../executor-config.js'; | ||
|
|
||
| describe('resolveExecutorType', () => { | ||
| let originalEnv: string | undefined; | ||
|
|
||
| beforeEach(() => { | ||
| originalEnv = process.env.GENIE_EXECUTOR; | ||
| process.env.GENIE_EXECUTOR = undefined as unknown as string; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| if (originalEnv !== undefined) { | ||
| process.env.GENIE_EXECUTOR = originalEnv; | ||
| } else { | ||
| process.env.GENIE_EXECUTOR = undefined as unknown as string; | ||
| } | ||
| }); | ||
|
|
||
| test('defaults to tmux when nothing is set', () => { | ||
| expect(resolveExecutorType()).toBe('tmux'); | ||
| }); | ||
|
|
||
| test('override argument wins over everything', () => { | ||
| process.env.GENIE_EXECUTOR = 'tmux'; | ||
| expect(resolveExecutorType('sdk')).toBe('sdk'); | ||
| }); | ||
|
|
||
| test('env var is used when no override', () => { | ||
| process.env.GENIE_EXECUTOR = 'sdk'; | ||
| expect(resolveExecutorType()).toBe('sdk'); | ||
| }); | ||
|
|
||
| test('env var tmux is respected', () => { | ||
| process.env.GENIE_EXECUTOR = 'tmux'; | ||
| expect(resolveExecutorType()).toBe('tmux'); | ||
| }); | ||
|
|
||
| test('override beats env var', () => { | ||
| process.env.GENIE_EXECUTOR = 'sdk'; | ||
| expect(resolveExecutorType('tmux')).toBe('tmux'); | ||
| }); | ||
|
|
||
| test('invalid override falls through to env', () => { | ||
| process.env.GENIE_EXECUTOR = 'sdk'; | ||
| expect(resolveExecutorType('bogus')).toBe('sdk'); | ||
| }); | ||
|
|
||
| test('invalid override and no env falls back to tmux', () => { | ||
| expect(resolveExecutorType('invalid')).toBe('tmux'); | ||
| }); | ||
|
|
||
| test('invalid env falls back to tmux', () => { | ||
| process.env.GENIE_EXECUTOR = 'docker'; | ||
| expect(resolveExecutorType()).toBe('tmux'); | ||
| }); | ||
|
|
||
| test('empty string override falls through', () => { | ||
| process.env.GENIE_EXECUTOR = 'sdk'; | ||
| expect(resolveExecutorType('')).toBe('sdk'); | ||
| }); | ||
|
|
||
| test('undefined override falls through', () => { | ||
| process.env.GENIE_EXECUTOR = 'sdk'; | ||
| expect(resolveExecutorType(undefined)).toBe('sdk'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||
| /** | ||||||
| * Shared audit event types and recording helper. | ||||||
| * | ||||||
| * Used by the SDK executor (Group 5) and resume logic (Group 7). | ||||||
| * Writes to the `audit_events` table via safePgCall so degraded-mode | ||||||
| * (no PG) silently skips — no throw, no data loss risk. | ||||||
| */ | ||||||
|
|
||||||
| import type { SafePgCallFn } from './safe-pg-call.js'; | ||||||
|
|
||||||
| // ============================================================================ | ||||||
| // Audit event type union | ||||||
| // ============================================================================ | ||||||
|
|
||||||
| export type AuditEventType = | ||||||
| // Lifecycle | ||||||
| | 'executor.spawn' | ||||||
| | 'executor.shutdown' | ||||||
| | 'executor.state_transition' | ||||||
| // Delivery | ||||||
| | 'deliver.start' | ||||||
| | 'deliver.end' | ||||||
| | 'deliver.error' | ||||||
| | 'deliver.tool_use' | ||||||
| // Resume (Group 7) | ||||||
| | 'session.resumed' | ||||||
| | 'session.resume_rejected' | ||||||
| | 'session.created_fresh'; | ||||||
|
|
||||||
| // ============================================================================ | ||||||
| // Record helper | ||||||
| // ============================================================================ | ||||||
|
|
||||||
| /** | ||||||
| * Write an audit event row via safePgCall. | ||||||
| * | ||||||
| * Maps to the real audit_events schema: | ||||||
| * entity_type, entity_id, event_type, actor, details, created_at | ||||||
| * | ||||||
| * `entityType` defaults to 'executor' when not provided in attrs. | ||||||
| * `entityId` defaults to attrs.executor_id. | ||||||
| * `actor` defaults to attrs.agent_id. | ||||||
| */ | ||||||
| export async function recordAuditEvent( | ||||||
| safePgCall: SafePgCallFn, | ||||||
| type: AuditEventType, | ||||||
| attrs: Record<string, unknown>, | ||||||
| ): Promise<void> { | ||||||
| const entityType = (attrs.entity_type as string) ?? 'executor'; | ||||||
| const entityId = (attrs.entity_id as string) ?? (attrs.executor_id as string) ?? ''; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For consistency with how
Suggested change
|
||||||
| const actor = (attrs.actor as string) ?? (attrs.agent_id as string) ?? null; | ||||||
|
|
||||||
| // Strip meta keys from details — they're top-level columns already. | ||||||
| const { entity_type: _et, entity_id: _eid, actor: _a, ...details } = attrs; | ||||||
|
|
||||||
| await safePgCall( | ||||||
| `audit:${type}`, | ||||||
| (sql) => | ||||||
| sql`INSERT INTO audit_events (entity_type, entity_id, event_type, actor, details) | ||||||
| VALUES (${entityType}, ${entityId}, ${type}, ${actor}, ${JSON.stringify(details)})`, | ||||||
| undefined, | ||||||
| { executorId: entityId, chatId: (attrs.chat_id as string) ?? '' }, | ||||||
| ); | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| /** | ||
| * Executor type resolver. | ||
| * | ||
| * Resolution order: | ||
| * 1. Explicit override argument (CLI --executor flag) | ||
| * 2. GENIE_EXECUTOR env var | ||
| * 3. Persisted config (~/.genie/config.json → omni.executor) | ||
| * 4. Default: 'tmux' | ||
| */ | ||
|
|
||
| import { loadGenieConfigSync } from './genie-config.js'; | ||
|
|
||
| export type ExecutorType = 'tmux' | 'sdk'; | ||
|
|
||
| const VALID: ReadonlySet<string> = new Set(['tmux', 'sdk']); | ||
|
|
||
| function isValid(value: unknown): value is ExecutorType { | ||
| return typeof value === 'string' && VALID.has(value); | ||
| } | ||
|
|
||
| export function resolveExecutorType(override?: string): ExecutorType { | ||
| // 1. Explicit override | ||
| if (isValid(override)) return override; | ||
|
|
||
| // 2. Env var | ||
| const env = process.env.GENIE_EXECUTOR; | ||
| if (isValid(env)) return env; | ||
|
|
||
| // 3. Persisted config | ||
| try { | ||
| const cfg = loadGenieConfigSync(); | ||
| const persisted = cfg.omni?.executor; | ||
| if (isValid(persisted)) return persisted; | ||
| } catch { | ||
| // Config unreadable — fall through | ||
| } | ||
|
|
||
| // 4. Default | ||
| return 'tmux'; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /** | ||
| * SafePgCallFn — Structural type for the bridge's PG-guarded call pattern. | ||
| * | ||
| * Extracted from services/executor.ts so that lib/ modules (audit-events, | ||
| * sdk-session-capture) can reference it without a cross-layer import. | ||
| */ | ||
|
|
||
| import type { Sql } from './db.js'; | ||
|
|
||
| /** | ||
| * Bound `safePgCall` injected by the bridge into each executor after construction. | ||
| * | ||
| * Mirror of `OmniBridge#safePgCall` — declared here as a structural type so | ||
| * executors can call it without importing `OmniBridge` (avoids a circular | ||
| * dependency between the bridge and its own executors). Tests pass a plain | ||
| * function; production wires `bridge.safePgCall.bind(bridge)`. | ||
| */ | ||
| export type SafePgCallFn = <T>( | ||
| op: string, | ||
| fn: (sql: Sql) => Promise<T>, | ||
| fallback: T, | ||
| ctx?: { executorId?: string; chatId?: string }, | ||
| ) => Promise<T>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
lscommand is also defined insrc/term-commands/agent/list.ts. Having the command definition in two places can lead to inconsistencies and maintenance issues. It would be better to consolidate the definition in one place, likelysrc/term-commands/agent/list.ts, and havegenie.tsjust register it. If this consolidation introduces a circular dependency, use dynamic imports (require()orimport()) to break the cycle as per repository guidelines.References
require()orimport()) to break circular dependency cycles that would otherwise occur at module load time.