From ba1282abf1a726a3af5a53e7cbbd5582defaf7fb Mon Sep 17 00:00:00 2001 From: Vladimir Novick Date: Thu, 16 Apr 2026 06:58:50 +0300 Subject: [PATCH 01/13] chore pre-release prep --- .github/pull_request_template.md | 2 +- .github/workflows/ci-go.yml | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 356 +++----- CONTRIBUTING.md | 4 +- Makefile | 2 +- README.md | 26 +- cmd/itervox/main.go | 129 ++- cmd/itervox/main_test.go | 54 ++ internal/agent/claude.go | 17 +- internal/agent/codex_test.go | 1 + internal/agent/events.go | 11 +- internal/agent/events_test.go | 9 +- internal/agent/helpers_test.go | 39 +- internal/agent/input_detector.go | 221 +++++ internal/agent/input_detector_test.go | 61 ++ internal/agent/runner.go | 7 +- internal/app/enrich.go | 14 + internal/app/enrich_test.go | 26 + internal/config/validate.go | 38 + internal/config/validate_test.go | 31 + internal/domain/types.go | 2 + internal/orchestrator/dispatch.go | 3 + internal/orchestrator/event_loop.go | 467 ++++++++--- internal/orchestrator/event_loop_test.go | 39 +- .../orchestrator/input_resume_session_test.go | 165 ++++ internal/orchestrator/integration_test.go | 778 ++++++++++++++++++ internal/orchestrator/orchestrator.go | 29 +- internal/orchestrator/orchestrator_test.go | 13 +- internal/orchestrator/reviewer_test.go | 212 ++++- internal/orchestrator/semantic_input_test.go | 98 +++ internal/orchestrator/snapshot.go | 228 ++++- .../orchestrator/snapshot_internal_test.go | 28 + internal/orchestrator/state.go | 100 ++- internal/orchestrator/worker.go | 481 +++++++---- internal/server/handlers.go | 10 + internal/server/server.go | 8 +- internal/server/server_test.go | 38 + internal/statusui/helpers_test.go | 40 + internal/statusui/model.go | 57 +- internal/statusui/model_teatest_test.go | 18 + internal/statusui/statusui.go | 7 + internal/templates/human_input.md | 20 +- internal/tracker/github/client.go | 43 +- internal/tracker/linear/client.go | 36 +- internal/tracker/linear/client_test.go | 18 +- internal/tracker/linear/queries.go | 3 +- internal/tracker/memory.go | 18 +- internal/tracker/memory_test.go | 7 +- internal/tracker/tracker.go | 5 +- lefthook.yml | 2 +- site/src/content/docs/configuration.mdx | 38 +- site/src/content/docs/getting-started.mdx | 3 +- site/src/pages/index.astro | 2 +- web/src/App.tsx | 30 +- web/src/__tests__/App.test.ts | 41 + web/src/components/itervox/IssueCard.tsx | 11 +- .../components/itervox/IssueDetailSlide.tsx | 27 +- .../itervox/__tests__/IssueCard.test.tsx | 13 + .../__tests__/IssueDetailSlide.test.tsx | 13 + web/src/layout/AppHeader.tsx | 46 +- web/src/layout/__tests__/AppHeader.test.tsx | 72 ++ .../pages/Dashboard/components/ListView.tsx | 9 +- web/src/pages/Logs/__tests__/Logs.test.tsx | 116 ++- web/src/pages/Logs/index.tsx | 113 ++- web/src/pages/Settings/ReviewerCard.tsx | 44 +- web/src/pages/Settings/WorkspaceCard.tsx | 19 +- .../Settings/__tests__/ReviewerCard.test.tsx | 49 ++ .../Settings/__tests__/WorkspaceCard.test.tsx | 49 ++ web/src/pages/Settings/index.tsx | 18 +- web/src/queries/__tests__/mutations.test.ts | 100 ++- web/src/queries/issues.ts | 203 +++-- web/src/types/schemas.ts | 10 +- web/src/utils/__tests__/format.test.ts | 22 +- web/src/utils/__tests__/inputRequired.test.ts | 52 ++ web/src/utils/format.ts | 6 + web/src/utils/inputRequired.ts | 27 + 77 files changed, 4288 insertions(+), 870 deletions(-) create mode 100644 internal/agent/input_detector.go create mode 100644 internal/agent/input_detector_test.go create mode 100644 internal/orchestrator/input_resume_session_test.go create mode 100644 internal/orchestrator/semantic_input_test.go create mode 100644 internal/orchestrator/snapshot_internal_test.go create mode 100644 web/src/__tests__/App.test.ts create mode 100644 web/src/layout/__tests__/AppHeader.test.tsx create mode 100644 web/src/pages/Settings/__tests__/WorkspaceCard.test.tsx create mode 100644 web/src/utils/__tests__/inputRequired.test.ts create mode 100644 web/src/utils/inputRequired.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2c0fad9..8bb334f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,7 +19,7 @@ - [ ] `go build ./...` passes - [ ] `go test -race ./...` passes (or `make test` if touching Go) - [ ] `cd web && pnpm test` passes (or `make web-test` if touching frontend) -- [ ] `golangci-lint run ./...` passes (if touching Go) +- [ ] `golangci-lint run ./cmd/... ./internal/...` passes (if touching Go) - [ ] `cd web && pnpm lint` passes (if touching frontend) - [ ] New behaviour is covered by tests - [ ] No API tokens, secrets, or credentials in the diff diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 53f24a0..b4c2a38 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -68,7 +68,7 @@ jobs: uses: golangci/golangci-lint-action@v9 with: version: latest - args: --timeout=5m --build-tags dev + args: --timeout=5m --build-tags dev ./cmd/... ./internal/... govulncheck: name: Security (govulncheck) diff --git a/AGENTS.md b/AGENTS.md index 6eba5f8..5abb7dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Reading the bundle before editing prevents the entire class of bugs it was writt go build ./... go test -race ./... go vet ./... -golangci-lint run ./... +golangci-lint run ./cmd/... ./internal/... # Frontend cd web diff --git a/CHANGELOG.md b/CHANGELOG.md index 92da813..1be1d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,285 +5,121 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [v0.0.3] — unreleased +## [0.1.4] — unreleased -### Added - -#### Codex (OpenAI CLI) backend - -| File | Change | -|------|--------| -| `internal/agent/codex.go` *(new)* | `CodexRunner.RunTurn` — spawns `codex` CLI, pipes stdout, delegates to `readLines` with `ParseCodexLine` | -| `internal/agent/codex_events.go` *(new)* | `ParseCodexLine` — parses Codex JSONL stream: `thread.started`, `item.started` (command_execution, collab_tool_call), `item.completed`, `turn.completed`, `turn.failed` | -| `internal/agent/multi.go` *(new)* | `MultiRunner` — selects Claude or Codex runner based on the active agent profile's `command` field | -| `internal/agent/events.go` | `StreamEvent.InProgress bool` — set `true` for `item.started` events to distinguish in-flight from completed tool calls | - -#### Observability: action_started and action_detail log lines - -| File | Change | -|------|--------| -| `internal/agent/claude.go` | `readLines`: emits `INFO : action_started … tool=… description=…` when `ev.InProgress`; emits `INFO : action_detail … tool=shell status=… exit_code=… output_size=…` for completed shell calls via new `logShellDetail()` | -| `internal/agent/claude.go` | `toolDescription("shell")`: appends ` (exit:N)` to description when exit code is non-zero | -| `internal/server/server.go` | `IssueLogEntry` gains `Detail string \`json:"detail,omitempty"\`` and `Time string \`json:"time,omitempty"\`` | -| `internal/server/handlers.go` | `parseLogLine`: new cases for `action_started` (→ `event:"action"` with `…` suffix) and `action_detail` (→ `event:"action"` with `Detail` JSON); both handle `claude:` and `codex:` prefixes | -| `internal/server/handlers.go` | `buildDetailJSON(status, exitCode, outputSize string) string` *(new)* — builds `{"status":…,"exit_code":…,"output_size":…}` omitting empty fields, using a typed struct for deterministic key order | -| `internal/statusui/model.go` | `colorLine`: `action_detail` case returns `""` (suppressed); `action_started` case renders gray `⧖ tool — desc…` | -| `web/src/types/itervox.ts` | `IssueLogEntry.time?: string`, `IssueLogEntry.detail?: string` | - -#### Codex parity in TUI and web API - -| File | Change | -|------|--------| -| `internal/statusui/model.go` | `colorLine`: `codex: text/action/subagent/todo/action_started` handled identically to `claude:` equivalents | -| `internal/statusui/model.go` | `buildToolStats`: `\|\| strings.HasPrefix(line, "INFO codex: action")` added; explicit early `action_detail` skip before generic `action` match | -| `internal/statusui/model.go` | `buildToolCalls`: same extensions as `buildToolStats` | -| `internal/server/handlers.go` | `parseLogLine`: `codex: text/subagent/action/todo` cases added mirroring `claude:` | -| `internal/server/handlers.go` | `skipLine`: `INFO codex: session started` and `INFO codex: turn done` added | - -#### Named agent profiles - -| File | Change | -|------|--------| -| `internal/config/config.go` | `AgentProfile{Command, Prompt, Backend}` struct; `Agent.Profiles map[string]AgentProfile` | -| `internal/orchestrator/orchestrator.go` | Profile lookup per issue; `MultiRunner` selected based on profile `Command`; profile prompt appended to rendered prompt | -| `internal/orchestrator/state.go` | `StateSnapshot.AvailableProfiles []string`, `ProfileDefs map[string]ProfileDef`, `AgentMode`, `ActiveStates`, `TerminalStates`, `CompletionState`, `BacklogStates` | -| `internal/server/handlers.go` | `/api/v1/settings` exposes `availableProfiles` and `profileDefs` | -| `internal/templates/workflow_github.md` | Profile section examples added | -| `internal/templates/workflow_linear.md` | Profile section examples added | -| `WORKFLOW.md` *(new)* | Root-level workflow template with profile definitions | -| `web/src/types/itervox.ts` | `ProfileDef` interface; `StateSnapshot.availableProfiles`, `profileDefs`, `agentMode`, `activeStates`, `terminalStates`, `completionState`, `backlogStates` | -| `web/src/pages/Settings/index.tsx` | Profile picker UI | -| `web/src/pages/Settings/profileCommands.ts` *(new)* | Per-profile agent command helpers | -| `web/src/hooks/useSettingsActions.ts` | Profile selection action | - -#### Frontend: running sessions table - -| File | Change | -|------|--------| -| `web/src/types/itervox.ts` | `RunningRow.backend string`, `HistoryRow.backend? string` | -| `web/src/components/itervox/RunningSessionsTable.tsx` | Backend column | -| `web/src/queries/issues.ts` | Backend field forwarded | - -#### Per-run log isolation (`AppSessionID` + `session_id` stamping) - -Each daemon invocation now receives a unique `AppSessionID` (a `crypto/rand`-derived hex string generated -at startup). Every completed run is tagged with the ID of the daemon that produced it, and every log entry -is tagged with the Claude Code session ID that produced it. This allows the Timeline page to show only the -subagents that belong to a specific run when you expand it — previously, expanding run #2 of an issue -would show subagents from all prior runs mixed together. - -| File | Change | -|------|--------| -| `cmd/itervox/main.go` | `newAppSessionID()` *(new)* — generates a 16-byte `crypto/rand` hex token at startup; stored as `appSessionID` and threaded through `buildSnapFunc` | -| `cmd/itervox/main.go` | `buildSnapFunc`: `HistoryRow.AppSessionID` set from `run.AppSessionID`; `StateSnapshot.CurrentAppSessionID` set from the live token | -| `internal/orchestrator/state.go` | `CompletedRun.AppSessionID string` *(new)* — daemon-invocation grouping key; empty for legacy entries | -| `internal/orchestrator/orchestrator.go` | `Orchestrator.appSessionID string` field and `SetAppSessionID(id string)` method *(new)* — allows `main.go` to inject the token after construction; stamped onto `CompletedRun` at worker exit | -| `internal/orchestrator/logging.go` | `formatBufLine` `switch key`: new `case "session_id"` maps slog key-value to `BufLogEntry.SessionID` — previously the session ID was silently dropped | -| `internal/domain/types.go` | `BufLogEntry.SessionID string` `json:"session_id,omitempty"` *(new)*; `IssueLogEntry.SessionID string` `json:"sessionId,omitempty"` *(new)* | -| `internal/server/handlers.go` | `parseLogLine`: copies `e.SessionID` → `entry.SessionID` | -| `internal/server/server.go` | `HistoryRow.AppSessionID string` `json:"appSessionId,omitempty"` *(new)*; `StateSnapshot.CurrentAppSessionID string` `json:"currentAppSessionId,omitempty"` *(new)* | -| `web/src/types/schemas.ts` | `IssueLogEntrySchema.sessionId z.string().optional()`; `StateSnapshotSchema.currentAppSessionId z.string().optional()` | -| `web/src/pages/Timeline/index.tsx` | `NormalisedSession.sessionId?: string` threaded through `fromRunning`/`fromHistory`; `extractSubagents` accepts `filterSessionId?: string` — filters log entries to the run's session before parsing, so each expanded run shows only its own subagents; daemon session badge in header | - -#### `.env` file support - -| File | Change | -|------|--------| -| `cmd/itervox/main.go` | `loadDotEnv()` *(new)* — loads `.itervox/.env` or `.env` from CWD at startup via `github.com/joho/godotenv`; existing env vars are never overwritten; runs before `config.Load` so env vars are available for config resolution | -| `.env.example` *(new)* | Documents all required env vars with format hints (`LINEAR_API_KEY`, `GITHUB_TOKEN`, `SSH_KEY_PATH`) | - -#### Single-issue fast-path fetch (`FetchIssueByIdentifier`) - -| File | Change | -|------|--------| -| `internal/tracker/tracker.go` | `Tracker` interface gains `FetchIssueByIdentifier(ctx, identifier) (*Issue, error)` method | -| `internal/tracker/linear/client.go` | Implements `FetchIssueByIdentifier` for Linear | -| `internal/tracker/github/client.go` | Implements `FetchIssueByIdentifier` for GitHub | -| `internal/tracker/memory.go` | Implements `FetchIssueByIdentifier` for in-memory tracker | -| `internal/server/server.go` | New `FetchIssue` callback on `server.Config`; `handleIssueDetail` uses fast path via `FetchIssue` with fallback to `fetchIssues` scan | - -#### `itervox init --runner` flag - -| File | Change | -|------|--------| -| `cmd/itervox/main.go` | `runInit`: new `--runner claude\|codex` flag (default: `claude`); `codex` emits `command: codex` + `backend: codex` in the generated WORKFLOW.md; runner is validated before file write | -| `cmd/itervox/main.go` | `generateWorkflow`: accepts `runner` parameter and emits the appropriate `agent:` block | -| `cmd/itervox/main.go` | `configuredBackend(command, explicit string)` *(new)* — resolves final backend string from agent command + explicit override | - -#### Per-project log directory - -| File | Change | -|------|--------| -| `cmd/itervox/main.go` | `--logs-dir` default changed from `./log` to `~/.itervox/logs//`; new `defaultLogsDir(workflowPath string)` helper performs a lightweight early config read to derive the path; failures fall back to `~/.itervox/logs` | - -#### Auto-clear workspace - -| File | Change | -|------|--------| -| `internal/orchestrator/orchestrator.go` | `SetAutoClearWorkspaceCfg(enabled bool)` / `AutoClearWorkspaceCfg() bool` — toggle automatic workspace deletion after a task reaches completion state; safe to call from any goroutine (guards via `cfgMu`) | -| `internal/server/server.go` | `WorkspaceConfig.AutoClearWorkspace bool`; `setAutoClearWorkspace` callback + `SetAutoClearWorkspaceSetter` | -| `internal/server/handlers.go` | `POST /api/v1/settings/workspace/auto-clear` — persists the toggle back to WORKFLOW.md and notifies the orchestrator | -| `internal/workflow/loader.go` | `PatchWorkspaceBoolField(path, key string, enabled bool)` *(new)* — generic workspace-block bool patcher; backed by shared `patchBlockBoolField` with the existing `PatchAgentBoolField` | -| `web/src/pages/Settings/index.tsx` | Toggle switch "Auto-clear workspace on success" with description | -| `web/src/types/itervox.ts` (via `schemas.ts`) | `StateSnapshot.autoClearWorkspace?: boolean` | - -#### Agent queue view - -| File | Change | -|------|--------| -| `web/src/components/itervox/AgentQueueView.tsx` *(new)* | Drag-and-drop issue→agent-profile assignment board using `@dnd-kit/core`; columns per profile + "Unassigned"; dragging a card calls `onProfileChange` | -| `web/src/pages/Dashboard/index.tsx` | "◈ Agents" tab added to the board/list/agents toggle (visible when `availableProfiles.length > 0`); `AgentQueueView` rendered in agents tab | -| `web/src/pages/Dashboard/index.tsx` | Inline profile ` { - updatePending({ profile: e.target.value }); + const nextProfile = e.target.value; + updatePending({ profile: nextProfile, ...(nextProfile === '' ? { auto: false } : {}) }); }} className="w-full cursor-pointer rounded-[var(--radius-sm)] border border-[var(--line)] bg-[var(--panel-strong)] px-3 py-2 text-[13px] text-[var(--text)] focus:outline-none" > @@ -77,6 +98,12 @@ export function ReviewerCard({ type="checkbox" checked={auto} onChange={(e) => { + if (e.target.checked && autoClearWorkspace) { + setError( + 'Auto-review cannot be enabled while auto-clear workspace is enabled. Disable auto-clear first.', + ); + return; + } updatePending({ auto: e.target.checked }); }} disabled={!profile} @@ -86,6 +113,11 @@ export function ReviewerCard({ Auto-review after agent succeeds + {error && ( +

+ {error} +

+ )} {auto && profile && (

A reviewer worker will be automatically dispatched using the {profile}{' '} diff --git a/web/src/pages/Settings/WorkspaceCard.tsx b/web/src/pages/Settings/WorkspaceCard.tsx index 97b3e6b..ac56749 100644 --- a/web/src/pages/Settings/WorkspaceCard.tsx +++ b/web/src/pages/Settings/WorkspaceCard.tsx @@ -1,16 +1,29 @@ import { useState } from 'react'; +const AUTO_REVIEW_CONFLICT_ERROR = + 'Auto-clear cannot be enabled while auto-review is enabled. Disable auto-review first.'; + interface WorkspaceCardProps { autoClearWorkspace: boolean; + autoReviewEnabled: boolean; onToggle: (enabled: boolean) => Promise; } -export function WorkspaceCard({ autoClearWorkspace, onToggle }: WorkspaceCardProps) { +export function WorkspaceCard({ + autoClearWorkspace, + autoReviewEnabled, + onToggle, +}: WorkspaceCardProps) { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const visibleError = !autoReviewEnabled && error === AUTO_REVIEW_CONFLICT_ERROR ? '' : error; const handleChange = async (enabled: boolean) => { if (saving) return; + if (enabled && autoReviewEnabled) { + setError(AUTO_REVIEW_CONFLICT_ERROR); + return; + } setSaving(true); setError(''); const ok = await onToggle(enabled); @@ -55,9 +68,9 @@ export function WorkspaceCard({ autoClearWorkspace, onToggle }: WorkspaceCardPro When a task completes successfully (reaches the completion state), automatically delete the cloned workspace directory. Logs are always kept for visibility. - {error && ( + {visibleError && ( - {error} + {visibleError} )} diff --git a/web/src/pages/Settings/__tests__/ReviewerCard.test.tsx b/web/src/pages/Settings/__tests__/ReviewerCard.test.tsx index 55b64d7..bc4f5da 100644 --- a/web/src/pages/Settings/__tests__/ReviewerCard.test.tsx +++ b/web/src/pages/Settings/__tests__/ReviewerCard.test.tsx @@ -15,6 +15,7 @@ describe('ReviewerCard', () => { , @@ -29,6 +30,7 @@ describe('ReviewerCard', () => { , @@ -42,6 +44,7 @@ describe('ReviewerCard', () => { , @@ -58,6 +61,7 @@ describe('ReviewerCard', () => { , @@ -74,6 +78,7 @@ describe('ReviewerCard', () => { , @@ -86,6 +91,7 @@ describe('ReviewerCard', () => { , @@ -104,6 +110,7 @@ describe('ReviewerCard', () => { , @@ -125,6 +132,7 @@ describe('ReviewerCard', () => { , @@ -142,4 +150,45 @@ describe('ReviewerCard', () => { expect(screen.queryByText('Saving…')).toBeNull(); }); }); + + it('blocks enabling auto-review while auto-clear is enabled', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(screen.getByRole('alert')).toHaveTextContent(/auto-clear/i); + expect(screen.queryByRole('button', { name: /save/i })).toBeNull(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('keeps pending edits and shows an error when save returns false', async () => { + const failedSave = vi.fn().mockResolvedValue(false); + + render( + , + ); + + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'reviewer' } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(failedSave).toHaveBeenCalledWith('reviewer', false); + expect(screen.getByRole('alert')).toHaveTextContent(/failed to save reviewer settings/i); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/pages/Settings/__tests__/WorkspaceCard.test.tsx b/web/src/pages/Settings/__tests__/WorkspaceCard.test.tsx new file mode 100644 index 0000000..d1b4778 --- /dev/null +++ b/web/src/pages/Settings/__tests__/WorkspaceCard.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { WorkspaceCard } from '../WorkspaceCard'; + +describe('WorkspaceCard', () => { + let onToggle: ReturnType; + + beforeEach(() => { + onToggle = vi.fn().mockResolvedValue(true); + }); + + it('calls onToggle when enabling auto-clear is allowed', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('checkbox')); + + await waitFor(() => { + expect(onToggle).toHaveBeenCalledWith(true); + }); + }); + + it('blocks enabling auto-clear while auto-review is enabled', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(onToggle).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent(/auto-review/i); + }); + + it('hides the auto-review conflict once auto-review is disabled', () => { + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('checkbox')); + expect(screen.getByRole('alert')).toHaveTextContent(/auto-review/i); + + rerender( + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/Settings/index.tsx b/web/src/pages/Settings/index.tsx index 6b27628..77f26d3 100644 --- a/web/src/pages/Settings/index.tsx +++ b/web/src/pages/Settings/index.tsx @@ -11,7 +11,6 @@ import { ReviewerCard } from './ReviewerCard'; import { CapacityCard } from './CapacityCard'; import { ConfirmButton } from '../../components/ui/button/ConfirmButton'; import { useClearAllLogs, useClearAllWorkspaces } from '../../queries/issues'; -import { useQueryClient } from '@tanstack/react-query'; import { EMPTY_PROFILE_DEFS, EMPTY_PROFILES, EMPTY_STATES } from '../../utils/constants'; export default function Settings() { @@ -36,11 +35,11 @@ export default function Settings() { setProjectFilter, setReviewerConfig, } = useSettingsActions(); - const queryClient = useQueryClient(); const clearAllLogs = useClearAllLogs(); const clearAllWorkspaces = useClearAllWorkspaces(); const trackerKind = useItervoxStore((s) => s.snapshot?.trackerKind); const activeProjectFilter = useItervoxStore((s) => s.snapshot?.activeProjectFilter); + const autoReviewEnabled = autoReview && reviewerProfile !== ''; return ( <> @@ -88,6 +87,7 @@ export default function Settings() { @@ -122,7 +122,11 @@ export default function Settings() { > Workspace - + {/* ── Agents ────────────────────────────────────────────────────── */} @@ -186,13 +190,7 @@ export default function Settings() { pendingLabel="Resetting…" isPending={clearAllWorkspaces.isPending} onConfirm={() => { - clearAllWorkspaces.mutate(undefined, { - onSuccess: () => { - void useItervoxStore.getState().refreshSnapshot(); - void queryClient.invalidateQueries({ queryKey: ['logs'] }); - void queryClient.invalidateQueries({ queryKey: ['sublogs'] }); - }, - }); + clearAllWorkspaces.mutate(undefined); }} /> diff --git a/web/src/queries/__tests__/mutations.test.ts b/web/src/queries/__tests__/mutations.test.ts index 6574ab9..61072c3 100644 --- a/web/src/queries/__tests__/mutations.test.ts +++ b/web/src/queries/__tests__/mutations.test.ts @@ -2,7 +2,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useUpdateIssueState, useCancelIssue, ISSUES_KEY } from '../issues'; +import { + ISSUE_KEY, + ISSUES_KEY, + useCancelIssue, + useClearAllWorkspaces, + useClearIssueLogs, + useProvideInput, + useTriggerAIReview, + useUpdateIssueState, +} from '../issues'; +import { logIdentifiersKey, logsKey } from '../logs'; import type { TrackerIssue, StateSnapshot } from '../../types/schemas'; import { useItervoxStore } from '../../store/itervoxStore'; import { useToastStore } from '../../store/toastStore'; @@ -64,6 +74,7 @@ describe('useUpdateIssueState', () => { it('applies optimistic update to the query cache immediately', async () => { const qc = freshClient(); qc.setQueryData(ISSUES_KEY, [makeIssue('ABC-1', 'Todo')]); + qc.setQueryData(ISSUE_KEY('ABC-1'), makeIssue('ABC-1', 'Todo')); // Mutation fn hangs so we can inspect optimistic state before it resolves global.fetch = vi.fn().mockReturnValue(new Promise(() => {})); @@ -81,6 +92,7 @@ describe('useUpdateIssueState', () => { const cached = qc.getQueryData(ISSUES_KEY); expect(cached?.[0].state).toBe('In Progress'); + expect(qc.getQueryData(ISSUE_KEY('ABC-1'))?.state).toBe('In Progress'); }); it('rolls back the cache when the API call fails', async () => { @@ -129,11 +141,96 @@ describe('useUpdateIssueState', () => { }); }); +describe('mutation refresh behavior', () => { + it('refreshes snapshot and invalidates issue caches after provideInput succeeds', async () => { + const qc = freshClient(); + const refreshSpy = vi + .spyOn(useItervoxStore.getState(), 'refreshSnapshot') + .mockResolvedValue(undefined); + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries'); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook(() => useProvideInput(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync({ identifier: 'ABC-9', message: 'continue' }); + }); + + expect(refreshSpy).toHaveBeenCalled(); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ISSUES_KEY }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ISSUE_KEY('ABC-9') }); + }); + + it('refreshes snapshot and invalidates issue caches after AI review trigger succeeds', async () => { + const qc = freshClient(); + const refreshSpy = vi + .spyOn(useItervoxStore.getState(), 'refreshSnapshot') + .mockResolvedValue(undefined); + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries'); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook(() => useTriggerAIReview(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync('ABC-11'); + }); + + expect(refreshSpy).toHaveBeenCalled(); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ISSUES_KEY }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ISSUE_KEY('ABC-11') }); + }); + + it('invalidates log queries after clearing issue logs', async () => { + const qc = freshClient(); + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries'); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook(() => useClearIssueLogs(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync('ABC-10'); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: logsKey('ABC-10') }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: logIdentifiersKey() }); + }); + + it('refreshes snapshot and invalidates global log queries after clearing all workspaces', async () => { + const qc = freshClient(); + const refreshSpy = vi + .spyOn(useItervoxStore.getState(), 'refreshSnapshot') + .mockResolvedValue(undefined); + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries'); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook(() => useClearAllWorkspaces(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync(); + }); + + expect(refreshSpy).toHaveBeenCalled(); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['logs'] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['sublogs'] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: logIdentifiersKey() }); + }); +}); + // ─── useCancelIssue ─────────────────────────────────────────────────────────── describe('useCancelIssue', () => { it('applies optimistic patch to the snapshot on mutate', async () => { const qc = freshClient(); + qc.setQueryData(ISSUES_KEY, [makeIssue('ABC-2', 'In Progress')]); + qc.setQueryData(ISSUE_KEY('ABC-2'), makeIssue('ABC-2', 'In Progress')); const baseSnapshot = { generatedAt: new Date().toISOString(), @@ -178,6 +275,7 @@ describe('useCancelIssue', () => { expect(patchSpy).toHaveBeenCalled(); const patchArg = patchSpy.mock.calls[0][0]; expect(patchArg.paused).toContain('ABC-2'); + expect(qc.getQueryData(ISSUE_KEY('ABC-2'))?.orchestratorState).toBe('paused'); }); it('rolls back both cache and snapshot on API error', async () => { diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts index 4857957..f6ecca8 100644 --- a/web/src/queries/issues.ts +++ b/web/src/queries/issues.ts @@ -7,11 +7,19 @@ import { TrackerIssueSchema } from '../types/schemas'; import { z } from 'zod'; import { authedFetch } from '../auth/authedFetch'; import { UnauthorizedError } from '../auth/UnauthorizedError'; +import { logIdentifiersKey, logsKey, sublogsKey } from './logs'; export const ISSUES_KEY = ['issues'] as const; export const ISSUE_KEY = (identifier: string) => ['issue', identifier] as const; -type RollbackContext = { prevIssues?: TrackerIssue[]; prevSnapshot?: StateSnapshot } | undefined; +type RollbackContext = + | { + prevIssue?: TrackerIssue; + prevIssueIdentifier?: string; + prevIssues?: TrackerIssue[]; + prevSnapshot?: StateSnapshot; + } + | undefined; /** * Extracts a user-facing message from an unknown error and shows it as a toast. @@ -32,11 +40,47 @@ function toastApiError(err: unknown, fallback = 'Action failed — please try ag function makeRollbackHandler(queryClient: QueryClient) { return (_error: unknown, _vars: unknown, context: RollbackContext) => { if (context?.prevIssues) queryClient.setQueryData(ISSUES_KEY, context.prevIssues); + if (context?.prevIssueIdentifier && context.prevIssue) { + queryClient.setQueryData(ISSUE_KEY(context.prevIssueIdentifier), context.prevIssue); + } if (context?.prevSnapshot) useItervoxStore.getState().setSnapshot(context.prevSnapshot); toastApiError(_error); }; } +function invalidateIssueQueries(queryClient: QueryClient, identifier?: string): void { + void queryClient.invalidateQueries({ queryKey: ISSUES_KEY }); + if (identifier) { + void queryClient.invalidateQueries({ queryKey: ISSUE_KEY(identifier) }); + } +} + +function refreshIssueViews(queryClient: QueryClient, identifier?: string): void { + void useItervoxStore.getState().refreshSnapshot(); + invalidateIssueQueries(queryClient, identifier); +} + +function updateIssueCaches( + queryClient: QueryClient, + identifier: string, + updater: (issue: TrackerIssue) => TrackerIssue, +): { prevIssue?: TrackerIssue; prevIssues?: TrackerIssue[] } { + const prevIssues = queryClient.getQueryData(ISSUES_KEY); + if (prevIssues) { + queryClient.setQueryData( + ISSUES_KEY, + prevIssues.map((issue) => (issue.identifier === identifier ? updater(issue) : issue)), + ); + } + + const prevIssue = queryClient.getQueryData(ISSUE_KEY(identifier)); + if (prevIssue) { + queryClient.setQueryData(ISSUE_KEY(identifier), updater(prevIssue)); + } + + return { prevIssue, prevIssues }; +} + async function fetchIssues(): Promise { const res = await authedFetch('/api/v1/issues'); if (!res.ok) throw new Error(`fetch issues failed: ${String(res.status)}`); @@ -101,18 +145,13 @@ export function useUpdateIssueState() { onMutate: async ({ identifier, state }: { identifier: string; state: string }) => { // Cancel any in-flight refetches so they don't overwrite the optimistic update. await queryClient.cancelQueries({ queryKey: ISSUES_KEY }); - const prevIssues = queryClient.getQueryData(ISSUES_KEY); - - if (prevIssues) { - queryClient.setQueryData( - ISSUES_KEY, - prevIssues.map((issue) => - issue.identifier === identifier ? { ...issue, state } : issue, - ), - ); - } + await queryClient.cancelQueries({ queryKey: ISSUE_KEY(identifier) }); + const { prevIssue, prevIssues } = updateIssueCaches(queryClient, identifier, (issue) => ({ + ...issue, + state, + })); - return { prevIssues }; + return { prevIssue, prevIssueIdentifier: identifier, prevIssues }; }, mutationFn: async ({ identifier, state }: { identifier: string; state: string }) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/state`, { @@ -123,8 +162,8 @@ export function useUpdateIssueState() { if (!res.ok) throw new Error(`updateIssueState failed: ${String(res.status)}`); }, onError: makeRollbackHandler(queryClient), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ISSUES_KEY }); + onSuccess: (_data, { identifier }) => { + invalidateIssueQueries(queryClient, identifier); }, }); } @@ -134,16 +173,12 @@ export function useSetIssueProfile() { return useMutation({ onMutate: async ({ identifier, profile }: { identifier: string; profile: string }) => { await queryClient.cancelQueries({ queryKey: ISSUES_KEY }); - const prevIssues = queryClient.getQueryData(ISSUES_KEY); - if (prevIssues) { - queryClient.setQueryData( - ISSUES_KEY, - prevIssues.map((i) => - i.identifier === identifier ? { ...i, agentProfile: profile || undefined } : i, - ), - ); - } - return { prevIssues }; + await queryClient.cancelQueries({ queryKey: ISSUE_KEY(identifier) }); + const { prevIssue, prevIssues } = updateIssueCaches(queryClient, identifier, (issue) => ({ + ...issue, + agentProfile: profile || undefined, + })); + return { prevIssue, prevIssueIdentifier: identifier, prevIssues }; }, mutationFn: async ({ identifier, profile }: { identifier: string; profile: string }) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/profile`, { @@ -154,8 +189,8 @@ export function useSetIssueProfile() { if (!res.ok) throw new Error(`setIssueProfile failed: ${String(res.status)}`); }, onError: makeRollbackHandler(queryClient), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ISSUES_KEY }); + onSuccess: (_data, { identifier }) => { + invalidateIssueQueries(queryClient, identifier); }, }); } @@ -165,16 +200,12 @@ export function useSetIssueBackend() { return useMutation({ onMutate: async ({ identifier, backend }: { identifier: string; backend: string }) => { await queryClient.cancelQueries({ queryKey: ISSUES_KEY }); - const prevIssues = queryClient.getQueryData(ISSUES_KEY); - if (prevIssues) { - queryClient.setQueryData( - ISSUES_KEY, - prevIssues.map((i) => - i.identifier === identifier ? { ...i, agentBackend: backend || undefined } : i, - ), - ); - } - return { prevIssues }; + await queryClient.cancelQueries({ queryKey: ISSUE_KEY(identifier) }); + const { prevIssue, prevIssues } = updateIssueCaches(queryClient, identifier, (issue) => ({ + ...issue, + agentBackend: backend || undefined, + })); + return { prevIssue, prevIssueIdentifier: identifier, prevIssues }; }, mutationFn: async ({ identifier, backend }: { identifier: string; backend: string }) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/backend`, { @@ -185,8 +216,8 @@ export function useSetIssueBackend() { if (!res.ok) throw new Error(`setIssueBackend failed: ${String(res.status)}`); }, onError: makeRollbackHandler(queryClient), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ISSUES_KEY }); + onSuccess: (_data, { identifier }) => { + invalidateIssueQueries(queryClient, identifier); }, }); } @@ -196,17 +227,12 @@ export function useCancelIssue() { return useMutation({ onMutate: async (identifier: string) => { await queryClient.cancelQueries({ queryKey: ISSUES_KEY }); - const prevIssues = queryClient.getQueryData(ISSUES_KEY); + await queryClient.cancelQueries({ queryKey: ISSUE_KEY(identifier) }); + const { prevIssue, prevIssues } = updateIssueCaches(queryClient, identifier, (issue) => ({ + ...issue, + orchestratorState: 'paused', + })); const prevSnapshot = useItervoxStore.getState().snapshot; - - if (prevIssues) { - queryClient.setQueryData( - ISSUES_KEY, - prevIssues.map((issue) => - issue.identifier === identifier ? { ...issue, orchestratorState: 'paused' } : issue, - ), - ); - } if (prevSnapshot) { const updated = optimisticPauseSnapshot(prevSnapshot, identifier); useItervoxStore.getState().patchSnapshot({ @@ -216,7 +242,12 @@ export function useCancelIssue() { }); } - return { prevIssues, prevSnapshot: prevSnapshot ?? undefined }; + return { + prevIssue, + prevIssueIdentifier: identifier, + prevIssues, + prevSnapshot: prevSnapshot ?? undefined, + }; }, mutationFn: async (identifier: string) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/cancel`, { @@ -225,10 +256,8 @@ export function useCancelIssue() { if (!res.ok) throw new Error(`cancelIssue failed: ${String(res.status)}`); }, onError: makeRollbackHandler(queryClient), - onSuccess: () => { - // SSE + useSnapshotInvalidation handle both snapshot and issue list updates. - // refreshSnapshot ensures immediate consistency if SSE is lagging. - void useItervoxStore.getState().refreshSnapshot(); + onSuccess: (_data, identifier) => { + refreshIssueViews(queryClient, identifier); }, }); } @@ -238,17 +267,12 @@ export function useResumeIssue() { return useMutation({ onMutate: async (identifier: string) => { await queryClient.cancelQueries({ queryKey: ISSUES_KEY }); - const prevIssues = queryClient.getQueryData(ISSUES_KEY); + await queryClient.cancelQueries({ queryKey: ISSUE_KEY(identifier) }); + const { prevIssue, prevIssues } = updateIssueCaches(queryClient, identifier, (issue) => ({ + ...issue, + orchestratorState: 'running', + })); const prevSnapshot = useItervoxStore.getState().snapshot; - - if (prevIssues) { - queryClient.setQueryData( - ISSUES_KEY, - prevIssues.map((issue) => - issue.identifier === identifier ? { ...issue, orchestratorState: 'running' } : issue, - ), - ); - } if (prevSnapshot) { const wasInPaused = prevSnapshot.paused.includes(identifier); const updatedPaused = prevSnapshot.paused.filter((id) => id !== identifier); @@ -280,7 +304,12 @@ export function useResumeIssue() { }); } - return { prevIssues, prevSnapshot: prevSnapshot ?? undefined }; + return { + prevIssue, + prevIssueIdentifier: identifier, + prevIssues, + prevSnapshot: prevSnapshot ?? undefined, + }; }, mutationFn: async (identifier: string) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/resume`, { @@ -289,13 +318,14 @@ export function useResumeIssue() { if (!res.ok) throw new Error(`resumeIssue failed: ${String(res.status)}`); }, onError: makeRollbackHandler(queryClient), - onSuccess: () => { - void useItervoxStore.getState().refreshSnapshot(); + onSuccess: (_data, identifier) => { + refreshIssueViews(queryClient, identifier); }, }); } export function useTerminateIssue() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (identifier: string) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/terminate`, { @@ -303,8 +333,8 @@ export function useTerminateIssue() { }); if (!res.ok) throw new Error(`terminateIssue failed: ${String(res.status)}`); }, - onSuccess: () => { - void useItervoxStore.getState().refreshSnapshot(); + onSuccess: (_data, identifier) => { + refreshIssueViews(queryClient, identifier); }, onError: (err: unknown) => { toastApiError(err, 'Terminate failed — please try again.'); @@ -313,6 +343,7 @@ export function useTerminateIssue() { } export function useTriggerAIReview() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (identifier: string) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/ai-review`, { @@ -320,6 +351,9 @@ export function useTriggerAIReview() { }); if (!res.ok) throw new Error(`triggerAIReview failed: ${String(res.status)}`); }, + onSuccess: (_data, identifier) => { + refreshIssueViews(queryClient, identifier); + }, onError: (err: unknown) => { toastApiError(err, 'AI review trigger failed — please try again.'); }, @@ -327,6 +361,7 @@ export function useTriggerAIReview() { } export function useClearIssueLogs() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (identifier: string) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/logs`, { @@ -334,6 +369,13 @@ export function useClearIssueLogs() { }); if (!res.ok) throw new Error(`clearIssueLogs failed: ${String(res.status)}`); }, + onSuccess: (_data, identifier) => { + void queryClient.invalidateQueries({ queryKey: logsKey(identifier) }); + void queryClient.invalidateQueries({ queryKey: logIdentifiersKey() }); + }, + onError: (err: unknown) => { + toastApiError(err, 'Clear logs failed — please try again.'); + }, }); } @@ -356,11 +398,18 @@ export function useClearAllLogs() { } export function useClearAllWorkspaces() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await authedFetch('/api/v1/workspaces', { method: 'DELETE' }); if (!res.ok) throw new Error(`clearAllWorkspaces failed: ${String(res.status)}`); }, + onSuccess: () => { + void useItervoxStore.getState().refreshSnapshot(); + void queryClient.invalidateQueries({ queryKey: ['logs'] }); + void queryClient.invalidateQueries({ queryKey: ['sublogs'] }); + void queryClient.invalidateQueries({ queryKey: logIdentifiersKey() }); + }, onError: (err: unknown) => { toastApiError(err, 'Reset workspaces failed — please try again.'); }, @@ -368,6 +417,7 @@ export function useClearAllWorkspaces() { } export function useClearIssueSubLogs() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (identifier: string) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/sublogs`, { @@ -375,6 +425,9 @@ export function useClearIssueSubLogs() { }); if (!res.ok) throw new Error(`clearIssueSubLogs failed: ${String(res.status)}`); }, + onSuccess: (_data, identifier) => { + void queryClient.invalidateQueries({ queryKey: sublogsKey(identifier) }); + }, onError: (err: unknown) => { toastApiError(err, 'Clear session logs failed — please try again.'); }, @@ -382,6 +435,7 @@ export function useClearIssueSubLogs() { } export function useProvideInput() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ identifier, message }: { identifier: string; message: string }) => { const res = await authedFetch( @@ -394,8 +448,8 @@ export function useProvideInput() { ); if (!res.ok) throw new Error(`provideInput failed: ${String(res.status)}`); }, - onSuccess: () => { - void useItervoxStore.getState().refreshSnapshot(); + onSuccess: (_data, { identifier }) => { + refreshIssueViews(queryClient, identifier); }, onError: (err: unknown) => { toastApiError(err, 'Failed to send input to agent.'); @@ -404,6 +458,7 @@ export function useProvideInput() { } export function useDismissInput() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (identifier: string) => { const res = await authedFetch( @@ -414,8 +469,8 @@ export function useDismissInput() { ); if (!res.ok) throw new Error(`dismissInput failed: ${String(res.status)}`); }, - onSuccess: () => { - void useItervoxStore.getState().refreshSnapshot(); + onSuccess: (_data, identifier) => { + refreshIssueViews(queryClient, identifier); }, onError: (err: unknown) => { toastApiError(err, 'Failed to dismiss input request.'); @@ -424,6 +479,7 @@ export function useDismissInput() { } export function useReanalyzeIssue() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (identifier: string) => { const res = await authedFetch(`/api/v1/issues/${encodeURIComponent(identifier)}/reanalyze`, { @@ -431,6 +487,9 @@ export function useReanalyzeIssue() { }); if (!res.ok) throw new Error(`reanalyzeIssue failed: ${String(res.status)}`); }, + onSuccess: (_data, identifier) => { + refreshIssueViews(queryClient, identifier); + }, onError: (err: unknown) => { toastApiError(err, 'Re-analysis failed — please try again.'); }, diff --git a/web/src/types/schemas.ts b/web/src/types/schemas.ts index 4c44ef9..cf53892 100644 --- a/web/src/types/schemas.ts +++ b/web/src/types/schemas.ts @@ -123,6 +123,7 @@ export const StateSnapshotSchema = z.object({ z.object({ identifier: z.string(), sessionId: z.string(), + state: z.enum(['input_required', 'pending_input_resume']), context: z.string(), backend: z.string().optional(), profile: z.string().optional(), @@ -159,7 +160,14 @@ export const TrackerIssueSchema = z.object({ state: z.string(), description: z.string().optional(), // omitempty — absent when "" url: z.string().optional(), // omitempty — absent when "" - orchestratorState: z.enum(['idle', 'running', 'retrying', 'paused', 'input_required']), + orchestratorState: z.enum([ + 'idle', + 'running', + 'retrying', + 'paused', + 'input_required', + 'pending_input_resume', + ]), turnCount: z.number().optional(), // omitempty — absent when 0 tokens: z.number().optional(), // omitempty — absent when 0 elapsedMs: z.number().optional(), // omitempty — absent when 0 diff --git a/web/src/utils/__tests__/format.test.ts b/web/src/utils/__tests__/format.test.ts index 08081ed..4b3bf15 100644 --- a/web/src/utils/__tests__/format.test.ts +++ b/web/src/utils/__tests__/format.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { fmtMs, orchDotClass, priorityDotClass, stateBadgeColor } from '../format'; +import { + fmtMs, + orchDotClass, + priorityDotClass, + stateBadgeColor, + formatOrchestratorState, +} from '../format'; describe('fmtMs', () => { it('renders seconds when under 60s', () => { @@ -31,6 +37,10 @@ describe('orchDotClass', () => { expect(orchDotClass('paused')).toBe('bg-red-400'); }); + it('returns orange pulse for pending input resume', () => { + expect(orchDotClass('pending_input_resume')).toBe('bg-orange-400 animate-pulse'); + }); + it('returns gray for idle and unknown states', () => { expect(orchDotClass('idle')).toBe('bg-gray-300 dark:bg-gray-600'); expect(orchDotClass('anything')).toBe('bg-gray-300 dark:bg-gray-600'); @@ -62,6 +72,16 @@ describe('priorityDotClass', () => { }); }); +describe('formatOrchestratorState', () => { + it('formats pending input resume for display', () => { + expect(formatOrchestratorState('pending_input_resume')).toBe('reply received'); + }); + + it('formats underscore states generically', () => { + expect(formatOrchestratorState('input_required')).toBe('input required'); + }); +}); + describe('stateBadgeColor', () => { it('returns warning for in-progress states', () => { expect(stateBadgeColor('In Progress')).toBe('warning'); diff --git a/web/src/utils/__tests__/inputRequired.test.ts b/web/src/utils/__tests__/inputRequired.test.ts new file mode 100644 index 0000000..0f89b6f --- /dev/null +++ b/web/src/utils/__tests__/inputRequired.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + inputRequiredFingerprintValue, + inputRequiredRowState, + PENDING_RESUME_CONTEXT_PREFIX, +} from '../inputRequired'; + +describe('inputRequiredRowState', () => { + it('uses explicit pending resume state when present', () => { + expect( + inputRequiredRowState({ + state: 'pending_input_resume', + context: 'anything', + }), + ).toBe('pending_input_resume'); + }); + + it('uses explicit input required state when present', () => { + expect( + inputRequiredRowState({ + state: 'input_required', + context: `${PENDING_RESUME_CONTEXT_PREFIX}\n\nOriginal request:\nNeed approval`, + }), + ).toBe('input_required'); + }); + + it('falls back to legacy pending resume context when state is missing', () => { + expect( + inputRequiredRowState({ + state: undefined as never, + context: `${PENDING_RESUME_CONTEXT_PREFIX}\n\nOriginal request:\nNeed approval`, + }), + ).toBe('pending_input_resume'); + }); +}); + +describe('inputRequiredFingerprintValue', () => { + it('changes when the row state changes for the same identifier', () => { + const waiting = inputRequiredFingerprintValue({ + identifier: 'ENG-1', + state: 'input_required', + context: 'Need approval', + }); + const pending = inputRequiredFingerprintValue({ + identifier: 'ENG-1', + state: 'pending_input_resume', + context: `${PENDING_RESUME_CONTEXT_PREFIX}\n\nOriginal request:\nNeed approval`, + }); + + expect(waiting).not.toBe(pending); + }); +}); diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts index ef9f39b..c1fe25c 100644 --- a/web/src/utils/format.ts +++ b/web/src/utils/format.ts @@ -19,9 +19,15 @@ export function orchDotClass(state: string): string { if (state === 'running') return 'bg-green-500 animate-pulse'; if (state === 'retrying') return 'bg-yellow-400 animate-pulse'; if (state === 'paused') return 'bg-red-400'; + if (state === 'pending_input_resume') return 'bg-orange-400 animate-pulse'; return 'bg-gray-300 dark:bg-gray-600'; } +export function formatOrchestratorState(state: string): string { + if (state === 'pending_input_resume') return 'reply received'; + return state.replace(/_/g, ' '); +} + /** Tailwind classes for the priority indicator dot. Returns null when no priority. */ export function priorityDotClass(p: number | null | undefined): string | null { if (!p) return null; // null, undefined, and 0 are all "no priority" diff --git a/web/src/utils/inputRequired.ts b/web/src/utils/inputRequired.ts new file mode 100644 index 0000000..94ee99f --- /dev/null +++ b/web/src/utils/inputRequired.ts @@ -0,0 +1,27 @@ +import type { StateSnapshot } from '../types/schemas'; + +type SnapshotInputRequiredRow = NonNullable[number]; +type InputRequiredRowStateInput = { + state?: SnapshotInputRequiredRow['state']; + context?: SnapshotInputRequiredRow['context']; +}; +type InputRequiredFingerprintInput = InputRequiredRowStateInput & { + identifier: SnapshotInputRequiredRow['identifier']; +}; + +export const PENDING_RESUME_CONTEXT_PREFIX = 'Reply received, waiting to resume.'; + +export function inputRequiredRowState( + entry: InputRequiredRowStateInput, +): 'input_required' | 'pending_input_resume' { + if (entry.state === 'pending_input_resume' || entry.state === 'input_required') { + return entry.state; + } + return (entry.context || '').startsWith(PENDING_RESUME_CONTEXT_PREFIX) + ? 'pending_input_resume' + : 'input_required'; +} + +export function inputRequiredFingerprintValue(entry: InputRequiredFingerprintInput): string { + return `${entry.identifier}:${inputRequiredRowState(entry)}`; +} From e10843b75fe7f430c18f4cf1d390fe72f45bb5ec Mon Sep 17 00:00:00 2001 From: Vladimir Novick Date: Thu, 16 Apr 2026 23:35:54 +0300 Subject: [PATCH 02/13] chore: add automation feature --- cmd/itervox/automations.go | 586 ++++++++++++++++++ cmd/itervox/automations_test.go | 455 ++++++++++++++ cmd/itervox/main.go | 383 ++++++++++-- cmd/itervox/main_test.go | 181 ++++++ docs/configuration.md | 77 +++ internal/agentactions/store.go | 87 +++ internal/config/agent_actions.go | 85 +++ internal/config/automations.go | 191 ++++++ internal/config/config.go | 31 +- internal/config/config_test.go | 150 +++++ internal/config/validate.go | 129 ++++ internal/config/validate_test.go | 144 +++++ internal/orchestrator/automation.go | 343 ++++++++++ internal/orchestrator/event_loop.go | 51 +- internal/orchestrator/event_loop_test.go | 48 ++ internal/orchestrator/integration_test.go | 61 ++ internal/orchestrator/orchestrator.go | 32 + internal/orchestrator/reviewer_test.go | 126 ++++ internal/orchestrator/state.go | 6 + internal/orchestrator/worker.go | 218 ++++++- internal/orchestrator/worker_test.go | 22 + internal/prompt/renderer.go | 10 + internal/schedule/cron.go | 168 +++++ internal/server/handlers.go | 249 +++++++- internal/server/server.go | 132 +++- internal/server/server_test.go | 375 ++++++++++- internal/tracker/github/client.go | 46 ++ internal/tracker/github/client_test.go | 28 + internal/tracker/linear/client.go | 98 +++ internal/tracker/linear/client_test.go | 58 ++ internal/tracker/linear/queries.go | 20 + internal/tracker/managed_comments.go | 21 + internal/tracker/memory.go | 60 +- internal/tracker/memory_test.go | 48 ++ internal/tracker/tracker.go | 4 + internal/workflow/loader.go | 202 ++++++ internal/workflow/loader_test.go | 122 +++- site/src/content/docs/configuration.mdx | 74 +++ site/src/content/docs/guides/automations.mdx | 361 +++++++++++ web/package.json | 1 + web/pnpm-lock.yaml | 34 +- web/src/App.tsx | 52 +- web/src/assets/logo.svg | 15 + web/src/components/brand/ItervoxLogo.tsx | 9 + web/src/components/itervox/AgentInfoModal.tsx | 50 +- web/src/components/itervox/TagInput.tsx | 127 +++- .../itervox/__tests__/AgentInfoModal.test.tsx | 26 + web/src/components/layout/NavIcons.tsx | 40 ++ web/src/components/layout/NavLink.tsx | 7 +- .../layout/__tests__/NavIcons.test.tsx | 28 + web/src/hooks/useSettingsActions.ts | 22 +- web/src/pages/Agents/index.tsx | 88 +++ web/src/pages/Automations/index.tsx | 49 ++ .../pages/Dashboard/components/HeroStats.tsx | 11 +- .../components/__tests__/HeroStats.test.tsx | 40 ++ web/src/pages/Dashboard/index.tsx | 24 +- web/src/pages/Settings/AutomationsCard.tsx | 225 +++++++ web/src/pages/Settings/ProfilesCard.tsx | 419 +++++-------- .../__tests__/AutomationsCard.test.tsx | 50 ++ .../__tests__/ProfileEditorFields.test.tsx | 66 ++ .../Settings/__tests__/ProfilesCard.test.tsx | 71 +++ .../__tests__/ScheduleEditorFields.test.tsx | 109 ++++ .../Settings/__tests__/automationForm.test.ts | 56 ++ .../__tests__/profileCommands.test.ts | 2 + .../automations/AutomationEditorFields.tsx | 550 ++++++++++++++++ .../automations/AutomationFormModal.tsx | 179 ++++++ .../Settings/automations/AutomationRow.tsx | 93 +++ .../automations/SuggestedAutomationCard.tsx | 52 ++ .../Settings/automations/automationForm.ts | 186 ++++++ .../automations/suggestedAutomations.ts | 77 +++ web/src/pages/Settings/formStyles.ts | 18 + web/src/pages/Settings/index.tsx | 93 +-- web/src/pages/Settings/profileCommands.ts | 48 +- .../profiles/MarkdownPromptEditor.tsx | 83 +++ .../Settings/profiles/ProfileEditorFields.tsx | 249 ++++++-- .../Settings/profiles/ProfileFormModal.tsx | 220 +++++++ .../pages/Settings/profiles/ProfileRow.tsx | 241 +++---- .../profiles/SuggestedProfileCard.tsx | 143 ++--- .../pages/Settings/profiles/profileForm.ts | 88 +++ .../Settings/profiles/suggestedProfiles.ts | 35 +- web/src/pages/Settings/useSettingsPageData.ts | 100 +++ web/src/types/schemas.ts | 49 ++ web/src/utils/constants.ts | 10 +- 83 files changed, 8772 insertions(+), 845 deletions(-) create mode 100644 cmd/itervox/automations.go create mode 100644 cmd/itervox/automations_test.go create mode 100644 internal/agentactions/store.go create mode 100644 internal/config/agent_actions.go create mode 100644 internal/config/automations.go create mode 100644 internal/orchestrator/automation.go create mode 100644 internal/orchestrator/worker_test.go create mode 100644 internal/schedule/cron.go create mode 100644 internal/tracker/managed_comments.go create mode 100644 site/src/content/docs/guides/automations.mdx create mode 100644 web/src/assets/logo.svg create mode 100644 web/src/components/brand/ItervoxLogo.tsx create mode 100644 web/src/components/layout/NavIcons.tsx create mode 100644 web/src/components/layout/__tests__/NavIcons.test.tsx create mode 100644 web/src/pages/Agents/index.tsx create mode 100644 web/src/pages/Automations/index.tsx create mode 100644 web/src/pages/Dashboard/components/__tests__/HeroStats.test.tsx create mode 100644 web/src/pages/Settings/AutomationsCard.tsx create mode 100644 web/src/pages/Settings/__tests__/AutomationsCard.test.tsx create mode 100644 web/src/pages/Settings/__tests__/ProfileEditorFields.test.tsx create mode 100644 web/src/pages/Settings/__tests__/ProfilesCard.test.tsx create mode 100644 web/src/pages/Settings/__tests__/ScheduleEditorFields.test.tsx create mode 100644 web/src/pages/Settings/__tests__/automationForm.test.ts create mode 100644 web/src/pages/Settings/automations/AutomationEditorFields.tsx create mode 100644 web/src/pages/Settings/automations/AutomationFormModal.tsx create mode 100644 web/src/pages/Settings/automations/AutomationRow.tsx create mode 100644 web/src/pages/Settings/automations/SuggestedAutomationCard.tsx create mode 100644 web/src/pages/Settings/automations/automationForm.ts create mode 100644 web/src/pages/Settings/automations/suggestedAutomations.ts create mode 100644 web/src/pages/Settings/formStyles.ts create mode 100644 web/src/pages/Settings/profiles/MarkdownPromptEditor.tsx create mode 100644 web/src/pages/Settings/profiles/ProfileFormModal.tsx create mode 100644 web/src/pages/Settings/profiles/profileForm.ts create mode 100644 web/src/pages/Settings/useSettingsPageData.ts diff --git a/cmd/itervox/automations.go b/cmd/itervox/automations.go new file mode 100644 index 0000000..ab0ce85 --- /dev/null +++ b/cmd/itervox/automations.go @@ -0,0 +1,586 @@ +package main + +import ( + "context" + "log/slog" + "regexp" + "slices" + "sort" + "strings" + "time" + + "github.com/vnovick/itervox/internal/config" + "github.com/vnovick/itervox/internal/domain" + "github.com/vnovick/itervox/internal/orchestrator" + "github.com/vnovick/itervox/internal/schedule" + "github.com/vnovick/itervox/internal/tracker" +) + +type compiledAutomation struct { + cfg config.AutomationConfig + expr schedule.Expression + location *time.Location + identifierRe *regexp.Regexp + inputContextRe *regexp.Regexp +} + +type observedAutomationIssue struct { + State string + InBacklog bool +} + +type observedAutomationComment struct { + LatestCommentID string + CommentCreatedAt string +} + +type automationPollState struct { + issues map[string]observedAutomationIssue + trackerComments map[string]map[string]observedAutomationComment +} + +type compiledAutomationSet struct { + cron []compiledAutomation + polledEvents []compiledAutomation + inputRequired []orchestrator.InputRequiredAutomation + runFailed []orchestrator.RunFailedAutomation +} + +func startAutomations(ctx context.Context, cfg *config.Config, tr tracker.Tracker, orch *orchestrator.Orchestrator) { + compiled := compileAutomations(cfg) + orch.SetInputRequiredAutomations(compiled.inputRequired) + orch.SetRunFailedAutomations(compiled.runFailed) + if len(compiled.cron) == 0 && len(compiled.polledEvents) == 0 { + return + } + + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + lastFired := make(map[string]string, len(compiled.cron)) + pollState := automationPollState{ + issues: make(map[string]observedAutomationIssue), + trackerComments: make(map[string]map[string]observedAutomationComment), + } + runOnce := func(now time.Time) { + for _, entry := range compiled.cron { + if !entry.cfg.Enabled { + continue + } + localNow := now.In(entry.location) + minuteKey := localNow.Format("2006-01-02T15:04") + if lastFired[entry.cfg.ID] == minuteKey { + continue + } + if !entry.expr.Matches(localNow) { + continue + } + lastFired[entry.cfg.ID] = minuteKey + execCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + runCronAutomation(execCtx, cfg, tr, orch, entry, now) + cancel() + } + if len(compiled.polledEvents) > 0 { + execCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + pollState = pollAutomationEvents(execCtx, cfg, tr, orch, compiled.polledEvents, pollState, now) + cancel() + } + } + + runOnce(time.Now()) + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + runOnce(now) + } + } + }() +} + +func compileAutomations(cfg *config.Config) compiledAutomationSet { + var compiled compiledAutomationSet + if len(cfg.Automations) == 0 { + return compiled + } + compiled.cron = make([]compiledAutomation, 0, len(cfg.Automations)) + compiled.polledEvents = make([]compiledAutomation, 0, len(cfg.Automations)) + compiled.inputRequired = make([]orchestrator.InputRequiredAutomation, 0, len(cfg.Automations)) + compiled.runFailed = make([]orchestrator.RunFailedAutomation, 0, len(cfg.Automations)) + for _, entry := range cfg.Automations { + if !entry.Enabled { + continue + } + profile, ok := cfg.Agent.Profiles[entry.Profile] + if !ok { + slog.Warn("automation: skipping rule with unknown profile", "automation", entry.ID, "profile", entry.Profile) + continue + } + if !config.ProfileEnabled(profile) { + slog.Warn("automation: skipping rule with disabled profile", "automation", entry.ID, "profile", entry.Profile) + continue + } + var identifierRe *regexp.Regexp + if entry.Filter.IdentifierRegex != "" { + re, err := regexp.Compile(entry.Filter.IdentifierRegex) + if err != nil { + slog.Warn("automation: invalid identifier regex", "automation", entry.ID, "regex", entry.Filter.IdentifierRegex, "error", err) + continue + } + identifierRe = re + } + switch entry.Trigger.Type { + case config.AutomationTriggerCron: + expr, err := schedule.Parse(entry.Trigger.Cron) + if err != nil { + slog.Warn("automation: invalid cron expression", "automation", entry.ID, "cron", entry.Trigger.Cron, "error", err) + continue + } + location := time.Local + if entry.Trigger.Timezone != "" { + loc, err := time.LoadLocation(entry.Trigger.Timezone) + if err != nil { + slog.Warn("automation: invalid timezone", "automation", entry.ID, "timezone", entry.Trigger.Timezone, "error", err) + continue + } + location = loc + } + compiled.cron = append(compiled.cron, compiledAutomation{ + cfg: entry, + expr: expr, + location: location, + identifierRe: identifierRe, + }) + case config.AutomationTriggerInputRequired: + var inputContextRe *regexp.Regexp + if entry.Filter.InputContextRegex != "" { + re, err := regexp.Compile(entry.Filter.InputContextRegex) + if err != nil { + slog.Warn("automation: invalid input context regex", "automation", entry.ID, "regex", entry.Filter.InputContextRegex, "error", err) + continue + } + inputContextRe = re + } + compiled.inputRequired = append(compiled.inputRequired, orchestrator.InputRequiredAutomation{ + ID: entry.ID, + ProfileName: entry.Profile, + Instructions: entry.Instructions, + MatchMode: entry.Filter.MatchMode, + States: entry.Filter.States, + LabelsAny: entry.Filter.LabelsAny, + IdentifierRegex: identifierRe, + InputContextRegex: inputContextRe, + AutoResume: entry.Policy.AutoResume, + }) + case config.AutomationTriggerTrackerComment, + config.AutomationTriggerIssueEnteredState, + config.AutomationTriggerIssueMovedBacklog: + compiled.polledEvents = append(compiled.polledEvents, compiledAutomation{ + cfg: entry, + identifierRe: identifierRe, + inputContextRe: inputContextReFor(entry), + }) + case config.AutomationTriggerRunFailed: + compiled.runFailed = append(compiled.runFailed, orchestrator.RunFailedAutomation{ + ID: entry.ID, + ProfileName: entry.Profile, + Instructions: entry.Instructions, + MatchMode: entry.Filter.MatchMode, + States: entry.Filter.States, + LabelsAny: entry.Filter.LabelsAny, + IdentifierRegex: identifierRe, + }) + default: + slog.Warn("automation: unsupported trigger type", "automation", entry.ID, "type", entry.Trigger.Type) + } + } + return compiled +} + +func runCronAutomation( + ctx context.Context, + cfg *config.Config, + tr tracker.Tracker, + orch *orchestrator.Orchestrator, + entry compiledAutomation, + now time.Time, +) { + states := cronAutomationFetchStates(cfg, entry) + issues, err := tr.FetchIssuesByStates(ctx, states) + if err != nil { + slog.Warn("automation: fetch issues failed", "automation", entry.cfg.ID, "error", err) + return + } + + snap := orch.Snapshot() + matches := make([]domain.Issue, 0, len(issues)) + for _, issue := range issues { + if shouldSkipAutomatedIssue(snap, issue) { + continue + } + if !matchesAutomationFilter(issue, entry, "") { + continue + } + matches = append(matches, issue) + } + sort.Slice(matches, func(i, j int) bool { + return matches[i].Identifier < matches[j].Identifier + }) + + limit := entry.cfg.Filter.Limit + if limit > 0 && len(matches) > limit { + matches = matches[:limit] + } + if len(matches) == 0 { + return + } + + count := 0 + for _, issue := range matches { + if orch.DispatchAutomation(issue, orchestrator.AutomationDispatch{ + AutomationID: entry.cfg.ID, + ProfileName: entry.cfg.Profile, + Instructions: entry.cfg.Instructions, + AutoResume: entry.cfg.Policy.AutoResume, + Trigger: orchestrator.AutomationTriggerContext{ + Type: config.AutomationTriggerCron, + FiredAt: now, + AutomationID: entry.cfg.ID, + Cron: entry.cfg.Trigger.Cron, + Timezone: entry.cfg.Trigger.Timezone, + CurrentState: issue.State, + }, + }) { + count++ + } + } + if count > 0 { + slog.Info("automation: queued issues", "automation", entry.cfg.ID, "count", count, "profile", entry.cfg.Profile) + } +} + +func shouldSkipAutomatedIssue(state orchestrator.State, issue domain.Issue) bool { + if _, claimed := state.Claimed[issue.ID]; claimed { + return true + } + if _, paused := state.PausedIdentifiers[issue.Identifier]; paused { + return true + } + if _, waiting := state.InputRequiredIssues[issue.Identifier]; waiting { + return true + } + if _, pending := state.PendingInputResumes[issue.Identifier]; pending { + return true + } + return false +} + +func matchesAutomationFilter(issue domain.Issue, entry compiledAutomation, inputContext string) bool { + checks := make([]bool, 0, 4) + if entry.identifierRe != nil { + checks = append(checks, entry.identifierRe.MatchString(issue.Identifier)) + } + if len(entry.cfg.Filter.States) > 0 { + checks = append(checks, containsFold(entry.cfg.Filter.States, issue.State)) + } + if len(entry.cfg.Filter.LabelsAny) > 0 { + issueLabels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + issueLabels = append(issueLabels, strings.ToLower(label)) + } + ok := false + for _, wanted := range entry.cfg.Filter.LabelsAny { + if slices.Contains(issueLabels, strings.ToLower(wanted)) { + ok = true + break + } + } + checks = append(checks, ok) + } + if entry.inputContextRe != nil { + checks = append(checks, entry.inputContextRe.MatchString(inputContext)) + } + if len(checks) == 0 { + return true + } + if entry.cfg.Filter.MatchMode == config.AutomationFilterMatchAny { + for _, check := range checks { + if check { + return true + } + } + return false + } + for _, check := range checks { + if !check { + return false + } + } + return true +} + +func inputContextReFor(entry config.AutomationConfig) *regexp.Regexp { + if entry.Filter.InputContextRegex == "" { + return nil + } + re, err := regexp.Compile(entry.Filter.InputContextRegex) + if err != nil { + slog.Warn("automation: invalid input context regex", "automation", entry.ID, "regex", entry.Filter.InputContextRegex, "error", err) + return nil + } + return re +} + +func cronAutomationFetchStates(cfg *config.Config, entry compiledAutomation) []string { + if entry.cfg.Filter.MatchMode != config.AutomationFilterMatchAny && len(entry.cfg.Filter.States) > 0 { + return append([]string{}, entry.cfg.Filter.States...) + } + return deduplicateStates(cfg.Tracker.BacklogStates, cfg.Tracker.ActiveStates, entry.cfg.Filter.States, "") +} + +func pollAutomationEvents( + ctx context.Context, + cfg *config.Config, + tr tracker.Tracker, + orch *orchestrator.Orchestrator, + entries []compiledAutomation, + prev automationPollState, + now time.Time, +) automationPollState { + states := automationPollStates(cfg, entries) + if len(states) == 0 { + return prev + } + issues, err := tr.FetchIssuesByStates(ctx, states) + if err != nil { + slog.Warn("automation: poll-event fetch failed", "error", err) + return prev + } + sort.Slice(issues, func(i, j int) bool { + return issues[i].Identifier < issues[j].Identifier + }) + + next := automationPollState{ + issues: make(map[string]observedAutomationIssue, len(issues)), + trackerComments: make(map[string]map[string]observedAutomationComment, len(prev.trackerComments)), + } + + snap := orch.Snapshot() + detailCache := make(map[string]*domain.Issue) + getDetail := func(issue domain.Issue) *domain.Issue { + if detailed, ok := detailCache[issue.ID]; ok { + return detailed + } + detailed, err := tr.FetchIssueDetail(ctx, issue.ID) + if err != nil { + slog.Warn("automation: fetch issue detail failed", "automation_issue", issue.Identifier, "error", err) + detailCache[issue.ID] = nil + return nil + } + detailCache[issue.ID] = detailed + return detailed + } + + for _, issue := range issues { + next.issues[issue.ID] = observedAutomationIssue{ + State: issue.State, + InBacklog: containsFold(cfg.Tracker.BacklogStates, issue.State), + } + } + for _, entry := range entries { + matches := make([]struct { + issue domain.Issue + trigger orchestrator.AutomationTriggerContext + }, 0) + var prevComments map[string]observedAutomationComment + var nextComments map[string]observedAutomationComment + if entry.cfg.Trigger.Type == config.AutomationTriggerTrackerComment { + prevComments = prev.trackerComments[entry.cfg.ID] + nextComments = make(map[string]observedAutomationComment) + next.trackerComments[entry.cfg.ID] = nextComments + } + for _, issue := range issues { + if shouldSkipAutomatedIssue(snap, issue) { + continue + } + prevIssue, seenBefore := prev.issues[issue.ID] + if !seenBefore { + continue + } + switch entry.cfg.Trigger.Type { + case config.AutomationTriggerIssueEnteredState: + if strings.EqualFold(prevIssue.State, issue.State) || !strings.EqualFold(issue.State, entry.cfg.Trigger.State) { + continue + } + if !matchesAutomationFilter(issue, entry, "") { + continue + } + matches = append(matches, struct { + issue domain.Issue + trigger orchestrator.AutomationTriggerContext + }{ + issue: issue, + trigger: orchestrator.AutomationTriggerContext{ + Type: config.AutomationTriggerIssueEnteredState, + FiredAt: now, + AutomationID: entry.cfg.ID, + TriggerState: entry.cfg.Trigger.State, + PreviousState: prevIssue.State, + CurrentState: issue.State, + }, + }) + case config.AutomationTriggerIssueMovedBacklog: + if prevIssue.InBacklog || !containsFold(cfg.Tracker.BacklogStates, issue.State) { + continue + } + if !matchesAutomationFilter(issue, entry, "") { + continue + } + matches = append(matches, struct { + issue domain.Issue + trigger orchestrator.AutomationTriggerContext + }{ + issue: issue, + trigger: orchestrator.AutomationTriggerContext{ + Type: config.AutomationTriggerIssueMovedBacklog, + FiredAt: now, + AutomationID: entry.cfg.ID, + PreviousState: prevIssue.State, + CurrentState: issue.State, + }, + }) + case config.AutomationTriggerTrackerComment: + if !matchesAutomationFilter(issue, entry, "") { + continue + } + detailed := getDetail(issue) + if detailed == nil { + continue + } + commentSnapshot := observedAutomationComment{} + comment, ok := latestAutomationComment(detailed.Comments) + if ok { + commentSnapshot = observedAutomationCommentFromComment(comment) + } + nextComments[issue.ID] = commentSnapshot + prevComment, seenBefore := prevComments[issue.ID] + if !seenBefore { + continue + } + if !hasNewAutomationComment(prevComment, commentSnapshot) { + continue + } + if !ok { + continue + } + if isAutomationManagedComment(comment) { + continue + } + trigger := orchestrator.AutomationTriggerContext{ + Type: config.AutomationTriggerTrackerComment, + FiredAt: now, + AutomationID: entry.cfg.ID, + CurrentState: issue.State, + CommentID: comment.ID, + CommentBody: comment.Body, + CommentAuthorID: comment.AuthorID, + CommentAuthorName: comment.AuthorName, + } + if comment.CreatedAt != nil { + trigger.CommentCreatedAt = comment.CreatedAt.Format(time.RFC3339) + } + matches = append(matches, struct { + issue domain.Issue + trigger orchestrator.AutomationTriggerContext + }{issue: issue, trigger: trigger}) + } + } + limit := entry.cfg.Filter.Limit + if limit > 0 && len(matches) > limit { + matches = matches[:limit] + } + for _, match := range matches { + _ = orch.DispatchAutomation(match.issue, orchestrator.AutomationDispatch{ + AutomationID: entry.cfg.ID, + ProfileName: entry.cfg.Profile, + Instructions: entry.cfg.Instructions, + Trigger: match.trigger, + }) + } + } + + return next +} + +func automationPollStates(cfg *config.Config, entries []compiledAutomation) []string { + states := deduplicateStates(cfg.Tracker.BacklogStates, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates, cfg.Tracker.CompletionState) + for _, entry := range entries { + states = append(states, entry.cfg.Filter.States...) + if entry.cfg.Trigger.State != "" { + states = append(states, entry.cfg.Trigger.State) + } + } + seen := make(map[string]struct{}, len(states)) + out := make([]string, 0, len(states)) + for _, state := range states { + if state == "" { + continue + } + if _, ok := seen[strings.ToLower(state)]; ok { + continue + } + seen[strings.ToLower(state)] = struct{}{} + out = append(out, state) + } + return out +} + +func latestAutomationComment(comments []domain.Comment) (domain.Comment, bool) { + if len(comments) == 0 { + return domain.Comment{}, false + } + return comments[len(comments)-1], true +} + +func observedAutomationCommentFromComment(comment domain.Comment) observedAutomationComment { + snapshot := observedAutomationComment{LatestCommentID: comment.ID} + if comment.CreatedAt != nil { + snapshot.CommentCreatedAt = comment.CreatedAt.Format(time.RFC3339) + } + return snapshot +} + +func hasNewAutomationComment(prev, current observedAutomationComment) bool { + if current.LatestCommentID == "" && current.CommentCreatedAt == "" { + return false + } + if prev.LatestCommentID == "" && prev.CommentCreatedAt == "" { + return true + } + if current.LatestCommentID != "" { + return !strings.EqualFold(prev.LatestCommentID, current.LatestCommentID) + } + return current.CommentCreatedAt != prev.CommentCreatedAt +} + +func markAutomationComment(body string) string { + return tracker.MarkManagedComment(body) +} + +func isAutomationManagedComment(comment domain.Comment) bool { + return tracker.IsManagedComment(comment) || + strings.HasPrefix(comment.Body, "🤖 **Agent needs your input**") || + strings.EqualFold(strings.TrimSpace(comment.AuthorName), "Itervox") +} + +func containsFold(values []string, target string) bool { + target = strings.ToLower(target) + for _, value := range values { + if strings.ToLower(value) == target { + return true + } + } + return false +} diff --git a/cmd/itervox/automations_test.go b/cmd/itervox/automations_test.go new file mode 100644 index 0000000..2ebbca7 --- /dev/null +++ b/cmd/itervox/automations_test.go @@ -0,0 +1,455 @@ +package main + +import ( + "context" + "fmt" + "regexp" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vnovick/itervox/internal/agent" + "github.com/vnovick/itervox/internal/agent/agenttest" + "github.com/vnovick/itervox/internal/config" + "github.com/vnovick/itervox/internal/domain" + "github.com/vnovick/itervox/internal/orchestrator" +) + +func TestCompileAutomations_SplitsCronAndInputRequired(t *testing.T) { + cfg := &config.Config{ + Agent: config.AgentConfig{ + Profiles: map[string]config.AgentProfile{ + "qa": {Command: "claude"}, + "input-responder": {Command: "claude"}, + "reviewer": {Command: "claude"}, + }, + }, + Automations: []config.AutomationConfig{ + { + ID: "qa-ready", + Enabled: true, + Profile: "qa", + Trigger: config.AutomationTriggerConfig{ + Type: "cron", + Cron: "0 */2 * * *", + Timezone: "UTC", + }, + }, + { + ID: "input-responder", + Enabled: true, + Profile: "input-responder", + Trigger: config.AutomationTriggerConfig{ + Type: "input_required", + }, + Filter: config.AutomationFilterConfig{ + InputContextRegex: "continue|branch", + }, + }, + { + ID: "state-entry", + Enabled: true, + Profile: "qa", + Trigger: config.AutomationTriggerConfig{ + Type: "issue_entered_state", + State: "Ready for QA", + }, + }, + { + ID: "comment-watch", + Enabled: true, + Profile: "reviewer", + Trigger: config.AutomationTriggerConfig{ + Type: "tracker_comment_added", + }, + }, + { + ID: "failed-run", + Enabled: true, + Profile: "reviewer", + Trigger: config.AutomationTriggerConfig{ + Type: "run_failed", + }, + }, + }, + } + + compiled := compileAutomations(cfg) + require.Len(t, compiled.cron, 1) + require.Len(t, compiled.inputRequired, 1) + require.Len(t, compiled.polledEvents, 2) + require.Len(t, compiled.runFailed, 1) + assert.Equal(t, "qa-ready", compiled.cron[0].cfg.ID) + assert.Equal(t, "input-responder", compiled.inputRequired[0].ID) + assert.Equal(t, "input-responder", compiled.inputRequired[0].ProfileName) +} + +func TestMatchesAutomationFilter_ChecksLabelsAndInputContext(t *testing.T) { + entry := compiledAutomation{ + cfg: config.AutomationConfig{ + ID: "input-responder", + Filter: config.AutomationFilterConfig{ + MatchMode: "all", + LabelsAny: []string{"qa", "triage"}, + InputContextRegex: "continue|branch", + }, + }, + inputContextRe: regexp.MustCompile("continue|branch"), + } + + issue := domain.Issue{ + Identifier: "ENG-42", + Labels: []string{"triage"}, + } + + assert.True(t, matchesAutomationFilter(issue, entry, "Continue with the existing branch")) + assert.False(t, matchesAutomationFilter(issue, entry, "Need approval for production migration")) + assert.False(t, matchesAutomationFilter(domain.Issue{Identifier: "ENG-42", Labels: []string{"docs"}}, entry, "Continue with the existing branch")) +} + +func TestMatchesAutomationFilter_AnyMatchMode(t *testing.T) { + entry := compiledAutomation{ + cfg: config.AutomationConfig{ + ID: "comment-watch", + Filter: config.AutomationFilterConfig{ + MatchMode: "any", + LabelsAny: []string{"triage"}, + IdentifierRegex: "^ENG-42$", + }, + }, + identifierRe: regexp.MustCompile("^ENG-42$"), + } + + assert.True(t, matchesAutomationFilter(domain.Issue{Identifier: "ENG-42"}, entry, "")) + assert.True(t, matchesAutomationFilter(domain.Issue{Identifier: "ENG-99", Labels: []string{"triage"}}, entry, "")) + assert.False(t, matchesAutomationFilter(domain.Issue{Identifier: "ENG-99", Labels: []string{"docs"}}, entry, "")) +} + +func TestCronAutomationFetchStates_IncludesExplicitStatesInAnyMode(t *testing.T) { + cfg := &config.Config{ + Tracker: config.TrackerConfig{ + BacklogStates: []string{"Backlog"}, + ActiveStates: []string{"Todo", "In Progress"}, + }, + } + entry := compiledAutomation{ + cfg: config.AutomationConfig{ + Filter: config.AutomationFilterConfig{ + MatchMode: config.AutomationFilterMatchAny, + States: []string{"Needs Clarification", "Ready for QA"}, + }, + }, + } + + states := cronAutomationFetchStates(cfg, entry) + + assert.ElementsMatch(t, []string{"Backlog", "Todo", "In Progress", "Needs Clarification", "Ready for QA"}, states) +} + +func TestAutomationPollStates_IncludesFilterStates(t *testing.T) { + cfg := &config.Config{ + Tracker: config.TrackerConfig{ + BacklogStates: []string{"Backlog"}, + ActiveStates: []string{"Todo", "In Progress"}, + TerminalStates: []string{"Done"}, + CompletionState: "Done", + }, + } + entries := []compiledAutomation{ + { + cfg: config.AutomationConfig{ + ID: "comment-watch", + Trigger: config.AutomationTriggerConfig{ + Type: config.AutomationTriggerTrackerComment, + }, + Filter: config.AutomationFilterConfig{ + MatchMode: config.AutomationFilterMatchAny, + States: []string{"Needs Clarification"}, + }, + }, + }, + { + cfg: config.AutomationConfig{ + ID: "state-entry", + Trigger: config.AutomationTriggerConfig{ + Type: config.AutomationTriggerIssueEnteredState, + State: "Ready for QA", + }, + Filter: config.AutomationFilterConfig{ + States: []string{"Ready for QA", "Needs Clarification"}, + }, + }, + }, + } + + states := automationPollStates(cfg, entries) + + assert.ElementsMatch(t, []string{"Backlog", "Todo", "In Progress", "Done", "Needs Clarification", "Ready for QA"}, states) +} + +func TestAutomationManagedCommentMarkers(t *testing.T) { + body := "QA failed. Moving back to Todo." + marked := markAutomationComment(body) + + assert.Contains(t, marked, body) + assert.True(t, isAutomationManagedComment(domain.Comment{Body: marked})) + assert.True(t, isAutomationManagedComment(domain.Comment{AuthorName: "Itervox"})) + assert.False(t, isAutomationManagedComment(domain.Comment{Body: body, AuthorName: "alice"})) +} + +type pollTracker struct { + mu sync.Mutex + poll int + issuesByRun [][]domain.Issue + detailByRun []map[string]domain.Issue +} + +func (t *pollTracker) FetchCandidateIssues(context.Context) ([]domain.Issue, error) { + return nil, nil +} + +func (t *pollTracker) FetchIssuesByStates(context.Context, []string) ([]domain.Issue, error) { + t.mu.Lock() + defer t.mu.Unlock() + if len(t.issuesByRun) == 0 { + return nil, nil + } + index := t.poll + if index >= len(t.issuesByRun) { + index = len(t.issuesByRun) - 1 + } + t.poll++ + return append([]domain.Issue(nil), t.issuesByRun[index]...), nil +} + +func (t *pollTracker) FetchIssueStatesByIDs(context.Context, []string) ([]domain.Issue, error) { + return nil, nil +} + +func (t *pollTracker) CreateComment(context.Context, string, string) (*domain.Comment, error) { + return nil, nil +} + +func (t *pollTracker) CreateIssue(context.Context, string, string, string, string) (*domain.Issue, error) { + return nil, nil +} + +func (t *pollTracker) UpdateIssueState(context.Context, string, string) error { + return nil +} + +func (t *pollTracker) FetchIssueDetail(_ context.Context, issueID string) (*domain.Issue, error) { + t.mu.Lock() + defer t.mu.Unlock() + if len(t.detailByRun) == 0 { + return nil, fmt.Errorf("issue %s not found", issueID) + } + index := t.poll - 1 + if index < 0 { + index = 0 + } + if index >= len(t.detailByRun) { + index = len(t.detailByRun) - 1 + } + issue, ok := t.detailByRun[index][issueID] + if !ok { + return nil, fmt.Errorf("issue %s not found", issueID) + } + cp := issue + return &cp, nil +} + +func (t *pollTracker) FetchIssueByIdentifier(_ context.Context, identifier string) (*domain.Issue, error) { + t.mu.Lock() + defer t.mu.Unlock() + for _, batch := range t.issuesByRun { + for _, issue := range batch { + if issue.Identifier == identifier { + cp := issue + return &cp, nil + } + } + } + return nil, fmt.Errorf("issue %s not found", identifier) +} + +func (t *pollTracker) SetIssueBranch(context.Context, string, string) error { + return nil +} + +type doneRunner struct { + agent.Runner + done chan struct{} +} + +func (r *doneRunner) RunTurn(ctx context.Context, log agent.Logger, onProgress func(agent.TurnResult), sessionID *string, prompt, workspacePath, command, workerHost, logDir string, readTimeoutMs, turnTimeoutMs int) (agent.TurnResult, error) { + res, err := r.Runner.RunTurn(ctx, log, onProgress, sessionID, prompt, workspacePath, command, workerHost, logDir, readTimeoutMs, turnTimeoutMs) + select { + case r.done <- struct{}{}: + default: + } + return res, err +} + +func TestPollAutomationEvents_TrackerCommentRequiresNewEligibleComment(t *testing.T) { + cfg := &config.Config{ + Polling: config.PollingConfig{IntervalMs: 50}, + Tracker: config.TrackerConfig{ + BacklogStates: []string{"Backlog"}, + ActiveStates: []string{"Todo"}, + TerminalStates: []string{"Done"}, + CompletionState: "Done", + }, + Agent: config.AgentConfig{ + Command: "claude", + MaxConcurrentAgents: 1, + Profiles: map[string]config.AgentProfile{ + "pm": {Command: "claude"}, + }, + TurnTimeoutMs: 60000, + ReadTimeoutMs: 30000, + }, + } + entries := []compiledAutomation{ + { + cfg: config.AutomationConfig{ + ID: "comment-watch", + Enabled: true, + Profile: "pm", + Trigger: config.AutomationTriggerConfig{ + Type: config.AutomationTriggerTrackerComment, + }, + Filter: config.AutomationFilterConfig{ + LabelsAny: []string{"triage"}, + }, + }, + }, + } + + comment1 := domain.Comment{ID: "c1", Body: "old comment", AuthorName: "alice"} + comment2 := domain.Comment{ID: "c2", Body: "new comment", AuthorName: "alice"} + comment2Edited := domain.Comment{ID: "c2", Body: "edited new comment", AuthorName: "alice"} + issueNoMatch := domain.Issue{ID: "id-1", Identifier: "ENG-1", Title: "Triage me", State: "Todo", Labels: []string{"docs"}} + issueMatch := domain.Issue{ID: "id-1", Identifier: "ENG-1", Title: "Triage me", State: "Todo", Labels: []string{"triage"}} + + tr := &pollTracker{ + issuesByRun: [][]domain.Issue{ + {issueNoMatch}, + {issueMatch}, + {issueMatch}, + {issueMatch}, + }, + detailByRun: []map[string]domain.Issue{ + {"id-1": {ID: "id-1", Identifier: "ENG-1", Title: "Triage me", State: "Todo", Labels: []string{"docs"}, Comments: []domain.Comment{comment1}}}, + {"id-1": {ID: "id-1", Identifier: "ENG-1", Title: "Triage me", State: "Todo", Labels: []string{"triage"}, Comments: []domain.Comment{comment1}}}, + {"id-1": {ID: "id-1", Identifier: "ENG-1", Title: "Triage me", State: "Todo", Labels: []string{"triage"}, Comments: []domain.Comment{comment2}}}, + {"id-1": {ID: "id-1", Identifier: "ENG-1", Title: "Triage me", State: "Todo", Labels: []string{"triage"}, Comments: []domain.Comment{comment2Edited}}}, + }, + } + + runner := &doneRunner{ + Runner: agenttest.NewFakeRunner([]agent.StreamEvent{ + {Type: "system", SessionID: "s1"}, + {Type: "result", SessionID: "s1"}, + }), + done: make(chan struct{}, 4), + } + orch := orchestrator.New(cfg, tr, runner, nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go orch.Run(ctx) //nolint:errcheck + time.Sleep(20 * time.Millisecond) + + state := automationPollState{issues: make(map[string]observedAutomationIssue)} + state = pollAutomationEvents(ctx, cfg, tr, orch, entries, state, time.Now()) + state = pollAutomationEvents(ctx, cfg, tr, orch, entries, state, time.Now().Add(time.Minute)) + + select { + case <-runner.done: + t.Fatal("stale comment should not dispatch when issue only became newly eligible") + default: + } + + state = pollAutomationEvents(ctx, cfg, tr, orch, entries, state, time.Now().Add(2*time.Minute)) + require.Eventually(t, func() bool { + return len(orch.RunHistory()) == 1 + }, 3*time.Second, 25*time.Millisecond, "expected new comment id to dispatch automation") + + pollAutomationEvents(ctx, cfg, tr, orch, entries, state, time.Now().Add(3*time.Minute)) + time.Sleep(250 * time.Millisecond) + assert.Len(t, orch.RunHistory(), 1, "editing the latest comment body with the same id should not dispatch again") +} + +func TestPollAutomationEvents_DropsIssueSnapshotsWhenIssueIsAbsent(t *testing.T) { + entries := []compiledAutomation{ + { + cfg: config.AutomationConfig{ + ID: "qa-ready", + Enabled: true, + Profile: "qa", + Trigger: config.AutomationTriggerConfig{ + Type: config.AutomationTriggerIssueEnteredState, + State: "Ready for QA", + }, + }, + }, + } + cfg := &config.Config{ + Tracker: config.TrackerConfig{ + ActiveStates: []string{"Todo"}, + TerminalStates: []string{"Done"}, + CompletionState: "Done", + }, + Agent: config.AgentConfig{ + Profiles: map[string]config.AgentProfile{ + "qa": {Command: "claude"}, + }, + }, + Automations: []config.AutomationConfig{ + entries[0].cfg, + }, + } + todoIssue := domain.Issue{ID: "id-1", Identifier: "ENG-1", Title: "QA me", State: "Todo"} + readyIssue := domain.Issue{ID: "id-1", Identifier: "ENG-1", Title: "QA me", State: "Ready for QA"} + + tr := &pollTracker{ + issuesByRun: [][]domain.Issue{ + {todoIssue}, + {}, + {readyIssue}, + {todoIssue}, + {readyIssue}, + }, + detailByRun: []map[string]domain.Issue{ + {"id-1": todoIssue}, + {}, + {"id-1": readyIssue}, + {"id-1": todoIssue}, + {"id-1": readyIssue}, + }, + } + + runner := &doneRunner{ + Runner: agenttest.NewFakeRunner([]agent.StreamEvent{ + {Type: "system", SessionID: "s1"}, + {Type: "result", SessionID: "s1"}, + }), + done: make(chan struct{}, 4), + } + orch := orchestrator.New(cfg, tr, runner, nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go orch.Run(ctx) //nolint:errcheck + time.Sleep(20 * time.Millisecond) + + state := automationPollState{issues: make(map[string]observedAutomationIssue)} + state = pollAutomationEvents(ctx, cfg, tr, orch, entries, state, time.Now()) + require.Contains(t, state.issues, "id-1") + + state = pollAutomationEvents(ctx, cfg, tr, orch, entries, state, time.Now().Add(time.Minute)) + assert.Empty(t, state.issues, "issues absent from the current poll should not keep stale snapshots") +} diff --git a/cmd/itervox/main.go b/cmd/itervox/main.go index 5b9ee18..297d1dd 100644 --- a/cmd/itervox/main.go +++ b/cmd/itervox/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -12,6 +13,7 @@ import ( "log/slog" "net" "net/http" + "net/url" "os" "os/exec" "os/signal" @@ -25,6 +27,7 @@ import ( "github.com/joho/godotenv" "github.com/vnovick/itervox/internal/agent" "github.com/vnovick/itervox/internal/agent/agenttest" + "github.com/vnovick/itervox/internal/agentactions" "github.com/vnovick/itervox/internal/app" "github.com/vnovick/itervox/internal/config" "github.com/vnovick/itervox/internal/domain" @@ -274,6 +277,9 @@ func main() { case "clear": runClear(os.Args[2:]) return + case "action": + runAction(os.Args[2:]) + return case "--version", "-version": fmt.Printf("itervox %s (commit: %s, built: %s)\n", version, commit, date) return @@ -444,23 +450,6 @@ func run(ctx context.Context, cfg *config.Config, workflowPath string, logFile s return fmt.Errorf("build tracker: %w", err) } - cfg.Agent.Command = resolveAgentCommand(cfg.Agent.Command) - for name, profile := range cfg.Agent.Profiles { - if profile.Command != "" { - // Extract the binary name (first token) and resolve it, keeping flags. - parts := strings.SplitN(profile.Command, " ", 2) - resolved := resolveAgentCommand(parts[0]) - if resolved != parts[0] { - if len(parts) > 1 { - profile.Command = resolved + " " + parts[1] - } else { - profile.Command = resolved - } - cfg.Agent.Profiles[name] = profile - } - } - } - var runner agent.Runner if demoMode { runner = agenttest.NewDemoRunner(5 * time.Second) @@ -471,6 +460,7 @@ func run(ctx context.Context, cfg *config.Config, workflowPath string, logFile s "codex": agent.NewCodexRunner(), }, ) + runner = commandResolverRunner{inner: runner} } // Validate CLI availability for the default agent command and all profiles. @@ -528,6 +518,7 @@ func run(ctx context.Context, cfg *config.Config, workflowPath string, logFile s var srvDone <-chan error var srvListener net.Listener var actualAddr string + var actionTokenStore *agentactions.Store if cfg.Server.Port != nil { var err error srvListener, actualAddr, err = listenWithFallback(cfg.Server.Host, *cfg.Server.Port, 10) @@ -568,6 +559,11 @@ func run(ctx context.Context, cfg *config.Config, workflowPath string, logFile s "url", fmt.Sprintf("http://%s/?token=%s", actualAddr, tok)) } } + if actualAddr != "" { + actionTokenStore = agentactions.NewStore() + orch.SetAgentActionBaseURL(agentActionBaseURL(actualAddr)) + orch.SetAgentActionTokens(actionTokenStore) + } // Redirect slog to file-only before the TUI takes the alt-screen. // Without this, concurrent slog writes to stderr corrupt the bubbletea display. @@ -610,13 +606,14 @@ func run(ctx context.Context, cfg *config.Config, workflowPath string, logFile s workflowPath: workflowPath, } srv := server.New(server.Config{ - Snapshot: snap, - RefreshChan: refreshChan, - LogFile: logFile, - Client: adapter, - FetchIssue: fetchIssue, - ProjectManager: pm, - APIToken: os.Getenv("ITERVOX_API_TOKEN"), + Snapshot: snap, + RefreshChan: refreshChan, + LogFile: logFile, + Client: adapter, + FetchIssue: fetchIssue, + ProjectManager: pm, + APIToken: os.Getenv("ITERVOX_API_TOKEN"), + ActionTokenStore: actionTokenStore, }) adapter.notify = srv.Notify if err := srv.Validate(); err != nil { @@ -639,6 +636,8 @@ func run(ctx context.Context, cfg *config.Config, workflowPath string, logFile s } }() + startAutomations(ctx, cfg, tr, orch) + orchDone := make(chan error, 1) go func() { orchDone <- orch.Run(ctx) }() @@ -798,8 +797,10 @@ func buildSnapFunc(orch *orchestrator.Orchestrator, tr tracker.Tracker, cfg *con activeStates, terminalStates, completionState := orch.TrackerStatesCfg() var availableProfiles []string - for name := range profiles { - availableProfiles = append(availableProfiles, name) + for name, profile := range profiles { + if config.ProfileEnabled(profile) { + availableProfiles = append(availableProfiles, name) + } } sort.Strings(availableProfiles) @@ -807,7 +808,14 @@ func buildSnapFunc(orch *orchestrator.Orchestrator, tr tracker.Tracker, cfg *con if len(profiles) > 0 { profileDefs = make(map[string]server.ProfileDef, len(profiles)) for n, p := range profiles { - profileDefs[n] = server.ProfileDef{Command: p.Command, Prompt: p.Prompt, Backend: p.Backend} + profileDefs[n] = server.ProfileDef{ + Command: p.Command, + Prompt: p.Prompt, + Backend: p.Backend, + Enabled: config.ProfileEnabled(p), + AllowedActions: config.NormalizeAllowedActions(p.AllowedActions), + CreateIssueState: p.CreateIssueState, + } } } @@ -869,6 +877,7 @@ func buildSnapFunc(orch *orchestrator.Orchestrator, tr tracker.Tracker, cfg *con DispatchStrategy: orch.DispatchStrategyCfg(), DefaultBackend: configuredBackend(cfg.Agent.Command, cfg.Agent.Backend), InlineInput: orch.InlineInputCfg(), + Automations: automationDefsFromConfig(cfg.Automations), AvailableModels: convertModelsForSnapshot(cfg.Agent.AvailableModels), ReviewerProfile: func() string { p, _ := orch.ReviewerCfg(); return p }(), AutoReview: func() bool { _, a := orch.ReviewerCfg(); return a }(), @@ -1050,11 +1059,80 @@ func profilesToEntries(profiles map[string]config.AgentProfile) map[string]workf Command: p.Command, Prompt: p.Prompt, Backend: p.Backend, + Enabled: func() *bool { + enabled := config.ProfileEnabled(p) + if enabled { + return nil + } + return &enabled + }(), + AllowedActions: config.NormalizeAllowedActions(p.AllowedActions), + CreateIssueState: p.CreateIssueState, } } return entries } +func automationsToEntries(automations []server.AutomationDef) []workflow.AutomationEntry { + entries := make([]workflow.AutomationEntry, 0, len(automations)) + for _, automation := range automations { + entries = append(entries, workflow.AutomationEntry{ + ID: automation.ID, + Enabled: automation.Enabled, + Profile: automation.Profile, + Instructions: automation.Instructions, + Trigger: workflow.AutomationTriggerEntry{ + Type: automation.Trigger.Type, + Cron: automation.Trigger.Cron, + Timezone: automation.Trigger.Timezone, + State: automation.Trigger.State, + }, + Filter: workflow.AutomationFilterEntry{ + MatchMode: automation.Filter.MatchMode, + States: automation.Filter.States, + LabelsAny: automation.Filter.LabelsAny, + IdentifierRegex: automation.Filter.IdentifierRegex, + Limit: automation.Filter.Limit, + InputContextRegex: automation.Filter.InputContextRegex, + }, + Policy: workflow.AutomationPolicyEntry{ + AutoResume: automation.Policy.AutoResume, + }, + }) + } + return entries +} + +func automationDefsFromConfig(automations []config.AutomationConfig) []server.AutomationDef { + defs := make([]server.AutomationDef, 0, len(automations)) + for _, automation := range automations { + defs = append(defs, server.AutomationDef{ + ID: automation.ID, + Enabled: automation.Enabled, + Profile: automation.Profile, + Instructions: automation.Instructions, + Trigger: server.AutomationTriggerDef{ + Type: automation.Trigger.Type, + Cron: automation.Trigger.Cron, + Timezone: automation.Trigger.Timezone, + State: automation.Trigger.State, + }, + Filter: server.AutomationFilterDef{ + MatchMode: automation.Filter.MatchMode, + States: automation.Filter.States, + LabelsAny: automation.Filter.LabelsAny, + IdentifierRegex: automation.Filter.IdentifierRegex, + Limit: automation.Filter.Limit, + InputContextRegex: automation.Filter.InputContextRegex, + }, + Policy: server.AutomationPolicyDef{ + AutoResume: automation.Policy.AutoResume, + }, + }) + } + return defs +} + // orchestratorAdapter implements server.OrchestratorClient using the live // orchestrator, log buffer, tracker, and WORKFLOW.md persistence helpers. // notify must be set after server construction (adapter.notify = srv.Notify). @@ -1204,19 +1282,41 @@ func (a *orchestratorAdapter) DispatchReviewer(identifier string) error { return a.orch.DispatchReviewer(identifier) } +func (a *orchestratorAdapter) CommentOnIssue(ctx context.Context, identifier, body string) error { + issue, err := a.tr.FetchIssueByIdentifier(ctx, identifier) + if err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + if issue == nil { + return fmt.Errorf("issue %s not found", identifier) + } + _, err = a.tr.CreateComment(ctx, issue.ID, tracker.MarkManagedComment(body)) + return err +} + +func (a *orchestratorAdapter) CreateIssue( + ctx context.Context, + identifier, title, body, stateName string, +) (*domain.Issue, error) { + issue, err := a.tr.FetchIssueByIdentifier(ctx, identifier) + if err != nil { + return nil, fmt.Errorf("fetch issue: %w", err) + } + if issue == nil { + return nil, fmt.Errorf("issue %s not found", identifier) + } + return a.tr.CreateIssue(ctx, issue.ID, title, body, stateName) +} + func (a *orchestratorAdapter) UpdateIssueState(ctx context.Context, identifier, stateName string) error { - active, terminal, completion := a.orch.TrackerStatesCfg() - allStates := deduplicateStates(a.cfg.Tracker.BacklogStates, active, terminal, completion) - issues, err := a.tr.FetchIssuesByStates(ctx, allStates) + issue, err := a.tr.FetchIssueByIdentifier(ctx, identifier) if err != nil { - return fmt.Errorf("fetch issues: %w", err) + return fmt.Errorf("fetch issue: %w", err) } - for _, iss := range issues { - if iss.Identifier == identifier { - return a.tr.UpdateIssueState(ctx, iss.ID, stateName) - } + if issue == nil { + return fmt.Errorf("issue %s not found", identifier) } - return fmt.Errorf("issue %s not found", identifier) + return a.tr.UpdateIssueState(ctx, issue.ID, stateName) } // deduplicateStates concatenates backlog, active, terminal states and the @@ -1264,7 +1364,14 @@ func (a *orchestratorAdapter) ProfileDefs() map[string]server.ProfileDef { profiles := a.orch.ProfilesCfg() defs := make(map[string]server.ProfileDef, len(profiles)) for name, p := range profiles { - defs[name] = server.ProfileDef{Command: p.Command, Prompt: p.Prompt, Backend: p.Backend} + defs[name] = server.ProfileDef{ + Command: p.Command, + Prompt: p.Prompt, + Backend: p.Backend, + Enabled: config.ProfileEnabled(p), + AllowedActions: config.NormalizeAllowedActions(p.AllowedActions), + CreateIssueState: p.CreateIssueState, + } } return defs } @@ -1277,6 +1384,13 @@ func (a *orchestratorAdapter) SetReviewerConfig(profile string, autoReview bool) if err := a.orch.SetReviewerCfg(profile, autoReview); err != nil { return err } + if err := workflow.PatchAgentStringField(a.workflowPath, "reviewer_profile", profile); err != nil { + return err + } + if err := workflow.PatchAgentBoolField(a.workflowPath, "auto_review", autoReview); err != nil { + return err + } + a.notify() return nil } @@ -1293,26 +1407,27 @@ func (a *orchestratorAdapter) AvailableModels() map[string][]server.ModelOption return result } -func (a *orchestratorAdapter) UpsertProfile(name string, def server.ProfileDef) error { +func (a *orchestratorAdapter) UpsertProfile(name string, def server.ProfileDef, originalName string) error { profiles := a.orch.ProfilesCfg() if profiles == nil { profiles = make(map[string]config.AgentProfile) } - // Resolve the command binary (e.g. alias → absolute path) so dispatch works - // in non-interactive shell contexts. - cmd := def.Command - if cmd != "" { - parts := strings.SplitN(cmd, " ", 2) - resolved := resolveAgentCommand(parts[0]) - if resolved != parts[0] { - if len(parts) > 1 { - cmd = resolved + " " + parts[1] - } else { - cmd = resolved - } + if originalName != "" && originalName != name { + if _, exists := profiles[name]; exists { + return fmt.Errorf("profile %q already exists", name) } + delete(profiles, originalName) + } else if _, exists := profiles[name]; exists && originalName == "" { + return fmt.Errorf("profile %q already exists", name) + } + profiles[name] = config.AgentProfile{ + Command: strings.TrimSpace(def.Command), + Prompt: def.Prompt, + Backend: def.Backend, + Enabled: func() *bool { enabled := def.Enabled; return &enabled }(), + AllowedActions: config.NormalizeAllowedActions(def.AllowedActions), + CreateIssueState: strings.TrimSpace(def.CreateIssueState), } - profiles[name] = config.AgentProfile{Command: cmd, Prompt: def.Prompt, Backend: def.Backend} a.orch.SetProfilesCfg(profiles) if err := workflow.PatchProfilesBlock(a.workflowPath, profilesToEntries(profiles)); err != nil { return err @@ -1332,6 +1447,14 @@ func (a *orchestratorAdapter) DeleteProfile(name string) error { return nil } +func (a *orchestratorAdapter) SetAutomations(automations []server.AutomationDef) error { + if err := workflow.PatchAutomationsBlock(a.workflowPath, automationsToEntries(automations)); err != nil { + return err + } + a.notify() + return nil +} + func (a *orchestratorAdapter) SetAgentMode(mode string) error { a.orch.SetAgentModeCfg(mode) if err := workflow.PatchAgentStringField(a.workflowPath, "agent_mode", mode); err != nil { @@ -1418,6 +1541,45 @@ func (a *orchestratorAdapter) SetInlineInput(enabled bool) error { return nil } +type commandResolverRunner struct { + inner agent.Runner + resolve func(string) string +} + +func (r commandResolverRunner) RunTurn( + ctx context.Context, + log agent.Logger, + onProgress func(agent.TurnResult), + sessionID *string, + prompt, workspacePath, command, workerHost, logDir string, + readTimeoutMs, turnTimeoutMs int, +) (agent.TurnResult, error) { + resolver := r.resolve + if resolver == nil { + resolver = resolveAgentCommand + } + if workerHost == "" { + command = resolveCommandLine(command, resolver) + } + return r.inner.RunTurn(ctx, log, onProgress, sessionID, prompt, workspacePath, command, workerHost, logDir, readTimeoutMs, turnTimeoutMs) +} + +func resolveCommandLine(command string, resolver func(string) string) string { + command = strings.TrimSpace(command) + if command == "" { + return "" + } + parts := strings.SplitN(command, " ", 2) + resolved := resolver(parts[0]) + if resolved == parts[0] { + return command + } + if len(parts) == 1 { + return resolved + } + return resolved + " " + parts[1] +} + func resolveAgentCommand(command string) string { if filepath.IsAbs(command) { return command @@ -1593,6 +1755,113 @@ func runClear(args []string) { } } +func runAction(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "itervox action: expected subcommand: comment | create-issue | move-state | provide-input") + os.Exit(1) + } + + daemonURL := strings.TrimRight(os.Getenv("ITERVOX_DAEMON_URL"), "/") + token := os.Getenv("ITERVOX_ACTION_TOKEN") + identifier := os.Getenv("ITERVOX_ISSUE_IDENTIFIER") + if daemonURL == "" || token == "" || identifier == "" { + fmt.Fprintln(os.Stderr, "itervox action: missing worker action environment; this command only works inside an active itervox worker") + os.Exit(2) + } + + var endpoint string + var body any + + switch args[0] { + case "comment": + fs := flag.NewFlagSet("action comment", flag.ExitOnError) + commentBody := fs.String("body", "", "tracker comment body") + _ = fs.Parse(args[1:]) + if strings.TrimSpace(*commentBody) == "" { + fmt.Fprintln(os.Stderr, "itervox action comment: --body is required") + os.Exit(2) + } + endpoint = "/api/v1/agent-actions/" + url.PathEscape(identifier) + "/comment" + body = map[string]string{"body": *commentBody} + case "create-issue": + fs := flag.NewFlagSet("action create-issue", flag.ExitOnError) + title := fs.String("title", "", "title for the follow-up issue") + issueBody := fs.String("body", "", "body/description for the follow-up issue") + _ = fs.Parse(args[1:]) + if strings.TrimSpace(*title) == "" { + fmt.Fprintln(os.Stderr, "itervox action create-issue: --title is required") + os.Exit(2) + } + if strings.TrimSpace(os.Getenv("ITERVOX_CREATE_ISSUE_STATE")) == "" { + fmt.Fprintln(os.Stderr, "itervox action create-issue: create_issue_state is not configured for this profile") + os.Exit(2) + } + endpoint = "/api/v1/agent-actions/" + url.PathEscape(identifier) + "/create-issue" + body = map[string]string{"title": *title, "body": *issueBody} + case "move-state": + fs := flag.NewFlagSet("action move-state", flag.ExitOnError) + state := fs.String("state", "", "target tracker state") + _ = fs.Parse(args[1:]) + if strings.TrimSpace(*state) == "" { + fmt.Fprintln(os.Stderr, "itervox action move-state: --state is required") + os.Exit(2) + } + endpoint = "/api/v1/agent-actions/" + url.PathEscape(identifier) + "/move-state" + body = map[string]string{"state": *state} + case "provide-input": + fs := flag.NewFlagSet("action provide-input", flag.ExitOnError) + message := fs.String("message", "", "input message to resume the blocked run") + _ = fs.Parse(args[1:]) + if strings.TrimSpace(*message) == "" { + fmt.Fprintln(os.Stderr, "itervox action provide-input: --message is required") + os.Exit(2) + } + endpoint = "/api/v1/agent-actions/" + url.PathEscape(identifier) + "/provide-input" + body = map[string]string{"message": *message} + default: + fmt.Fprintf(os.Stderr, "itervox action: unknown subcommand %q\n", args[0]) + os.Exit(1) + } + + if err := invokeAgentAction(daemonURL+endpoint, token, body); err != nil { + fmt.Fprintf(os.Stderr, "itervox action: %v\n", err) + os.Exit(1) + } + fmt.Println("ok") +} + +func invokeAgentAction(endpoint, token string, body any) error { + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + msg := strings.TrimSpace(string(bodyBytes)) + if msg == "" { + msg = resp.Status + } + return fmt.Errorf("%s: %s", resp.Status, msg) + } + return nil +} + // repoInfo holds values discovered by scanning the current directory. type repoInfo struct { RemoteURL string // raw git remote URL @@ -2184,6 +2453,18 @@ func runInit(args []string) { } } +func agentActionBaseURL(addr string) string { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "http://" + addr + } + switch host { + case "", "0.0.0.0", "::": + host = "127.0.0.1" + } + return "http://" + net.JoinHostPort(host, port) +} + // listenWithFallback tries to listen on the given host:port. If the port is // already in use, it tries up to maxPortRetries successive ports. Returns the // listener and the actual address it bound to. diff --git a/cmd/itervox/main_test.go b/cmd/itervox/main_test.go index 7c3ddf8..1e8c6a7 100644 --- a/cmd/itervox/main_test.go +++ b/cmd/itervox/main_test.go @@ -13,12 +13,26 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vnovick/itervox/internal/agent" + "github.com/vnovick/itervox/internal/agent/agenttest" "github.com/vnovick/itervox/internal/config" + "github.com/vnovick/itervox/internal/domain" + "github.com/vnovick/itervox/internal/logbuffer" "github.com/vnovick/itervox/internal/orchestrator" "github.com/vnovick/itervox/internal/server" "github.com/vnovick/itervox/internal/tracker" ) +type captureRunner struct { + command string + workerHost string +} + +func (c *captureRunner) RunTurn(_ context.Context, _ agent.Logger, _ func(agent.TurnResult), _ *string, _, _, command, workerHost, _ string, _, _ int) (agent.TurnResult, error) { + c.command = command + c.workerHost = workerHost + return agent.TurnResult{}, nil +} + func TestLoadDotEnv_LoadsItervoxDotEnv(t *testing.T) { dir := t.TempDir() itervoxDir := filepath.Join(dir, ".itervox") @@ -123,6 +137,33 @@ func TestConfiguredBackend(t *testing.T) { } } +func TestResolveCommandLineResolvesBinaryPreservingArgs(t *testing.T) { + resolved := resolveCommandLine("claude --model sonnet", func(command string) string { + if command == "claude" { + return "/usr/local/bin/claude" + } + return command + }) + + assert.Equal(t, "/usr/local/bin/claude --model sonnet", resolved) +} + +func TestCommandResolverRunnerSkipsResolutionForSSHWorkers(t *testing.T) { + inner := &captureRunner{} + runner := commandResolverRunner{ + inner: inner, + resolve: func(command string) string { + return "/resolved/" + command + }, + } + + _, err := runner.RunTurn(context.Background(), nil, nil, nil, "prompt", ".", "claude --model sonnet", "ssh://host", "", 0, 0) + + require.NoError(t, err) + assert.Equal(t, "claude --model sonnet", inner.command) + assert.Equal(t, "ssh://host", inner.workerHost) +} + // ─── buildDemoConfig ────────────────────────────────────────────────────────── func TestBuildDemoConfig_HasRequiredFields(t *testing.T) { @@ -260,6 +301,146 @@ func TestDemoMode_SnapshotBuilder(t *testing.T) { } } +func TestOrchestratorAdapterUpdateIssueState_FindsIssueOutsideConfiguredStates(t *testing.T) { + cfg := &config.Config{ + Tracker: config.TrackerConfig{ + BacklogStates: []string{"Backlog"}, + ActiveStates: []string{"Todo", "In Progress"}, + TerminalStates: []string{"Done"}, + CompletionState: "Done", + }, + Agent: config.AgentConfig{}, + } + issue := tracker.GenerateDemoIssues(1)[0] + issue.State = "Ready for QA" + mt := tracker.NewMemoryTracker([]domain.Issue{issue}, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates) + orch := orchestrator.New(cfg, mt, &agenttest.FakeRunner{}, nil) + adapter := &orchestratorAdapter{ + orch: orch, + logBuf: logbuffer.New(), + cfg: cfg, + tr: mt, + } + + err := adapter.UpdateIssueState(context.Background(), issue.Identifier, "Done") + + require.NoError(t, err) + fetched, fetchErr := mt.FetchIssueByIdentifier(context.Background(), issue.Identifier) + require.NoError(t, fetchErr) + require.NotNil(t, fetched) + assert.Equal(t, "Done", fetched.State) +} + +func TestOrchestratorAdapterUpsertProfile_RejectsRenameCollision(t *testing.T) { + cfg := &config.Config{ + Tracker: config.TrackerConfig{ + ActiveStates: []string{"Todo"}, + TerminalStates: []string{"Done"}, + }, + Agent: config.AgentConfig{ + Profiles: map[string]config.AgentProfile{ + "qa": {Command: "claude"}, + "pm": {Command: "codex"}, + }, + }, + } + mt := tracker.NewMemoryTracker(nil, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates) + orch := orchestrator.New(cfg, mt, &agenttest.FakeRunner{}, nil) + adapter := &orchestratorAdapter{ + orch: orch, + cfg: cfg, + tr: mt, + } + + err := adapter.UpsertProfile("pm", server.ProfileDef{Command: "claude"}, "qa") + + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + profiles := orch.ProfilesCfg() + assert.Equal(t, "claude", profiles["qa"].Command) + assert.Equal(t, "codex", profiles["pm"].Command) +} + +func TestOrchestratorAdapterUpsertProfilePreservesRawCommand(t *testing.T) { + dir := t.TempDir() + workflowPath := filepath.Join(dir, "WORKFLOW.md") + content := `--- +tracker: + kind: linear + api_key: key + project_slug: proj +agent: + command: claude +--- + +Prompt. +` + require.NoError(t, os.WriteFile(workflowPath, []byte(content), 0o644)) + + cfg, err := config.Load(workflowPath) + require.NoError(t, err) + mt := tracker.NewMemoryTracker(nil, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates) + orch := orchestrator.New(cfg, mt, &agenttest.FakeRunner{}, nil) + adapter := &orchestratorAdapter{ + orch: orch, + cfg: cfg, + tr: mt, + workflowPath: workflowPath, + notify: func() {}, + } + + err = adapter.UpsertProfile("qa", server.ProfileDef{Command: "claude --model sonnet"}, "") + + require.NoError(t, err) + profiles := orch.ProfilesCfg() + assert.Equal(t, "claude --model sonnet", profiles["qa"].Command) + + updated, readErr := os.ReadFile(workflowPath) + require.NoError(t, readErr) + assert.Contains(t, string(updated), "command: claude --model sonnet") + assert.NotContains(t, string(updated), "/Users/") +} + +func TestOrchestratorAdapterSetReviewerConfig_PersistsWorkflow(t *testing.T) { + dir := t.TempDir() + workflowPath := filepath.Join(dir, "WORKFLOW.md") + content := `--- +tracker: + kind: linear + api_key: key + project_slug: proj +agent: + command: claude + profiles: + reviewer: + command: claude +--- + +Prompt. +` + require.NoError(t, os.WriteFile(workflowPath, []byte(content), 0o644)) + + cfg, err := config.Load(workflowPath) + require.NoError(t, err) + mt := tracker.NewMemoryTracker(nil, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates) + orch := orchestrator.New(cfg, mt, &agenttest.FakeRunner{}, nil) + adapter := &orchestratorAdapter{ + orch: orch, + cfg: cfg, + tr: mt, + workflowPath: workflowPath, + notify: func() {}, + } + + err = adapter.SetReviewerConfig("reviewer", true) + + require.NoError(t, err) + updated, readErr := os.ReadFile(workflowPath) + require.NoError(t, readErr) + assert.Contains(t, string(updated), `reviewer_profile: "reviewer"`) + assert.Contains(t, string(updated), "auto_review: true") +} + // ─── server.ModelOption conversion ──────────────────────────────────────────── func TestServerModelOption_JSONRoundTrip(t *testing.T) { diff --git a/docs/configuration.md b/docs/configuration.md index 1184ff1..5145e62 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,6 +98,9 @@ silently dropped at load time. Commands must not contain shell metacharacters | `command` | CLI command for this profile (required) | | `backend` | Explicit backend override (`claude` or `codex`); inferred from `command` when absent | | `prompt` | Role description appended to the rendered template when `agent_mode: teams` | +| `enabled` | Optional boolean. Disabled profiles stay in config but are hidden from normal selection and dispatch. | +| `allowed_actions` | Optional list of daemon-backed actions: `comment`, `create_issue`, `move_state`, `provide_input`. | +| `create_issue_state` | Required when `allowed_actions` includes `create_issue`; the tracker state/column for follow-up issues. | ```yaml agent: @@ -112,14 +115,88 @@ agent: code-reviewer: command: claude --model claude-opus-4-6 prompt: "You are a senior code reviewer. Focus on correctness and test coverage." + allowed_actions: [comment, move_state] codex-research: command: run-codex-wrapper --json backend: codex prompt: "You are a long-horizon investigation agent." + input-responder: + command: claude --model claude-sonnet-4-6 + enabled: true + allowed_actions: [provide_input] + qa: + command: claude --model claude-sonnet-4-6 + allowed_actions: [comment, create_issue, move_state] + create_issue_state: Todo ``` --- +## `automations` + +Automations dispatch a selected profile when a trigger fires, then add a small +instruction overlay on top of that profile. + +Supported triggers: + +- `cron` +- `input_required` +- `tracker_comment_added` +- `issue_entered_state` +- `issue_moved_to_backlog` +- `run_failed` + +| Field | Type | Description | +|---|---|---| +| `id` | string | Stable automation identifier | +| `enabled` | bool | Whether the automation is active | +| `profile` | string | Name of the agent profile to dispatch | +| `instructions` | string | Markdown/Liquid instruction overlay appended after the selected profile prompt | +| `trigger.type` | string | Trigger type | +| `trigger.cron` | string | Five-field cron expression for `cron` triggers | +| `trigger.timezone` | string | Optional timezone for `cron` triggers | +| `trigger.state` | string | Required for `issue_entered_state`; the state that must be entered | +| `filter.match_mode` | string | How populated filters combine: `all` or `any` | +| `filter.states` | []string | Issue-state filter. For cron automations, leave empty to use backlog and active states | +| `filter.labels_any` | []string | Match issues with at least one listed label | +| `filter.identifier_regex` | string | Regex matched against issue identifiers like `ENG-42` | +| `filter.limit` | int | Maximum issues to queue from one cron tick or event poll batch | +| `filter.input_context_regex` | string | Only for `input_required`; matched against the blocked-agent question text | +| `policy.auto_resume` | bool | Only for `input_required`; allows the helper to resume the blocked run via `provide_input` | + +```yaml +automations: + - id: qa-ready + enabled: true + trigger: + type: issue_entered_state + state: "Ready for QA" + profile: qa + instructions: | + Run the QA routine for this issue. + Comment the results. + If any required check fails, move the issue to Todo. + + - id: pm-backlog-review + enabled: true + trigger: + type: cron + cron: "0 9 * * 1-5" + timezone: "Asia/Jerusalem" + profile: pm + instructions: | + Review backlog issues for missing clarity and acceptance criteria. + Leave one concise comment summarising what is unclear. + filter: + states: ["Backlog"] + limit: 20 +``` + +For a more detailed guide, including trigger semantics, filter behavior, prompt +variables, and worked examples, see `site/src/content/docs/guides/automations.mdx`. + +--- + ## `workspace` | Field | Type | Default | Description | diff --git a/internal/agentactions/store.go b/internal/agentactions/store.go new file mode 100644 index 0000000..1ea83cf --- /dev/null +++ b/internal/agentactions/store.go @@ -0,0 +1,87 @@ +package agentactions + +import ( + "crypto/rand" + "encoding/hex" + "slices" + "sync" + "time" +) + +type Grant struct { + IssueIdentifier string + RunSessionID string + AllowedActions []string + CreateIssueState string + ExpiresAt time.Time +} + +type Store struct { + mu sync.Mutex + grants map[string]Grant +} + +func NewStore() *Store { + return &Store{ + grants: make(map[string]Grant), + } +} + +func (s *Store) Issue(issueIdentifier, runSessionID string, allowedActions []string, createIssueState string, ttl time.Duration) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return "", err + } + token := hex.EncodeToString(tokenBytes) + expiresAt := time.Now().Add(ttl) + if ttl <= 0 { + expiresAt = time.Now().Add(time.Hour) + } + + actions := slices.Clone(allowedActions) + slices.Sort(actions) + s.grants[token] = Grant{ + IssueIdentifier: issueIdentifier, + RunSessionID: runSessionID, + AllowedActions: actions, + CreateIssueState: createIssueState, + ExpiresAt: expiresAt, + } + return token, nil +} + +func (s *Store) Revoke(token string) { + if s == nil || token == "" { + return + } + s.mu.Lock() + delete(s.grants, token) + s.mu.Unlock() +} + +func (s *Store) Validate(token, issueIdentifier, action string, now time.Time) (Grant, string, bool) { + if s == nil || token == "" { + return Grant{}, "missing_token", false + } + s.mu.Lock() + defer s.mu.Unlock() + + grant, ok := s.grants[token] + if !ok { + return Grant{}, "unknown_token", false + } + if now.After(grant.ExpiresAt) { + delete(s.grants, token) + return Grant{}, "expired_token", false + } + if grant.IssueIdentifier != issueIdentifier { + return Grant{}, "issue_mismatch", false + } + if !slices.Contains(grant.AllowedActions, action) { + return Grant{}, "action_not_allowed", false + } + return grant, "", true +} diff --git a/internal/config/agent_actions.go b/internal/config/agent_actions.go new file mode 100644 index 0000000..61b15fc --- /dev/null +++ b/internal/config/agent_actions.go @@ -0,0 +1,85 @@ +package config + +import ( + "slices" + "strings" +) + +const ( + AgentActionComment = "comment" + AgentActionCreateIssue = "create_issue" + AgentActionMoveState = "move_state" + AgentActionProvideInput = "provide_input" +) + +var supportedAgentActions = []string{ + AgentActionComment, + AgentActionCreateIssue, + AgentActionMoveState, + AgentActionProvideInput, +} + +var supportedAgentActionSet = map[string]struct{}{ + AgentActionComment: {}, + AgentActionCreateIssue: {}, + AgentActionMoveState: {}, + AgentActionProvideInput: {}, +} + +func SupportedAgentActions() []string { + return slices.Clone(supportedAgentActions) +} + +func InvalidAgentActions(actions []string) []string { + if len(actions) == 0 { + return nil + } + seen := make(map[string]struct{}, len(actions)) + invalid := make([]string, 0, len(actions)) + for _, action := range actions { + normalized := strings.TrimSpace(strings.ToLower(action)) + if normalized == "" { + continue + } + if _, ok := supportedAgentActionSet[normalized]; ok { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + invalid = append(invalid, normalized) + } + if len(invalid) == 0 { + return nil + } + slices.Sort(invalid) + return invalid +} + +func NormalizeAllowedActions(actions []string) []string { + if len(actions) == 0 { + return nil + } + seen := make(map[string]struct{}, len(actions)) + normalized := make([]string, 0, len(actions)) + for _, action := range actions { + value := strings.TrimSpace(strings.ToLower(action)) + if value == "" { + continue + } + if _, ok := supportedAgentActionSet[value]; !ok { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + if len(normalized) == 0 { + return nil + } + slices.Sort(normalized) + return normalized +} diff --git a/internal/config/automations.go b/internal/config/automations.go new file mode 100644 index 0000000..73c013f --- /dev/null +++ b/internal/config/automations.go @@ -0,0 +1,191 @@ +package config + +const ( + AutomationTriggerCron = "cron" + AutomationTriggerInputRequired = "input_required" + AutomationTriggerTrackerComment = "tracker_comment_added" + AutomationTriggerIssueEnteredState = "issue_entered_state" + AutomationTriggerIssueMovedBacklog = "issue_moved_to_backlog" + AutomationTriggerRunFailed = "run_failed" + AutomationFilterMatchAll = "all" + AutomationFilterMatchAny = "any" +) + +type AutomationTriggerConfig struct { + Type string + Cron string + Timezone string + State string +} + +type AutomationFilterConfig struct { + MatchMode string + States []string + LabelsAny []string + IdentifierRegex string + Limit int + InputContextRegex string +} + +type AutomationPolicyConfig struct { + AutoResume bool +} + +type AutomationConfig struct { + ID string + Enabled bool + Profile string + Instructions string + Trigger AutomationTriggerConfig + Filter AutomationFilterConfig + Policy AutomationPolicyConfig +} + +type ScheduleFilterConfig struct { + States []string + LabelsAny []string + IdentifierRegex string + Limit int +} + +type ScheduleConfig struct { + ID string + Enabled bool + Cron string + Timezone string + Profile string + Filter ScheduleFilterConfig +} + +func parseAutomations(raw any) []AutomationConfig { + items, ok := raw.([]any) + if !ok || len(items) == 0 { + return nil + } + automations := make([]AutomationConfig, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + id := strField(m, "id", "") + profile := strField(m, "profile", "") + trigger := nestedMap(m, "trigger") + triggerType := strField(trigger, "type", "") + if id == "" || profile == "" || triggerType == "" { + continue + } + filter := nestedMap(m, "filter") + policy := nestedMap(m, "policy") + limit := intField(filter, "limit", 0) + if limit < 0 { + limit = 0 + } + automations = append(automations, AutomationConfig{ + ID: id, + Enabled: boolField(m, "enabled", true), + Profile: profile, + Instructions: strField(m, "instructions", ""), + Trigger: AutomationTriggerConfig{ + Type: triggerType, + Cron: strField(trigger, "cron", ""), + Timezone: strField(trigger, "timezone", ""), + State: strField(trigger, "state", ""), + }, + Filter: AutomationFilterConfig{ + MatchMode: normalizeAutomationMatchMode(strField(filter, "match_mode", "")), + States: strSliceField(filter, "states", nil), + LabelsAny: strSliceField(filter, "labels_any", nil), + IdentifierRegex: strField(filter, "identifier_regex", ""), + Limit: limit, + InputContextRegex: strField(filter, "input_context_regex", ""), + }, + Policy: AutomationPolicyConfig{ + AutoResume: boolField(policy, "auto_resume", false), + }, + }) + } + if len(automations) == 0 { + return nil + } + return automations +} + +func normalizeAutomationMatchMode(value string) string { + switch value { + case AutomationFilterMatchAny: + return AutomationFilterMatchAny + case "", AutomationFilterMatchAll: + return AutomationFilterMatchAll + default: + return AutomationFilterMatchAll + } +} + +func parseSchedules(raw any) []ScheduleConfig { + items, ok := raw.([]any) + if !ok || len(items) == 0 { + return nil + } + schedules := make([]ScheduleConfig, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + id := strField(m, "id", "") + cronExpr := strField(m, "cron", "") + profile := strField(m, "profile", "") + if id == "" || cronExpr == "" || profile == "" { + continue + } + filter := nestedMap(m, "filter") + limit := intField(filter, "limit", 0) + if limit < 0 { + limit = 0 + } + schedules = append(schedules, ScheduleConfig{ + ID: id, + Enabled: boolField(m, "enabled", true), + Cron: cronExpr, + Timezone: strField(m, "timezone", ""), + Profile: profile, + Filter: ScheduleFilterConfig{ + States: strSliceField(filter, "states", nil), + LabelsAny: strSliceField(filter, "labels_any", nil), + IdentifierRegex: strField(filter, "identifier_regex", ""), + Limit: limit, + }, + }) + } + if len(schedules) == 0 { + return nil + } + return schedules +} + +func legacySchedulesToAutomations(schedules []ScheduleConfig) []AutomationConfig { + if len(schedules) == 0 { + return nil + } + automations := make([]AutomationConfig, 0, len(schedules)) + for _, schedule := range schedules { + automations = append(automations, AutomationConfig{ + ID: schedule.ID, + Enabled: schedule.Enabled, + Profile: schedule.Profile, + Trigger: AutomationTriggerConfig{ + Type: AutomationTriggerCron, + Cron: schedule.Cron, + Timezone: schedule.Timezone, + }, + Filter: AutomationFilterConfig{ + States: schedule.Filter.States, + LabelsAny: schedule.Filter.LabelsAny, + IdentifierRegex: schedule.Filter.IdentifierRegex, + Limit: schedule.Filter.Limit, + }, + }) + } + return automations +} diff --git a/internal/config/config.go b/internal/config/config.go index b5b2f0d..de60e36 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -88,6 +88,15 @@ type AgentProfile struct { // Backend optionally overrides runner selection when it cannot be inferred // from the command binary alone (for example, a wrapper script around codex). Backend string + // Enabled controls whether the profile is selectable and dispatchable. + // Nil means true for backward compatibility with older tests/config literals. + Enabled *bool + // AllowedActions grants the profile access to daemon-backed actions such as + // tracker comments or provide-input. Empty = no extra actions. + AllowedActions []string + // CreateIssueState is the tracker state/column used when the create_issue + // action is allowed for this profile. + CreateIssueState string } // AgentConfig holds agent runner settings. @@ -213,6 +222,7 @@ type Config struct { Agent AgentConfig Hooks HooksConfig Server ServerConfig + Automations []AutomationConfig PromptTemplate string } @@ -322,6 +332,10 @@ func fromWorkflow(wf *workflow.Workflow) *Config { } } cfg.Server.AllowUnauthenticatedLAN = boolField(srv, "allow_unauthenticated_lan", false) + cfg.Automations = parseAutomations(raw["automations"]) + if len(cfg.Automations) == 0 { + cfg.Automations = legacySchedulesToAutomations(parseSchedules(raw["schedules"])) + } return cfg } @@ -408,9 +422,12 @@ func parseAgentProfiles(raw map[string]any) map[string]AgentProfile { continue } profiles[name] = AgentProfile{ - Command: cmd, - Prompt: strField(m, "prompt", ""), - Backend: strField(m, "backend", ""), + Command: cmd, + Prompt: strField(m, "prompt", ""), + Backend: strField(m, "backend", ""), + Enabled: boolPtr(boolField(m, "enabled", true)), + AllowedActions: NormalizeAllowedActions(strSliceField(m, "allowed_actions", nil)), + CreateIssueState: strField(m, "create_issue_state", ""), } } if len(profiles) == 0 { @@ -419,6 +436,14 @@ func parseAgentProfiles(raw map[string]any) map[string]AgentProfile { return profiles } +func boolPtr(v bool) *bool { + return &v +} + +func ProfileEnabled(profile AgentProfile) bool { + return profile.Enabled == nil || *profile.Enabled +} + // parseAvailableModels parses the agent.available_models YAML field. // Expected format: // diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6994792..5276770 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -175,6 +175,56 @@ func TestAgentProfileBackendField(t *testing.T) { assert.Equal(t, "", cfg.Agent.Profiles["inferred"].Backend) } +func TestAgentProfileAllowedActionsField(t *testing.T) { + content := minimal(`agent: + profiles: + responder: + command: claude --model claude-sonnet-4-6 + allowed_actions: + - comment + - provide_input +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + require.NotNil(t, cfg.Agent.Profiles) + assert.Equal(t, []string{"comment", "provide_input"}, cfg.Agent.Profiles["responder"].AllowedActions) +} + +func TestAgentProfileCreateIssueStateField(t *testing.T) { + content := minimal(`agent: + profiles: + triage: + command: claude --model claude-sonnet-4-6 + allowed_actions: + - create_issue + create_issue_state: Todo +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + require.NotNil(t, cfg.Agent.Profiles) + assert.Equal(t, []string{"create_issue"}, cfg.Agent.Profiles["triage"].AllowedActions) + assert.Equal(t, "Todo", cfg.Agent.Profiles["triage"].CreateIssueState) +} + +func TestAgentProfileEnabledField(t *testing.T) { + content := minimal(`agent: + profiles: + active: + command: claude --model claude-sonnet-4-6 + paused: + command: codex --model gpt-5.3-codex + enabled: false +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + require.NotNil(t, cfg.Agent.Profiles) + assert.True(t, config.ProfileEnabled(cfg.Agent.Profiles["active"])) + assert.False(t, config.ProfileEnabled(cfg.Agent.Profiles["paused"])) +} + func TestAgentBackendField(t *testing.T) { content := minimal(`agent: command: run-codex-wrapper @@ -219,3 +269,103 @@ func TestWorkspaceCloneURLDefault(t *testing.T) { assert.Equal(t, "", cfg.Workspace.CloneURL) assert.Equal(t, "main", cfg.Workspace.BaseBranch) } + +func TestAutomationsParsed(t *testing.T) { + content := minimal(`automations: + - id: backlog-review + enabled: true + profile: reviewer + instructions: "Review backlog issues and comment with missing details." + trigger: + type: cron + cron: "0 9 * * 1" + timezone: "Asia/Jerusalem" + filter: + match_mode: any + states: ["Backlog","Todo"] + labels_any: ["bug"] + identifier_regex: "^ENG-" + limit: 2 + - id: moved-to-backlog + enabled: true + profile: pm + instructions: "Review why the issue returned to backlog." + trigger: + type: issue_moved_to_backlog + - id: qa-state-entry + enabled: true + profile: qa + instructions: "Run QA when the issue enters Ready for QA." + trigger: + type: issue_entered_state + state: "Ready for QA" + - id: comment-triage + enabled: true + profile: reviewer + instructions: "React to new tracker comments." + trigger: + type: tracker_comment_added + - id: failed-run + enabled: true + profile: reviewer + instructions: "Summarise the failed run and suggest next action." + trigger: + type: run_failed + - id: input-responder + enabled: true + profile: input-responder + instructions: "Answer low-risk blocked-run questions." + trigger: + type: input_required + filter: + input_context_regex: "continue|branch" + policy: + auto_resume: true +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + require.Len(t, cfg.Automations, 6) + assert.Equal(t, "backlog-review", cfg.Automations[0].ID) + assert.Equal(t, "cron", cfg.Automations[0].Trigger.Type) + assert.Equal(t, "0 9 * * 1", cfg.Automations[0].Trigger.Cron) + assert.Equal(t, "Asia/Jerusalem", cfg.Automations[0].Trigger.Timezone) + assert.Equal(t, "reviewer", cfg.Automations[0].Profile) + assert.Equal(t, "any", cfg.Automations[0].Filter.MatchMode) + assert.Equal(t, []string{"Backlog", "Todo"}, cfg.Automations[0].Filter.States) + assert.Equal(t, []string{"bug"}, cfg.Automations[0].Filter.LabelsAny) + assert.Equal(t, "^ENG-", cfg.Automations[0].Filter.IdentifierRegex) + assert.Equal(t, 2, cfg.Automations[0].Filter.Limit) + assert.Equal(t, "issue_moved_to_backlog", cfg.Automations[1].Trigger.Type) + assert.Equal(t, "issue_entered_state", cfg.Automations[2].Trigger.Type) + assert.Equal(t, "Ready for QA", cfg.Automations[2].Trigger.State) + assert.Equal(t, "tracker_comment_added", cfg.Automations[3].Trigger.Type) + assert.Equal(t, "run_failed", cfg.Automations[4].Trigger.Type) + assert.Equal(t, "input_required", cfg.Automations[5].Trigger.Type) + assert.Equal(t, "continue|branch", cfg.Automations[5].Filter.InputContextRegex) + assert.True(t, cfg.Automations[5].Policy.AutoResume) +} + +func TestLegacySchedulesParsedAsCronAutomations(t *testing.T) { + content := minimal(`schedules: + - id: weekday-review + enabled: true + cron: "0 9 * * 1-5" + timezone: "UTC" + profile: reviewer + filter: + states: ["Backlog"] + labels_any: ["triage"] +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + require.Len(t, cfg.Automations, 1) + assert.Equal(t, "weekday-review", cfg.Automations[0].ID) + assert.Equal(t, "cron", cfg.Automations[0].Trigger.Type) + assert.Equal(t, "0 9 * * 1-5", cfg.Automations[0].Trigger.Cron) + assert.Equal(t, "UTC", cfg.Automations[0].Trigger.Timezone) + assert.Equal(t, "reviewer", cfg.Automations[0].Profile) + assert.Equal(t, []string{"Backlog"}, cfg.Automations[0].Filter.States) + assert.Equal(t, []string{"triage"}, cfg.Automations[0].Filter.LabelsAny) +} diff --git a/internal/config/validate.go b/internal/config/validate.go index bb32281..9426990 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -3,9 +3,12 @@ package config import ( "errors" "fmt" + "regexp" "strings" + "time" "github.com/osteele/liquid" + "github.com/vnovick/itervox/internal/schedule" ) var supportedTrackerKinds = map[string]bool{ @@ -21,6 +24,14 @@ var ErrAutoClearAutoReviewConflict = errors.New("workspace.auto_clear and agent. // enabled without a configured reviewer profile. var ErrAutoReviewRequiresReviewerProfile = errors.New("agent.auto_review requires agent.reviewer_profile to be set") +// ErrReviewerProfileNotFound reports that a configured reviewer profile does +// not exist in agent.profiles. +var ErrReviewerProfileNotFound = errors.New("agent.reviewer_profile must reference an existing profile") + +// ErrReviewerProfileDisabled reports that a configured reviewer profile exists +// but is disabled. +var ErrReviewerProfileDisabled = errors.New("agent.reviewer_profile must reference an enabled profile") + // ValidateReviewerAutoReview rejects configurations where auto-review was // enabled without a reviewer profile to dispatch. func ValidateReviewerAutoReview(reviewerProfile string, autoReview bool) error { @@ -39,6 +50,21 @@ func ValidateAutoClearAutoReview(autoClear bool, reviewerProfile string, autoRev return nil } +func ValidateReviewerProfile(profiles map[string]AgentProfile, reviewerProfile string) error { + reviewerProfile = strings.TrimSpace(reviewerProfile) + if reviewerProfile == "" { + return nil + } + profile, ok := profiles[reviewerProfile] + if !ok { + return fmt.Errorf("%w: %q", ErrReviewerProfileNotFound, reviewerProfile) + } + if !ProfileEnabled(profile) { + return fmt.Errorf("%w: %q", ErrReviewerProfileDisabled, reviewerProfile) + } + return nil +} + // ValidateDispatch runs the spec §6.3 dispatch preflight checks against an // already-loaded Config. Call Load first; this function does not re-read the file. func ValidateDispatch(cfg *Config) error { @@ -93,10 +119,19 @@ func ValidateDispatch(cfg *Config) error { name, profile.Command, shellMetachars) } } + if err := ValidateAgentProfiles(cfg.Agent.Profiles); err != nil { + return err + } + if err := ValidateAutomations(cfg.Automations, cfg.Agent.Profiles); err != nil { + return err + } if err := ValidateReviewerAutoReview(cfg.Agent.ReviewerProfile, cfg.Agent.AutoReview); err != nil { return err } + if err := ValidateReviewerProfile(cfg.Agent.Profiles, cfg.Agent.ReviewerProfile); err != nil { + return err + } if err := ValidateAutoClearAutoReview( cfg.Workspace.AutoClearWorkspace, cfg.Agent.ReviewerProfile, @@ -107,3 +142,97 @@ func ValidateDispatch(cfg *Config) error { return nil } + +func ValidateAgentProfiles(profiles map[string]AgentProfile) error { + for name, profile := range profiles { + actions := NormalizeAllowedActions(profile.AllowedActions) + if containsString(actions, AgentActionCreateIssue) && strings.TrimSpace(profile.CreateIssueState) == "" { + return fmt.Errorf("invalid profile %q: create_issue_state is required when create_issue is enabled", name) + } + } + return nil +} + +func ValidateAutomations(automations []AutomationConfig, profiles map[string]AgentProfile) error { + if len(automations) == 0 { + return nil + } + seenIDs := make(map[string]struct{}, len(automations)) + for _, entry := range automations { + id := strings.TrimSpace(entry.ID) + if id == "" || strings.TrimSpace(entry.Profile) == "" || strings.TrimSpace(entry.Trigger.Type) == "" { + return fmt.Errorf("each automation requires id, trigger.type, and profile") + } + key := strings.ToLower(id) + if _, exists := seenIDs[key]; exists { + return fmt.Errorf("duplicate automation id %q", id) + } + seenIDs[key] = struct{}{} + } + for _, entry := range automations { + id := strings.TrimSpace(entry.ID) + profileName := strings.TrimSpace(entry.Profile) + triggerType := strings.TrimSpace(entry.Trigger.Type) + + profile, ok := profiles[profileName] + if !ok { + return fmt.Errorf("automation %q references unknown profile %q", id, profileName) + } + if !ProfileEnabled(profile) { + return fmt.Errorf("automation %q references disabled profile %q", id, profileName) + } + + switch triggerType { + case AutomationTriggerCron: + if strings.TrimSpace(entry.Trigger.Cron) == "" { + return fmt.Errorf("automation %q: cron automations require trigger.cron", id) + } + if _, err := schedule.Parse(entry.Trigger.Cron); err != nil { + return fmt.Errorf("automation %q invalid cron: %w", id, err) + } + if entry.Trigger.Timezone != "" { + if _, err := time.LoadLocation(entry.Trigger.Timezone); err != nil { + return fmt.Errorf("automation %q invalid timezone: %w", id, err) + } + } + case AutomationTriggerInputRequired: + case AutomationTriggerTrackerComment: + case AutomationTriggerIssueMovedBacklog: + case AutomationTriggerRunFailed: + case AutomationTriggerIssueEnteredState: + if strings.TrimSpace(entry.Trigger.State) == "" { + return fmt.Errorf("automation %q: issue_entered_state automations require trigger.state", id) + } + default: + return fmt.Errorf("automation %q has unsupported trigger type %q", id, triggerType) + } + if entry.Filter.MatchMode != "" && + entry.Filter.MatchMode != AutomationFilterMatchAll && + entry.Filter.MatchMode != AutomationFilterMatchAny { + return fmt.Errorf("automation %q filter.match_mode must be %q or %q", id, AutomationFilterMatchAll, AutomationFilterMatchAny) + } + if entry.Filter.Limit < 0 { + return fmt.Errorf("automation %q filter.limit must be >= 0", id) + } + if entry.Filter.IdentifierRegex != "" { + if _, err := regexp.Compile(entry.Filter.IdentifierRegex); err != nil { + return fmt.Errorf("automation %q invalid identifier_regex: %w", id, err) + } + } + if entry.Filter.InputContextRegex != "" { + if _, err := regexp.Compile(entry.Filter.InputContextRegex); err != nil { + return fmt.Errorf("automation %q invalid input_context_regex: %w", id, err) + } + } + } + return nil +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 86206a1..1ce9529 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -99,6 +99,9 @@ func TestValidateDispatchFailsWhenAutoReviewAndAutoClearBothEnabled(t *testing.T content := minimal(`agent: reviewer_profile: code-reviewer auto_review: true + profiles: + code-reviewer: + command: claude workspace: auto_clear: true `) @@ -125,3 +128,144 @@ func TestValidateDispatchFailsWhenAutoReviewEnabledWithoutReviewerProfile(t *tes assert.Contains(t, err.Error(), "reviewer_profile") assert.Contains(t, err.Error(), "auto_review") } + +func TestValidateDispatchRejectsUnknownReviewerProfile(t *testing.T) { + content := minimal(`agent: + reviewer_profile: reviewer + profiles: + qa: + command: claude +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrReviewerProfileNotFound) +} + +func TestValidateDispatchRejectsDisabledReviewerProfile(t *testing.T) { + content := minimal(`agent: + reviewer_profile: reviewer + profiles: + reviewer: + command: claude + enabled: false +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrReviewerProfileDisabled) +} + +func TestValidateDispatchRejectsCreateIssueProfileWithoutState(t *testing.T) { + content := minimal(`agent: + profiles: + qa: + command: claude + allowed_actions: [create_issue] +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "create_issue_state") +} + +func TestValidateDispatchRejectsDuplicateAutomationIDs(t *testing.T) { + content := minimal(`agent: + profiles: + qa: + command: claude +automations: + - id: comment-watch + enabled: true + profile: qa + trigger: + type: tracker_comment_added + - id: comment-watch + enabled: true + profile: qa + trigger: + type: run_failed +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate automation id") +} + +func TestValidateDispatchRejectsInvalidAutomationRegex(t *testing.T) { + content := minimal(`agent: + profiles: + qa: + command: claude +automations: + - id: comment-watch + enabled: true + profile: qa + trigger: + type: tracker_comment_added + filter: + identifier_regex: "[" +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "identifier_regex") +} + +func TestValidateDispatchRejectsUnknownAutomationProfile(t *testing.T) { + content := minimal(`agent: + profiles: + qa: + command: claude +automations: + - id: comment-watch + enabled: true + profile: pm + trigger: + type: tracker_comment_added +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown profile") +} + +func TestValidateDispatchRejectsDisabledAutomationProfile(t *testing.T) { + content := minimal(`agent: + profiles: + qa: + command: claude + enabled: false +automations: + - id: comment-watch + enabled: true + profile: qa + trigger: + type: tracker_comment_added +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "disabled profile") +} diff --git a/internal/orchestrator/automation.go b/internal/orchestrator/automation.go new file mode 100644 index 0000000..2ae27b0 --- /dev/null +++ b/internal/orchestrator/automation.go @@ -0,0 +1,343 @@ +package orchestrator + +import ( + "context" + "log/slog" + "regexp" + "slices" + "strings" + "time" + + "github.com/vnovick/itervox/internal/agent" + "github.com/vnovick/itervox/internal/config" + "github.com/vnovick/itervox/internal/domain" +) + +type AutomationTriggerContext struct { + Type string + FiredAt time.Time + AutomationID string + Cron string + Timezone string + TriggerState string + InputContext string + BlockedProfile string + BlockedBackend string + PreviousState string + CurrentState string + CommentID string + CommentBody string + CommentAuthorID string + CommentAuthorName string + CommentCreatedAt string + ErrorMessage string + WillRetry bool + RetryAttempt int + RetryBackoffMs int +} + +type AutomationDispatch struct { + AutomationID string + ProfileName string + Instructions string + Trigger AutomationTriggerContext + AutoResume bool +} + +type InputRequiredAutomation struct { + ID string + ProfileName string + Instructions string + MatchMode string + States []string + LabelsAny []string + IdentifierRegex *regexp.Regexp + InputContextRegex *regexp.Regexp + AutoResume bool +} + +type RunFailedAutomation struct { + ID string + ProfileName string + Instructions string + MatchMode string + States []string + LabelsAny []string + IdentifierRegex *regexp.Regexp +} + +// SetInputRequiredAutomations installs the compiled input-required automation +// rules. Must be called before Run; config reload restarts the process. +func (o *Orchestrator) SetInputRequiredAutomations(automations []InputRequiredAutomation) { + o.inputRequiredAutomations = append([]InputRequiredAutomation(nil), automations...) +} + +// SetRunFailedAutomations installs the compiled terminal-failure automation +// rules. Must be called before Run; config reload restarts the process. +func (o *Orchestrator) SetRunFailedAutomations(automations []RunFailedAutomation) { + o.runFailedAutomations = append([]RunFailedAutomation(nil), automations...) +} + +// DispatchAutomation queues an automation worker through the event loop. +// Safe to call from any goroutine. +func (o *Orchestrator) DispatchAutomation(issue domain.Issue, automation AutomationDispatch) bool { + select { + case o.events <- OrchestratorEvent{ + Type: EventDispatchAutomation, + Issue: &issue, + Automation: &automation, + }: + return true + default: + slog.Warn("orchestrator: automation dispatch event channel full", "identifier", issue.Identifier, "automation", automation.AutomationID) + return false + } +} + +func (o *Orchestrator) dispatchMatchingInputRequiredAutomations( + ctx context.Context, + state *State, + issue domain.Issue, + entry *InputRequiredEntry, + now time.Time, +) { + if entry == nil || len(o.inputRequiredAutomations) == 0 { + return + } + for _, automation := range o.inputRequiredAutomations { + if !matchesInputRequiredAutomation(issue, automation, entry.Context) { + continue + } + o.startAutomationRun(ctx, state, issue, now, AutomationDispatch{ + AutomationID: automation.ID, + ProfileName: automation.ProfileName, + Instructions: automation.Instructions, + AutoResume: automation.AutoResume, + Trigger: AutomationTriggerContext{ + Type: config.AutomationTriggerInputRequired, + FiredAt: now, + AutomationID: automation.ID, + InputContext: entry.Context, + BlockedProfile: entry.ProfileName, + BlockedBackend: entry.Backend, + }, + }) + } +} + +func matchesInputRequiredAutomation(issue domain.Issue, automation InputRequiredAutomation, inputContext string) bool { + return matchesAutomationFilter( + issue, + automation.MatchMode, + automation.States, + automation.LabelsAny, + automation.IdentifierRegex, + automation.InputContextRegex, + inputContext, + ) +} + +func (o *Orchestrator) dispatchMatchingRunFailedAutomations( + ctx context.Context, + state *State, + issue domain.Issue, + now time.Time, + errorMessage string, + attempt int, +) { + if len(o.runFailedAutomations) == 0 { + return + } + for _, automation := range o.runFailedAutomations { + if !matchesAutomationFilter( + issue, + automation.MatchMode, + automation.States, + automation.LabelsAny, + automation.IdentifierRegex, + nil, + "", + ) { + continue + } + o.startAutomationRun(ctx, state, issue, now, AutomationDispatch{ + AutomationID: automation.ID, + ProfileName: automation.ProfileName, + Instructions: automation.Instructions, + Trigger: AutomationTriggerContext{ + Type: config.AutomationTriggerRunFailed, + FiredAt: now, + AutomationID: automation.ID, + CurrentState: issue.State, + ErrorMessage: errorMessage, + WillRetry: false, + RetryAttempt: attempt, + }, + }) + } +} + +func containsFold(values []string, target string) bool { + target = strings.ToLower(target) + for _, value := range values { + if strings.ToLower(value) == target { + return true + } + } + return false +} + +func matchesAutomationFilter( + issue domain.Issue, + matchMode string, + states []string, + labelsAny []string, + identifierRegex *regexp.Regexp, + inputContextRegex *regexp.Regexp, + inputContext string, +) bool { + checks := make([]bool, 0, 4) + if identifierRegex != nil { + checks = append(checks, identifierRegex.MatchString(issue.Identifier)) + } + if len(states) > 0 { + checks = append(checks, containsFold(states, issue.State)) + } + if len(labelsAny) > 0 { + labelMatch := false + for _, wanted := range labelsAny { + if containsFold(issue.Labels, wanted) { + labelMatch = true + break + } + } + checks = append(checks, labelMatch) + } + if inputContextRegex != nil { + checks = append(checks, inputContextRegex.MatchString(inputContext)) + } + if len(checks) == 0 { + return true + } + if matchMode == config.AutomationFilterMatchAny { + for _, check := range checks { + if check { + return true + } + } + return false + } + for _, check := range checks { + if !check { + return false + } + } + return true +} + +func (o *Orchestrator) startAutomationRun( + ctx context.Context, + state *State, + issue domain.Issue, + now time.Time, + automation AutomationDispatch, +) { + if automation.ProfileName == "" { + return + } + if _, running := state.Running[issue.ID]; running { + return + } + if _, claimed := state.Claimed[issue.ID]; claimed { + return + } + if AvailableSlots(*state) <= 0 { + return + } + + o.cfgMu.RLock() + profile, ok := o.cfg.Agent.Profiles[automation.ProfileName] + defaultCommand := o.cfg.Agent.Command + defaultBackend := o.cfg.Agent.Backend + hosts := append([]string{}, o.cfg.Agent.SSHHosts...) + dispatchStrategy := o.cfg.Agent.DispatchStrategy + o.cfgMu.RUnlock() + + if !ok { + slog.Warn("orchestrator: automation profile not found", "identifier", issue.Identifier, "profile", automation.ProfileName, "automation", automation.AutomationID) + return + } + if !config.ProfileEnabled(profile) { + slog.Warn("orchestrator: automation profile disabled", "identifier", issue.Identifier, "profile", automation.ProfileName, "automation", automation.AutomationID) + return + } + + workerCtx, workerCancel := context.WithCancel(ctx) + workerHost := o.selectWorkerHost(hosts, dispatchStrategy, *state) + + agentCommand := defaultCommand + backend := agent.BackendFromCommand(agentCommand) + if defaultBackend != "" { + backend = defaultBackend + } + runnerCommand := agentCommand + if profile.Command != "" { + agentCommand = profile.Command + runnerCommand = agentCommand + backend = agent.BackendFromCommand(agentCommand) + } + if profile.Backend != "" { + backend = profile.Backend + runnerCommand = agent.CommandWithBackendHint(agentCommand, profile.Backend) + } + + if o.DryRun { + workerCancel() + slog.Info("orchestrator: [DRY-RUN] would dispatch automation", + "identifier", issue.Identifier, + "automation", automation.AutomationID, + "profile", automation.ProfileName, + "worker_host", workerHost, + "backend", backend) + state.Claimed[issue.ID] = struct{}{} + return + } + + state.Claimed[issue.ID] = struct{}{} + attempt := 0 + state.Running[issue.ID] = &RunEntry{ + Issue: issue, + WorkerHost: workerHost, + Backend: backend, + Kind: "automation", + StartedAt: now, + RetryAttempt: &attempt, + WorkerCancel: workerCancel, + } + + o.workerCancelsMu.Lock() + o.workerCancels[issue.Identifier] = workerCancel + o.workerCancelsMu.Unlock() + + slog.Info("orchestrator: dispatching automation worker", + "identifier", issue.Identifier, + "automation", automation.AutomationID, + "profile", automation.ProfileName, + "backend", backend, + ) + + go o.runWorker(workerCtx, issue, attempt, workerHost, runnerCommand, backend, automation.ProfileName, false, nil, &automation) +} + +func filterAllowedActionsForAutomation(actions []string, automation *AutomationDispatch) []string { + normalized := config.NormalizeAllowedActions(actions) + if automation == nil { + return normalized + } + if automation.Trigger.Type != config.AutomationTriggerInputRequired || automation.AutoResume { + return normalized + } + return slices.DeleteFunc(normalized, func(action string) bool { + return action == config.AgentActionProvideInput + }) +} diff --git a/internal/orchestrator/event_loop.go b/internal/orchestrator/event_loop.go index 74de7f2..a3a290c 100644 --- a/internal/orchestrator/event_loop.go +++ b/internal/orchestrator/event_loop.go @@ -514,7 +514,7 @@ func (o *Orchestrator) processPendingInputResumes(ctx context.Context, state Sta SessionID: entry.SessionID, UserMessage: entry.UserMessage, InputContext: entry.Context, - }) + }, nil) } return state } @@ -561,6 +561,10 @@ func (o *Orchestrator) dispatch(ctx context.Context, state State, issue domain.I slog.Warn("orchestrator: profile not found, using default", "identifier", issue.Identifier, "profile", profileName) profileName = "" // clear so the worker does not reference a missing profile + } else if !config.ProfileEnabled(profile) { + slog.Warn("orchestrator: profile disabled, using default", + "identifier", issue.Identifier, "profile", profileName) + profileName = "" } else { if profile.Command != "" { agentCommand = profile.Command @@ -630,7 +634,7 @@ func (o *Orchestrator) dispatch(ctx context.Context, state State, issue domain.I // needed (the worker now owns the session via its RunEntry). delete(state.PausedSessions, issue.Identifier) } - go o.runWorker(workerCtx, issue, attempt, workerHost, runnerCommand, backend, profileName, skipPRCheck, resumeCtx) + go o.runWorker(workerCtx, issue, attempt, workerHost, runnerCommand, backend, profileName, skipPRCheck, resumeCtx, nil) return state } @@ -652,6 +656,11 @@ func (o *Orchestrator) dispatchReviewerForIssue(ctx context.Context, state *Stat "issue_identifier", issue.Identifier, "profile", profileName) return } + if !config.ProfileEnabled(profile) { + slog.Warn("orchestrator: reviewer profile disabled, skipping auto-review", + "issue_identifier", issue.Identifier, "profile", profileName) + return + } agentCommand := defaultCommand backend := agent.BackendFromCommand(agentCommand) @@ -704,7 +713,7 @@ func (o *Orchestrator) dispatchReviewerForIssue(ctx context.Context, state *Stat o.issueProfiles[issue.Identifier] = profileName o.issueProfilesMu.Unlock() - go o.runWorker(workerCtx, issue, attempt, workerHost, runnerCommand, backend, profileName, false, nil) + go o.runWorker(workerCtx, issue, attempt, workerHost, runnerCommand, backend, profileName, false, nil, nil) } func (o *Orchestrator) selectWorkerHost(hosts []string, dispatchStrategy string, state State) string { @@ -867,7 +876,7 @@ func (o *Orchestrator) handleEvent(ctx context.Context, state State, ev Orchestr go func(issueID, ident, msg string) { postCtx, cancel := context.WithTimeout(context.Background(), postRunTimeout) defer cancel() - if _, err := o.tracker.CreateComment(postCtx, issueID, msg); err != nil { + if _, err := o.tracker.CreateComment(postCtx, issueID, tracker.MarkManagedComment(msg)); err != nil { slog.Warn("orchestrator: failed to post user input as tracker comment", "identifier", ident, "error", err) } @@ -944,6 +953,19 @@ func (o *Orchestrator) handleEvent(ctx context.Context, state State, ev Orchestr } o.dispatchReviewerForIssue(ctx, &state, *found, ev.ReviewerProfile, time.Now()) + case EventDispatchAutomation: + if ev.Issue == nil || ev.Automation == nil { + return state + } + if reason := IneligibleReason(*ev.Issue, state, o.cfg); reason != "" { + slog.Debug("orchestrator: skipping automation dispatch", + "identifier", ev.Issue.Identifier, + "automation", ev.Automation.AutomationID, + "reason", reason) + return state + } + o.startAutomationRun(ctx, &state, *ev.Issue, time.Now(), *ev.Automation) + case EventWorkerExited: // Capture the live entry before deletion so we can record history. liveEntry := state.Running[ev.IssueID] @@ -1081,7 +1103,7 @@ func (o *Orchestrator) handleEvent(ctx context.Context, state State, ev Orchestr reviewerProfile := o.cfg.Agent.ReviewerProfile autoReview := o.cfg.Agent.AutoReview o.cfgMu.RUnlock() - if autoClear && autoReview && reviewerProfile != "" && (liveEntry == nil || liveEntry.Kind != "reviewer") { + if autoClear && autoReview && reviewerProfile != "" && runEligibleForAutoReview(liveEntry) { slog.Warn("orchestrator: skipping auto-review because workspace auto-clear is enabled", "issue_id", ev.IssueID, "issue_identifier", issue.Identifier) autoReview = false @@ -1117,10 +1139,8 @@ func (o *Orchestrator) handleEvent(ctx context.Context, state State, ev Orchestr // Auto-review: if configured, dispatch a reviewer worker for this issue. // Only trigger when the completed worker was NOT itself a reviewer // (prevents infinite review loops). - if liveEntry == nil || liveEntry.Kind != "reviewer" { - if autoReview && reviewerProfile != "" { - o.dispatchReviewerForIssue(ctx, &state, issue, reviewerProfile, now) - } + if autoReview && reviewerProfile != "" && runEligibleForAutoReview(liveEntry) { + o.dispatchReviewerForIssue(ctx, &state, issue, reviewerProfile, now) } case TerminalStalled: @@ -1156,7 +1176,7 @@ func (o *Orchestrator) handleEvent(ctx context.Context, state State, ev Orchestr // Post the agent's question as a tracker comment so it's visible // in Linear/GitHub. The dashboard shows a reply UI; user replies // are also posted as tracker comments before resuming the agent. - commentText := buildInputRequiredComment(entry) + commentText := tracker.MarkManagedComment(buildInputRequiredComment(entry)) go func(issueID, ident string) { postCtx, cancel := context.WithTimeout(context.Background(), postRunTimeout) defer cancel() @@ -1181,6 +1201,7 @@ func (o *Orchestrator) handleEvent(ctx context.Context, state State, ev Orchestr } }(entry.IssueID, issue.Identifier) state.InputRequiredIssues[issue.Identifier] = entry + o.dispatchMatchingInputRequiredAutomations(ctx, &state, issue, entry, now) slog.Info("orchestrator: issue queued for human input", "issue_id", ev.IssueID, "issue_identifier", issue.Identifier) o.recordHistory(liveEntry, issue, now, "input_required") @@ -1219,6 +1240,7 @@ func (o *Orchestrator) handleEvent(ctx context.Context, state State, ev Orchestr o.savePausedToDisk(copyStringMap(state.PausedIdentifiers)) } delete(state.Claimed, ev.IssueID) + o.dispatchMatchingRunFailedAutomations(ctx, &state, issue, now, errMsg, nextAttempt) o.recordHistory(liveEntry, issue, now, "failed") } else { backoff := BackoffMs(nextAttempt, o.cfg.Agent.MaxRetryBackoffMs) @@ -1245,7 +1267,7 @@ func (o *Orchestrator) commentMaxRetriesExhausted(issue domain.Issue, attempts i attempts, lastErr) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - if _, err := o.tracker.CreateComment(ctx, issue.ID, comment); err != nil { + if _, err := o.tracker.CreateComment(ctx, issue.ID, tracker.MarkManagedComment(comment)); err != nil { slog.Warn("worker: failed to post max-retries comment", "issue_id", issue.ID, "error", err) } } @@ -1370,6 +1392,13 @@ func (o *Orchestrator) recordHistory(liveEntry *RunEntry, issue domain.Issue, fi o.addCompletedRun(run) } +func runEligibleForAutoReview(liveEntry *RunEntry) bool { + if liveEntry == nil { + return true + } + return liveEntry.Kind == "" || liveEntry.Kind == "worker" +} + // buildSubAgentContext generates a "## Available Sub-Agents" section that is // appended to the rendered prompt when agent teams mode is active. // activeProfile is excluded from the list so the agent doesn't try to spawn itself. diff --git a/internal/orchestrator/event_loop_test.go b/internal/orchestrator/event_loop_test.go index 625f37d..4f94fd8 100644 --- a/internal/orchestrator/event_loop_test.go +++ b/internal/orchestrator/event_loop_test.go @@ -260,6 +260,51 @@ func TestDryRunMode(t *testing.T) { assert.Empty(t, snap.Running, "no workers should be running in dry-run mode") } +func TestDryRunAutomationDispatch(t *testing.T) { + logBuf := &syncBuffer{} + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + + cfg := baseConfig() + cfg.Agent.Profiles = map[string]config.AgentProfile{ + "qa": {Command: "claude", Prompt: "Run QA checks."}, + } + issue := makeIssue("id1", "ENG-1", "Todo", nil, nil) + mt := &noCandidateTracker{base: tracker.NewMemoryTracker([]domain.Issue{issue}, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates)} + fake := agenttest.NewFakeRunner([]agent.StreamEvent{ + {Type: "system", SessionID: "s1"}, + {Type: "result", SessionID: "s1"}, + }) + + orch := orchestrator.New(cfg, mt, fake, nil) + orch.DryRun = true + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + go orch.Run(ctx) //nolint:errcheck + time.Sleep(20 * time.Millisecond) + + require.True(t, orch.DispatchAutomation(issue, orchestrator.AutomationDispatch{ + AutomationID: "qa-ready", + ProfileName: "qa", + Instructions: "Run QA and report.", + Trigger: orchestrator.AutomationTriggerContext{ + Type: config.AutomationTriggerCron, + AutomationID: "qa-ready", + FiredAt: time.Now(), + }, + })) + + time.Sleep(150 * time.Millisecond) + cancel() + + assert.Zero(t, fake.CallCount, "dry-run automation dispatch should not execute the runner") + assert.Contains(t, logBuf.String(), "DRY-RUN", "dry-run automation dispatch should be logged") + assert.Empty(t, orch.Snapshot().Running) +} + // --------------------------------------------------------------------------- // 10. Profile override resolution // --------------------------------------------------------------------------- @@ -403,6 +448,9 @@ func TestAutoClearWorkspaceCfgRoundtrip(t *testing.T) { func TestAutoClearWorkspaceCfgRejectsReviewerConflict(t *testing.T) { o := newOrch() + o.SetProfilesCfg(map[string]config.AgentProfile{ + "reviewer": {Command: "claude"}, + }) require.NoError(t, o.SetReviewerCfg("reviewer", true)) err := o.SetAutoClearWorkspaceCfg(true) diff --git a/internal/orchestrator/integration_test.go b/internal/orchestrator/integration_test.go index 45ce58f..916abf2 100644 --- a/internal/orchestrator/integration_test.go +++ b/internal/orchestrator/integration_test.go @@ -124,6 +124,10 @@ func (t *commentTracker) CreateComment(_ context.Context, issueID, body string) return &comment, nil } +func (t *commentTracker) CreateIssue(ctx context.Context, sourceIssueID, title, body, stateName string) (*domain.Issue, error) { + return t.base.CreateIssue(ctx, sourceIssueID, title, body, stateName) +} + func (t *commentTracker) UpdateIssueState(ctx context.Context, issueID, stateName string) error { return t.base.UpdateIssueState(ctx, issueID, stateName) } @@ -508,6 +512,63 @@ func TestInputRequiredResumeReusesWorkspaceWithoutRerunningBeforeRun(t *testing. } } +func TestInputRequiredCommentsAreMarkedManaged(t *testing.T) { + cfg := baseConfig() + cfg.Polling.IntervalMs = 20 + cfg.Agent.MaxTurns = 3 + cfg.Tracker.CompletionState = "Done" + cfg.PromptTemplate = "Handle {{ issue.identifier }}" + + issue := makeIssue("id1", "ENG-1", "In Progress", nil, nil) + ct := newCommentTracker( + []domain.Issue{issue}, + cfg.Tracker.ActiveStates, + cfg.Tracker.TerminalStates, + ) + runner := &inputRequiredResumeRunner{} + orch := orchestrator.New(cfg, ct, runner, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go orch.Run(ctx) //nolint:errcheck + + deadline := time.After(3 * time.Second) + for { + comments := ct.commentsFor("id1") + if _, ok := orch.Snapshot().InputRequiredIssues["ENG-1"]; ok && len(comments) > 0 { + assert.Contains(t, comments[0].Body, "") + assert.Contains(t, comments[0].Body, "Agent needs your input") + break + } + select { + case <-deadline: + t.Fatalf("input-required question comment was not posted; snap=%+v comments=%+v", orch.Snapshot(), comments) + case <-time.After(20 * time.Millisecond): + } + } + + require.True(t, orch.ProvideInput("ENG-1", "Approved. Continue with the existing branch.")) + + deadline = time.After(3 * time.Second) + for { + comments := ct.commentsFor("id1") + issues, err := ct.FetchIssueStatesByIDs(ctx, []string{"id1"}) + require.NoError(t, err) + if len(comments) >= 2 && len(issues) > 0 && issues[0].State == "Done" { + reply := comments[len(comments)-1] + assert.Contains(t, reply.Body, "Approved. Continue with the existing branch.") + assert.Contains(t, reply.Body, "") + return + } + select { + case <-deadline: + t.Fatalf("managed provide-input comment was not posted; snap=%+v comments=%+v", orch.Snapshot(), comments) + case <-time.After(20 * time.Millisecond): + } + } +} + func TestInputRequiredResumeUsesCodexSessionAndUserReply(t *testing.T) { cfg := baseConfig() cfg.Polling.IntervalMs = 20 diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index c7dbf84..5232212 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "github.com/vnovick/itervox/internal/agent" + "github.com/vnovick/itervox/internal/agentactions" "github.com/vnovick/itervox/internal/config" "github.com/vnovick/itervox/internal/logbuffer" "github.com/vnovick/itervox/internal/tracker" @@ -100,11 +101,27 @@ type Orchestrator struct { issueBackendsMu sync.RWMutex issueBackends map[string]string // identifier → "claude"|"codex" + // inputRequiredAutomations is the compiled set of helper-agent rules that + // react to blocked runs. Installed before Run and read by the event loop. + inputRequiredAutomations []InputRequiredAutomation + + // runFailedAutomations is the compiled set of helper-agent rules that react + // to terminal worker failures after retries are exhausted. + runFailedAutomations []RunFailedAutomation + // agentLogDir, when non-empty, is passed to RunTurn as CLAUDE_CODE_LOG_DIR // so Claude Code writes full session logs (including sub-agents) to disk. // Set via SetAgentLogDir before calling Run. agentLogDir string + // agentActionBaseURL is the daemon base URL exposed to local worker actions. + // Set via SetAgentActionBaseURL before Run. + agentActionBaseURL string + + // agentActionTokens issues and validates short-lived per-run action grants. + // Set via SetAgentActionTokens before Run. + agentActionTokens *agentactions.Store + // appSessionID is a unique ID for this daemon invocation, used to group // all history entries produced during a single run of the binary. // Set via SetAppSessionID before calling Run. @@ -154,6 +171,18 @@ func (o *Orchestrator) SetAgentLogDir(dir string) { o.agentLogDir = dir } +// SetAgentActionBaseURL configures the daemon base URL exposed to local workers. +// Must be called before Run. +func (o *Orchestrator) SetAgentActionBaseURL(url string) { + o.agentActionBaseURL = url +} + +// SetAgentActionTokens configures the short-lived action grant store. +// Must be called before Run. +func (o *Orchestrator) SetAgentActionTokens(store *agentactions.Store) { + o.agentActionTokens = store +} + // SetAppSessionID sets the unique ID for this daemon invocation. // Must be called before Run. func (o *Orchestrator) SetAppSessionID(id string) { @@ -309,6 +338,9 @@ func (o *Orchestrator) SetReviewerCfg(profile string, autoReview bool) error { if err := config.ValidateReviewerAutoReview(profile, autoReview); err != nil { return err } + if err := config.ValidateReviewerProfile(o.cfg.Agent.Profiles, profile); err != nil { + return err + } if err := config.ValidateAutoClearAutoReview( o.cfg.Workspace.AutoClearWorkspace, profile, diff --git a/internal/orchestrator/reviewer_test.go b/internal/orchestrator/reviewer_test.go index ee1d04e..bbecc19 100644 --- a/internal/orchestrator/reviewer_test.go +++ b/internal/orchestrator/reviewer_test.go @@ -23,6 +23,9 @@ import ( func TestReviewerCfgRoundtrip(t *testing.T) { cfg := baseConfig() + cfg.Agent.Profiles = map[string]config.AgentProfile{ + "reviewer": {Command: "claude"}, + } orch := orchestrator.New(cfg, tracker.NewMemoryTracker(nil, nil, nil), agenttest.NewFakeRunner(nil), nil) require.NoError(t, orch.SetReviewerCfg("reviewer", true)) @@ -39,6 +42,9 @@ func TestReviewerCfgRoundtrip(t *testing.T) { func TestReviewerCfgRejectsAutoClearConflict(t *testing.T) { cfg := baseConfig() cfg.Workspace.AutoClearWorkspace = true + cfg.Agent.Profiles = map[string]config.AgentProfile{ + "reviewer": {Command: "claude"}, + } orch := orchestrator.New(cfg, tracker.NewMemoryTracker(nil, nil, nil), agenttest.NewFakeRunner(nil), nil) err := orch.SetReviewerCfg("reviewer", true) @@ -63,6 +69,29 @@ func TestReviewerCfgRejectsAutoReviewWithoutProfile(t *testing.T) { assert.False(t, autoReview) } +func TestReviewerCfgRejectsUnknownProfile(t *testing.T) { + cfg := baseConfig() + orch := orchestrator.New(cfg, tracker.NewMemoryTracker(nil, nil, nil), agenttest.NewFakeRunner(nil), nil) + + err := orch.SetReviewerCfg("reviewer", false) + + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrReviewerProfileNotFound) +} + +func TestReviewerCfgRejectsDisabledProfile(t *testing.T) { + cfg := baseConfig() + cfg.Agent.Profiles = map[string]config.AgentProfile{ + "reviewer": {Command: "claude", Enabled: func() *bool { disabled := false; return &disabled }()}, + } + orch := orchestrator.New(cfg, tracker.NewMemoryTracker(nil, nil, nil), agenttest.NewFakeRunner(nil), nil) + + err := orch.SetReviewerCfg("reviewer", false) + + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrReviewerProfileDisabled) +} + func TestReviewerConfigParsedFromYAML(t *testing.T) { cfg := baseConfig() cfg.Agent.ReviewerProfile = "code-reviewer" @@ -222,6 +251,63 @@ func TestAutoReview_DispatchesAfterSuccess(t *testing.T) { assert.Contains(t, logs, "orchestrator: dispatching reviewer", "should log reviewer dispatch") } +func TestAutoReview_DoesNotTriggerForAutomationRuns(t *testing.T) { + logBuf := &syncBuffer{} + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + + cfg := baseConfig() + cfg.Polling.IntervalMs = 50 + cfg.Agent.ReviewerProfile = "reviewer" + cfg.Agent.AutoReview = true + cfg.Agent.Profiles = map[string]config.AgentProfile{ + "reviewer": {Command: "claude", Prompt: "Review this code."}, + "qa": {Command: "claude", Prompt: "Run QA checks."}, + } + + issue := makeIssue("id1", "ENG-1", "Todo", nil, nil) + mt := &noCandidateTracker{base: tracker.NewMemoryTracker([]domain.Issue{issue}, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates)} + done := make(chan struct{}, 2) + countingRunner := &countingTrackingRunner{ + Runner: agenttest.NewFakeRunner([]agent.StreamEvent{ + {Type: "system", SessionID: "s1"}, + {Type: "result", SessionID: "s1"}, + }), + done: done, + } + + orch := orchestrator.New(cfg, mt, countingRunner, nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go orch.Run(ctx) //nolint:errcheck + time.Sleep(20 * time.Millisecond) + + require.True(t, orch.DispatchAutomation(issue, orchestrator.AutomationDispatch{ + AutomationID: "qa-ready", + ProfileName: "qa", + Instructions: "Run QA and report.", + Trigger: orchestrator.AutomationTriggerContext{ + Type: config.AutomationTriggerCron, + AutomationID: "qa-ready", + Cron: "0 */2 * * *", + FiredAt: time.Now(), + }, + })) + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("expected automation worker to run") + } + + time.Sleep(250 * time.Millisecond) + + assert.Equal(t, 1, countingRunner.CallCount(), "automation success should not dispatch reviewer") + assert.NotContains(t, logBuf.String(), "orchestrator: dispatching reviewer") +} + func TestAutoReview_UsesSSHHostSelection(t *testing.T) { cfg := baseConfig() cfg.Polling.IntervalMs = 50 @@ -469,6 +555,46 @@ func (r *workerHostTrackingRunner) snapshot() []string { // If this doesn't compile, the syncBuffer type from token_log_test.go is needed. var _ = (*syncBuffer)(nil) +type noCandidateTracker struct { + base *tracker.MemoryTracker +} + +func (t *noCandidateTracker) FetchCandidateIssues(context.Context) ([]domain.Issue, error) { + return nil, nil +} + +func (t *noCandidateTracker) FetchIssuesByStates(ctx context.Context, stateNames []string) ([]domain.Issue, error) { + return t.base.FetchIssuesByStates(ctx, stateNames) +} + +func (t *noCandidateTracker) FetchIssueStatesByIDs(ctx context.Context, issueIDs []string) ([]domain.Issue, error) { + return t.base.FetchIssueStatesByIDs(ctx, issueIDs) +} + +func (t *noCandidateTracker) CreateComment(ctx context.Context, issueID, body string) (*domain.Comment, error) { + return t.base.CreateComment(ctx, issueID, body) +} + +func (t *noCandidateTracker) CreateIssue(ctx context.Context, sourceIssueID, title, body, stateName string) (*domain.Issue, error) { + return t.base.CreateIssue(ctx, sourceIssueID, title, body, stateName) +} + +func (t *noCandidateTracker) UpdateIssueState(ctx context.Context, issueID, stateName string) error { + return t.base.UpdateIssueState(ctx, issueID, stateName) +} + +func (t *noCandidateTracker) FetchIssueDetail(ctx context.Context, issueID string) (*domain.Issue, error) { + return t.base.FetchIssueDetail(ctx, issueID) +} + +func (t *noCandidateTracker) FetchIssueByIdentifier(ctx context.Context, identifier string) (*domain.Issue, error) { + return t.base.FetchIssueByIdentifier(ctx, identifier) +} + +func (t *noCandidateTracker) SetIssueBranch(ctx context.Context, issueID, branchName string) error { + return t.base.SetIssueBranch(ctx, issueID, branchName) +} + // Needed for the strings.Split usage in log assertions. var _ = strings.Split var _ = bytes.Buffer{} diff --git a/internal/orchestrator/state.go b/internal/orchestrator/state.go index f0c81da..90750a9 100644 --- a/internal/orchestrator/state.go +++ b/internal/orchestrator/state.go @@ -48,6 +48,10 @@ const ( // creates the input-required question comment so the event loop can persist // the exact tracker comment ID and author identity locally. EventInputRequiredCommentRecorded EventType = "InputRequiredCommentRecorded" + // EventDispatchAutomation is sent by cron automations to dispatch a helper + // worker through the event loop using a selected profile plus extra + // automation instructions. + EventDispatchAutomation EventType = "DispatchAutomation" ) // OrchestratorEvent is sent over the event channel to the orchestrator loop. @@ -62,6 +66,8 @@ type OrchestratorEvent struct { //nolint:revive ReviewerProfile string // profile name for EventDispatchReviewer InputRequiredEntry *InputRequiredEntry // used by TerminalInputRequired Comment *domain.Comment // used by EventInputRequiredCommentRecorded + Issue *domain.Issue // used by EventDispatchAutomation + Automation *AutomationDispatch // used by EventDispatchAutomation } // TerminalReason classifies why a worker stopped. diff --git a/internal/orchestrator/worker.go b/internal/orchestrator/worker.go index c05b138..731f103 100644 --- a/internal/orchestrator/worker.go +++ b/internal/orchestrator/worker.go @@ -8,9 +8,11 @@ import ( "fmt" "log/slog" "maps" + "os" "os/exec" "path/filepath" "runtime/debug" + "sort" "strings" "time" @@ -19,6 +21,7 @@ import ( "github.com/vnovick/itervox/internal/domain" "github.com/vnovick/itervox/internal/prdetector" "github.com/vnovick/itervox/internal/prompt" + "github.com/vnovick/itervox/internal/tracker" "github.com/vnovick/itervox/internal/workspace" ) @@ -51,7 +54,7 @@ const ( // dispatch and we're continuing in-place). // // See ResumeContext in state.go for the full contract. -func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attempt int, workerHost string, agentCommand string, backend string, profileName string, skipPRCheck bool, resume *ResumeContext) { +func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attempt int, workerHost string, agentCommand string, backend string, profileName string, skipPRCheck bool, resume *ResumeContext, automation *AutomationDispatch) { defer func() { if r := recover(); r != nil { err := fmt.Errorf("worker panic: %v", r) @@ -83,6 +86,7 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp } // --- Workspace --- + automationRun := automation != nil hasResumeSession := resume != nil && resume.SessionID != "" hasResumeMessage := resume != nil && resume.UserMessage != "" inputRequiredResume := hasResumeSession && hasResumeMessage @@ -180,7 +184,7 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp } // Transition issue to working state (e.g. Todo → In Progress). - if !skipFreshDispatchSetup { + if !skipFreshDispatchSetup && !automationRun { o.transitionToWorking(ctx, issue) } @@ -205,8 +209,50 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp beforeRunHook := o.cfg.Hooks.BeforeRun afterRunHook := o.cfg.Hooks.AfterRun hookTimeoutMs := o.cfg.Hooks.TimeoutMs + profilesSnap := make(map[string]config.AgentProfile, len(o.cfg.Agent.Profiles)) + maps.Copy(profilesSnap, o.cfg.Agent.Profiles) o.cfgMu.RUnlock() + profileAllowedActions := filterAllowedActionsForAutomation(profilesSnap[profileName].AllowedActions, automation) + profileCreateIssueState := strings.TrimSpace(profilesSnap[profileName].CreateIssueState) + actionContext := "" + if len(profileAllowedActions) > 0 { + if workerHost != "" { + actionContext = buildAgentActionContext(profileAllowedActions, profileCreateIssueState, true) + } else if o.agentActionTokens == nil || o.agentActionBaseURL == "" { + slog.Warn("worker: profile allowed_actions configured but daemon action bridge is unavailable", + "issue_id", issue.ID, "issue_identifier", issue.Identifier, "profile", profileName) + } else if shimDir, token, err := prepareAgentActionRuntime( + o.agentActionTokens, + issue.Identifier, + runLogID, + profileAllowedActions, + profileCreateIssueState, + turnTimeoutMs, + ); err != nil { + slog.Warn("worker: failed to prepare daemon action bridge", + "issue_id", issue.ID, "issue_identifier", issue.Identifier, "profile", profileName, "error", err) + } else { + pathValue := shimDir + if currentPath := os.Getenv("PATH"); currentPath != "" { + pathValue = shimDir + string(os.PathListSeparator) + currentPath + } + agentCommand = prependEnvToCommand(agentCommand, map[string]string{ + "ITERVOX_ACTION_TOKEN": token, + "ITERVOX_DAEMON_URL": o.agentActionBaseURL, + "ITERVOX_CREATE_ISSUE_STATE": profileCreateIssueState, + "ITERVOX_ISSUE_IDENTIFIER": issue.Identifier, + "ITERVOX_RUN_ID": runLogID, + "PATH": pathValue, + }) + actionContext = buildAgentActionContext(profileAllowedActions, profileCreateIssueState, false) + defer func() { + o.agentActionTokens.Revoke(token) + _ = os.RemoveAll(shimDir) + }() + } + } + // --- Multi-turn loop --- // before_run hook runs once per worker invocation (not per turn), so that // hooks like "git reset --hard origin/main" set up a clean workspace for the @@ -326,15 +372,56 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp // with HTTP handler goroutines that may mutate them concurrently. o.cfgMu.RLock() agentMode := o.cfg.Agent.AgentMode - profilesSnap := make(map[string]config.AgentProfile, len(o.cfg.Agent.Profiles)) - maps.Copy(profilesSnap, o.cfg.Agent.Profiles) o.cfgMu.RUnlock() if profileName != "" { - if profile, ok := profilesSnap[profileName]; ok && profile.Prompt != "" { - renderedPrompt += "\n\n" + prompt.RenderProfilePrompt(profile.Prompt, issue, attemptPtr) + if profile, ok := profilesSnap[profileName]; ok { + if profile.Prompt != "" { + renderedPrompt += "\n\n" + prompt.RenderProfilePrompt(profile.Prompt, issue, attemptPtr) + } } } + if automation != nil && automation.Instructions != "" { + renderedPrompt += "\n\n" + prompt.RenderPromptOverlay( + automation.Instructions, + issue, + attemptPtr, + map[string]any{ + "trigger": map[string]any{ + "type": automation.Trigger.Type, + "fired_at": automation.Trigger.FiredAt.Format(time.RFC3339), + "automation_id": automation.Trigger.AutomationID, + "cron": automation.Trigger.Cron, + "timezone": automation.Trigger.Timezone, + "trigger_state": automation.Trigger.TriggerState, + "input_context": automation.Trigger.InputContext, + "blocked_profile": automation.Trigger.BlockedProfile, + "blocked_backend": automation.Trigger.BlockedBackend, + "previous_state": automation.Trigger.PreviousState, + "current_state": automation.Trigger.CurrentState, + "error_message": automation.Trigger.ErrorMessage, + "will_retry": automation.Trigger.WillRetry, + "retry_attempt": automation.Trigger.RetryAttempt, + "retry_backoff_ms": automation.Trigger.RetryBackoffMs, + "comment_id": automation.Trigger.CommentID, + "comment_body": automation.Trigger.CommentBody, + "comment_author_id": automation.Trigger.CommentAuthorID, + "comment_author_name": automation.Trigger.CommentAuthorName, + "comment_created_at": automation.Trigger.CommentCreatedAt, + "comment": map[string]any{ + "id": automation.Trigger.CommentID, + "body": automation.Trigger.CommentBody, + "author_id": automation.Trigger.CommentAuthorID, + "author_name": automation.Trigger.CommentAuthorName, + "created_at": automation.Trigger.CommentCreatedAt, + }, + }, + }, + ) + } + if actionContext != "" { + renderedPrompt += "\n\n" + actionContext + } // In teams mode, also append sub-agent roster context so the active backend // knows which specialised agents it can spawn via its delegation tool. if agentMode == "teams" { @@ -649,7 +736,7 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp // issue. This runs before the session summary so the PR link is visible even // on trackers that truncate long comments. Uses the same gh CLI check as the // pre-run guard (now the workspace is on the newly-created branch). - if wsPath != "" && prCtx == nil { + if wsPath != "" && prCtx == nil && !automationRun { if prURL := workspace.FindOpenPRURL(ctx, wsPath); prURL != "" { detectedPRURL = prURL // Dedup: check if we already posted a PR comment for this URL. @@ -664,7 +751,7 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp if alreadyPosted { slog.Info("worker: PR comment already posted, skipping", "issue_id", issue.ID, "issue_identifier", issue.Identifier, "pr_url", prURL) - } else if _, err := o.tracker.CreateComment(ctx, issue.ID, prComment); err != nil { + } else if _, err := o.tracker.CreateComment(ctx, issue.ID, tracker.MarkManagedComment(prComment)); err != nil { slog.Warn("worker: create PR comment failed (ignored)", "issue_id", issue.ID, "issue_identifier", issue.Identifier, "error", err) } else { @@ -686,7 +773,7 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp // Use a fresh background-derived context with a timeout so that a cancellation // of the worker context (e.g. user pause) between the ctx.Err() guard and // command execution does not silently skip the post-run cleanup. - if prCtx != nil && ctx.Err() == nil { + if prCtx != nil && ctx.Err() == nil && !automationRun { postRunCtx, postRunCancel := context.WithTimeout(context.Background(), postRunTimeout) defer postRunCancel() // Push so the remote branch reflects the agent's changes. @@ -717,8 +804,8 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp // Post one comprehensive comment covering the full session narration (best-effort). // Skip when there is an open PR: the summary was already posted as a PR comment // above, so posting it again on the tracker issue would create a duplicate (GO-R10-3). - if sessionComment != "" && prCtx == nil { - if _, err := o.tracker.CreateComment(ctx, issue.ID, sessionComment); err != nil { + if sessionComment != "" && prCtx == nil && !automationRun { + if _, err := o.tracker.CreateComment(ctx, issue.ID, tracker.MarkManagedComment(sessionComment)); err != nil { slog.Warn("worker: create session comment failed (ignored)", "issue_id", issue.ID, "issue_identifier", issue.Identifier, "error", err) } @@ -734,7 +821,7 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp o.cfgMu.RLock() completionState := o.cfg.Tracker.CompletionState o.cfgMu.RUnlock() - if completionState != "" && ctx.Err() == nil { + if completionState != "" && ctx.Err() == nil && !automationRun { slog.Info("worker: transitioning to completion state", "issue_id", issue.ID, "issue_identifier", issue.Identifier, "target_state", completionState) if o.logBuf != nil { @@ -1020,6 +1107,113 @@ func (o *Orchestrator) runAfterHook(ctx context.Context, hook string, timeoutMs } } +func prepareAgentActionRuntime(tokens interface { + Issue(issueIdentifier, runSessionID string, allowedActions []string, createIssueState string, ttl time.Duration) (string, error) +}, issueIdentifier, runLogID string, allowedActions []string, createIssueState string, turnTimeoutMs int) (string, string, error) { + exePath, err := os.Executable() + if err != nil { + return "", "", fmt.Errorf("resolve current executable: %w", err) + } + shimDir, err := os.MkdirTemp("", "itervox-agent-actions-*") + if err != nil { + return "", "", fmt.Errorf("create shim dir: %w", err) + } + shimPath := filepath.Join(shimDir, "itervox") + script := "#!/bin/sh\nexec " + shellQuote(exePath) + " \"$@\"\n" + if err := os.WriteFile(shimPath, []byte(script), 0o755); err != nil { + _ = os.RemoveAll(shimDir) + return "", "", fmt.Errorf("write shim: %w", err) + } + token, err := tokens.Issue(issueIdentifier, runLogID, allowedActions, createIssueState, agentActionTokenTTL(turnTimeoutMs)) + if err != nil { + _ = os.RemoveAll(shimDir) + return "", "", fmt.Errorf("issue action token: %w", err) + } + return shimDir, token, nil +} + +func agentActionTokenTTL(turnTimeoutMs int) time.Duration { + if turnTimeoutMs <= 0 { + return time.Hour + } + return max(time.Duration(turnTimeoutMs)*time.Millisecond+5*time.Minute, 15*time.Minute) +} + +func prependEnvToCommand(command string, env map[string]string) string { + const backendHintPrefix = "@@itervox-backend=" + + keys := make([]string, 0, len(env)) + for key := range env { + keys = append(keys, key) + } + sort.Strings(keys) + + trimmedCommand := strings.TrimSpace(command) + hintToken := "" + commandRemainder := trimmedCommand + if strings.HasPrefix(trimmedCommand, backendHintPrefix) { + if idx := strings.IndexAny(trimmedCommand, " \t"); idx >= 0 { + hintToken = trimmedCommand[:idx] + commandRemainder = strings.TrimLeft(trimmedCommand[idx:], " \t") + } else { + hintToken = trimmedCommand + commandRemainder = "" + } + } + + var b strings.Builder + if hintToken != "" { + b.WriteString(hintToken) + b.WriteByte(' ') + } + for _, key := range keys { + if env[key] == "" { + continue + } + b.WriteString(key) + b.WriteString("=") + b.WriteString(shellQuote(env[key])) + b.WriteByte(' ') + } + b.WriteString(commandRemainder) + return b.String() +} + +func buildAgentActionContext(actions []string, createIssueState string, remoteUnavailable bool) string { + normalized := config.NormalizeAllowedActions(actions) + if len(normalized) == 0 { + return "" + } + if remoteUnavailable { + return "Daemon-backed itervox actions are configured for this profile, but they are not available on remote SSH workers in v1." + } + lines := []string{ + "Itervox daemon actions are available for this profile. They only operate on the current issue.", + "Use the `itervox action ...` CLI only when the task actually needs a tracker or resume action.", + } + for _, action := range normalized { + switch action { + case config.AgentActionComment: + lines = append(lines, "- `itervox action comment --body \"...\"` posts a tracker comment on the current issue.") + case config.AgentActionCreateIssue: + if createIssueState != "" { + lines = append(lines, "- `itervox action create-issue --title \"...\" --body \"...\"` creates a follow-up issue in state `"+createIssueState+"`.") + } else { + lines = append(lines, "- `itervox action create-issue --title \"...\" --body \"...\"` creates a follow-up issue using the profile's configured target state.") + } + case config.AgentActionMoveState: + lines = append(lines, "- `itervox action move-state --state \"...\"` moves the current issue to a new tracker state.") + case config.AgentActionProvideInput: + lines = append(lines, "- `itervox action provide-input --message \"...\"` answers an input-required prompt and resumes the blocked run.") + } + } + return strings.Join(lines, "\n") +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} + // generateRunID returns a short random ID that is assigned to a worker run // before the agent subprocess starts, enabling all log entries — including // early hook/worker messages — to be tagged with the same session ID. diff --git a/internal/orchestrator/worker_test.go b/internal/orchestrator/worker_test.go new file mode 100644 index 0000000..1e75605 --- /dev/null +++ b/internal/orchestrator/worker_test.go @@ -0,0 +1,22 @@ +package orchestrator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrependEnvToCommand_PreservesBackendHintPrefix(t *testing.T) { + command := "@@itervox-backend=codex /tmp/codex-wrapper --flag" + + got := prependEnvToCommand(command, map[string]string{ + "ITERVOX_ACTION_TOKEN": "token value", + "PATH": "/tmp/bin:/usr/bin", + }) + + assert.True(t, strings.HasPrefix(got, "@@itervox-backend=codex "), "backend hint should stay at the front so runner dispatch remains stable") + assert.Contains(t, got, "ITERVOX_ACTION_TOKEN='token value'") + assert.Contains(t, got, "PATH='/tmp/bin:/usr/bin'") + assert.Contains(t, got, "/tmp/codex-wrapper --flag") +} diff --git a/internal/prompt/renderer.go b/internal/prompt/renderer.go index 6e08770..6c66da0 100644 --- a/internal/prompt/renderer.go +++ b/internal/prompt/renderer.go @@ -53,6 +53,13 @@ func Render(tmpl string, issue domain.Issue, attempt *int) (string, error) { // it passes through unchanged. Returns the input as-is on parse/render errors // so a plain-text prompt still works. func RenderProfilePrompt(promptText string, issue domain.Issue, attempt *int) string { + return RenderPromptOverlay(promptText, issue, attempt, nil) +} + +// RenderPromptOverlay renders a plain-text or Liquid prompt fragment using the +// standard issue/attempt bindings plus optional extra bindings, returning the +// original text on parse/render errors for backward compatibility. +func RenderPromptOverlay(promptText string, issue domain.Issue, attempt *int, extra map[string]any) string { if strings.TrimSpace(promptText) == "" { return "" } @@ -67,6 +74,9 @@ func RenderProfilePrompt(promptText string, issue domain.Issue, attempt *int) st "issue": issueToMap(issue), "attempt": attemptValue(attempt), } + for key, value := range extra { + bindings[key] = value + } out, err := tpl.Render(bindings) if err != nil { diff --git a/internal/schedule/cron.go b/internal/schedule/cron.go new file mode 100644 index 0000000..6f16361 --- /dev/null +++ b/internal/schedule/cron.go @@ -0,0 +1,168 @@ +package schedule + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type fieldSpec struct { + any bool + values map[int]struct{} +} + +type Expression struct { + minute fieldSpec + hour fieldSpec + day fieldSpec + month fieldSpec + week fieldSpec +} + +func Parse(expr string) (Expression, error) { + fields := strings.Fields(strings.TrimSpace(expr)) + if len(fields) != 5 { + return Expression{}, fmt.Errorf("expected 5 cron fields") + } + minute, err := parseField(fields[0], 0, 59) + if err != nil { + return Expression{}, fmt.Errorf("minute: %w", err) + } + hour, err := parseField(fields[1], 0, 23) + if err != nil { + return Expression{}, fmt.Errorf("hour: %w", err) + } + day, err := parseField(fields[2], 1, 31) + if err != nil { + return Expression{}, fmt.Errorf("day-of-month: %w", err) + } + month, err := parseField(fields[3], 1, 12) + if err != nil { + return Expression{}, fmt.Errorf("month: %w", err) + } + week, err := parseField(fields[4], 0, 6) + if err != nil { + return Expression{}, fmt.Errorf("day-of-week: %w", err) + } + return Expression{ + minute: minute, + hour: hour, + day: day, + month: month, + week: week, + }, nil +} + +func (e Expression) Matches(t time.Time) bool { + if !matchesField(e.minute, t.Minute()) || !matchesField(e.hour, t.Hour()) || !matchesField(e.month, int(t.Month())) { + return false + } + dayMatch := matchesField(e.day, t.Day()) + weekMatch := matchesField(e.week, int(t.Weekday())) + switch { + case e.day.any && e.week.any: + return true + case e.day.any: + return weekMatch + case e.week.any: + return dayMatch + default: + return dayMatch || weekMatch + } +} + +func matchesField(spec fieldSpec, value int) bool { + if spec.any { + return true + } + _, ok := spec.values[value] + return ok +} + +func parseField(expr string, min, max int) (fieldSpec, error) { + if expr == "*" { + return fieldSpec{any: true}, nil + } + values := make(map[int]struct{}) + parts := strings.Split(expr, ",") + for _, part := range parts { + if err := addFieldPart(values, strings.TrimSpace(part), min, max); err != nil { + return fieldSpec{}, err + } + } + if len(values) == 0 { + return fieldSpec{}, fmt.Errorf("no values") + } + return fieldSpec{values: values}, nil +} + +func addFieldPart(values map[int]struct{}, expr string, min, max int) error { + if expr == "" { + return fmt.Errorf("empty field part") + } + step := 1 + base := expr + if strings.Contains(expr, "/") { + pieces := strings.Split(expr, "/") + if len(pieces) != 2 { + return fmt.Errorf("invalid step expression %q", expr) + } + base = pieces[0] + n, err := strconv.Atoi(pieces[1]) + if err != nil || n <= 0 { + return fmt.Errorf("invalid step value %q", pieces[1]) + } + step = n + } + + start, end, err := parseRange(base, min, max) + if err != nil { + return err + } + for value := start; value <= end; value += step { + values[value] = struct{}{} + } + return nil +} + +func parseRange(expr string, min, max int) (int, int, error) { + switch { + case expr == "" || expr == "*": + return min, max, nil + case strings.Contains(expr, "-"): + pieces := strings.Split(expr, "-") + if len(pieces) != 2 { + return 0, 0, fmt.Errorf("invalid range %q", expr) + } + start, err := parseBound(pieces[0], min, max) + if err != nil { + return 0, 0, err + } + end, err := parseBound(pieces[1], min, max) + if err != nil { + return 0, 0, err + } + if end < start { + return 0, 0, fmt.Errorf("descending range %q", expr) + } + return start, end, nil + default: + value, err := parseBound(expr, min, max) + if err != nil { + return 0, 0, err + } + return value, value, nil + } +} + +func parseBound(expr string, min, max int) (int, error) { + value, err := strconv.Atoi(expr) + if err != nil { + return 0, fmt.Errorf("invalid integer %q", expr) + } + if value < min || value > max { + return 0, fmt.Errorf("value %d out of range [%d,%d]", value, min, max) + } + return value, nil +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 5f8dab1..dbe0de7 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -15,6 +15,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/vnovick/itervox/internal/agentactions" "github.com/vnovick/itervox/internal/config" "github.com/vnovick/itervox/internal/domain" ) @@ -737,6 +738,118 @@ func (s *Server) handleDismissInput(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (s *Server) validateAgentActionRequest(w http.ResponseWriter, r *http.Request, action string) (agentactions.Grant, bool) { + if s.actionTokens == nil { + writeError(w, http.StatusNotImplemented, "not_supported", "agent actions are not configured") + return agentactions.Grant{}, false + } + const prefix = "Bearer " + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, prefix) { + writeError(w, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token") + return agentactions.Grant{}, false + } + token := strings.TrimPrefix(auth, prefix) + identifier := chi.URLParam(r, "identifier") + grant, reason, ok := s.actionTokens.Validate(token, identifier, action, time.Now()) + if !ok { + status := http.StatusForbidden + if reason == "missing_token" || reason == "unknown_token" || reason == "expired_token" { + status = http.StatusUnauthorized + } + writeError(w, status, "agent_action_denied", reason) + return agentactions.Grant{}, false + } + return grant, true +} + +func (s *Server) handleAgentComment(w http.ResponseWriter, r *http.Request) { + if _, ok := s.validateAgentActionRequest(w, r, config.AgentActionComment); !ok { + return + } + identifier := chi.URLParam(r, "identifier") + var body struct { + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Body) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "body field required") + return + } + if err := s.client.CommentOnIssue(r.Context(), identifier, body.Body); err != nil { + writeError(w, http.StatusInternalServerError, "comment_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAgentCreateIssue(w http.ResponseWriter, r *http.Request) { + grant, ok := s.validateAgentActionRequest(w, r, config.AgentActionCreateIssue) + if !ok { + return + } + identifier := chi.URLParam(r, "identifier") + var body struct { + Title string `json:"title"` + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "title field required") + return + } + if strings.TrimSpace(grant.CreateIssueState) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "create issue state is not configured for this profile") + return + } + issue, err := s.client.CreateIssue(r.Context(), identifier, body.Title, body.Body, grant.CreateIssueState) + if err != nil { + writeError(w, http.StatusInternalServerError, "create_issue_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "issue": issue}) +} + +func (s *Server) handleAgentMoveState(w http.ResponseWriter, r *http.Request) { + if _, ok := s.validateAgentActionRequest(w, r, config.AgentActionMoveState); !ok { + return + } + identifier := chi.URLParam(r, "identifier") + var body struct { + State string `json:"state"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.State) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "state field required") + return + } + if err := s.client.UpdateIssueState(r.Context(), identifier, body.State); err != nil { + writeError(w, http.StatusInternalServerError, "update_failed", err.Error()) + return + } + select { + case s.refreshChan <- struct{}{}: + default: + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAgentProvideInput(w http.ResponseWriter, r *http.Request) { + if _, ok := s.validateAgentActionRequest(w, r, config.AgentActionProvideInput); !ok { + return + } + identifier := chi.URLParam(r, "identifier") + var body struct { + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Message) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "message field required") + return + } + if ok := s.client.ProvideInput(identifier, body.Message); !ok { + writeError(w, http.StatusNotFound, "not_found", "issue not in input-required state") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (s *Server) handleSetInlineInput(w http.ResponseWriter, r *http.Request) { var body struct { Enabled bool `json:"enabled"` @@ -812,6 +925,10 @@ func (s *Server) handleSetReviewer(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_combination", err.Error()) return } + if errors.Is(err, config.ErrReviewerProfileNotFound) || errors.Is(err, config.ErrReviewerProfileDisabled) { + writeError(w, http.StatusBadRequest, "invalid_profile", err.Error()) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -832,26 +949,55 @@ func (s *Server) handleListModels(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleUpsertProfile(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") var body struct { - Command string `json:"command"` - Prompt string `json:"prompt"` - Backend string `json:"backend"` + Command string `json:"command"` + Prompt string `json:"prompt"` + Backend string `json:"backend"` + Enabled *bool `json:"enabled"` + AllowedActions []string `json:"allowedActions"` + CreateIssueState string `json:"createIssueState"` + OriginalName string `json:"originalName"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Command == "" { writeError(w, http.StatusBadRequest, "bad_request", "command field required") return } - def := ProfileDef{ - Command: body.Command, - Prompt: body.Prompt, - Backend: body.Backend, + if invalid := config.InvalidAgentActions(body.AllowedActions); len(invalid) > 0 { + writeError(w, http.StatusBadRequest, "invalid_allowed_actions", fmt.Sprintf("unknown allowedActions: %s", strings.Join(invalid, ", "))) + return } - if err := s.client.UpsertProfile(name, def); err != nil { + if slicesContains(config.NormalizeAllowedActions(body.AllowedActions), config.AgentActionCreateIssue) && + strings.TrimSpace(body.CreateIssueState) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "createIssueState is required when create_issue is enabled") + return + } + def := ProfileDef{ + Command: body.Command, + Prompt: body.Prompt, + Backend: body.Backend, + Enabled: body.Enabled == nil || *body.Enabled, + AllowedActions: config.NormalizeAllowedActions(body.AllowedActions), + CreateIssueState: strings.TrimSpace(body.CreateIssueState), + } + if err := s.client.UpsertProfile(name, def, strings.TrimSpace(body.OriginalName)); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "already exists") { + writeError(w, http.StatusConflict, "profile_exists", err.Error()) + return + } writeError(w, http.StatusInternalServerError, "upsert_failed", err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func slicesContains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + // handleDeleteProfile removes a named agent profile. // DELETE /api/v1/settings/profiles/{name} func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { @@ -863,6 +1009,93 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (s *Server) handleSetAutomations(w http.ResponseWriter, r *http.Request) { + var body struct { + Automations []AutomationDef `json:"automations"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + profileDefs := s.client.ProfileDefs() + profiles := make(map[string]config.AgentProfile, len(profileDefs)) + for name, def := range profileDefs { + enabled := def.Enabled + profiles[name] = config.AgentProfile{ + Command: def.Command, + Prompt: def.Prompt, + Backend: def.Backend, + Enabled: &enabled, + AllowedActions: config.NormalizeAllowedActions(def.AllowedActions), + CreateIssueState: strings.TrimSpace(def.CreateIssueState), + } + } + if err := config.ValidateAutomations(automationConfigsFromDefs(body.Automations), profiles); err != nil { + writeAutomationValidationError(w, err) + return + } + if err := s.client.SetAutomations(body.Automations); err != nil { + writeError(w, http.StatusInternalServerError, "set_automations_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func automationConfigsFromDefs(defs []AutomationDef) []config.AutomationConfig { + if len(defs) == 0 { + return nil + } + automations := make([]config.AutomationConfig, 0, len(defs)) + for _, def := range defs { + automations = append(automations, config.AutomationConfig{ + ID: def.ID, + Enabled: def.Enabled, + Profile: def.Profile, + Instructions: def.Instructions, + Trigger: config.AutomationTriggerConfig{ + Type: def.Trigger.Type, + Cron: def.Trigger.Cron, + Timezone: def.Trigger.Timezone, + State: def.Trigger.State, + }, + Filter: config.AutomationFilterConfig{ + MatchMode: def.Filter.MatchMode, + States: def.Filter.States, + LabelsAny: def.Filter.LabelsAny, + IdentifierRegex: def.Filter.IdentifierRegex, + Limit: def.Filter.Limit, + InputContextRegex: def.Filter.InputContextRegex, + }, + Policy: config.AutomationPolicyConfig{ + AutoResume: def.Policy.AutoResume, + }, + }) + } + return automations +} + +func writeAutomationValidationError(w http.ResponseWriter, err error) { + msg := err.Error() + switch { + case strings.Contains(msg, "duplicate automation id"): + writeError(w, http.StatusBadRequest, "duplicate_automation_id", msg) + case strings.Contains(msg, "invalid cron"): + writeError(w, http.StatusBadRequest, "invalid_cron", msg) + case strings.Contains(msg, "invalid timezone"): + writeError(w, http.StatusBadRequest, "invalid_timezone", msg) + case strings.Contains(msg, "invalid identifier_regex"), strings.Contains(msg, "invalid input_context_regex"): + writeError(w, http.StatusBadRequest, "invalid_regex", msg) + case strings.Contains(msg, "unsupported trigger type"): + writeError(w, http.StatusBadRequest, "invalid_trigger_type", msg) + case strings.Contains(msg, "filter.match_mode"): + writeError(w, http.StatusBadRequest, "invalid_match_mode", msg) + case strings.Contains(msg, "filter.limit"): + writeError(w, http.StatusBadRequest, "invalid_limit", msg) + default: + writeError(w, http.StatusBadRequest, "bad_request", msg) + } +} + // handleSetAgentMode sets the agent collaboration mode. // POST /api/v1/settings/agent-mode // Body: {"mode": "" | "subagents" | "teams"} diff --git a/internal/server/server.go b/internal/server/server.go index f3204f8..06e6a2d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/vnovick/itervox/internal/agentactions" "github.com/vnovick/itervox/internal/domain" "github.com/go-chi/chi/v5" @@ -111,6 +112,8 @@ type OrchestratorClient interface { ClearSessionSublog(identifier, sessionID string) error FetchSubLogs(identifier string) ([]domain.IssueLogEntry, error) DispatchReviewer(identifier string) error + CommentOnIssue(ctx context.Context, identifier, body string) error + CreateIssue(ctx context.Context, identifier, title, body, stateName string) (*domain.Issue, error) UpdateIssueState(ctx context.Context, identifier, stateName string) error SetWorkers(n int) BumpWorkers(delta int) int @@ -120,8 +123,9 @@ type OrchestratorClient interface { AvailableModels() map[string][]ModelOption ReviewerConfig() (profile string, autoReview bool) SetReviewerConfig(profile string, autoReview bool) error - UpsertProfile(name string, def ProfileDef) error + UpsertProfile(name string, def ProfileDef, originalName string) error DeleteProfile(name string) error + SetAutomations(automations []AutomationDef) error SetAgentMode(mode string) error SetAutoClearWorkspace(enabled bool) error ClearAllWorkspaces() error @@ -139,18 +143,22 @@ type OrchestratorClient interface { // Boolean methods return false; error methods return errNotConfigured. type noopClient struct{} -func (noopClient) FetchIssues(context.Context) ([]TrackerIssue, error) { return nil, errNotConfigured } -func (noopClient) CancelIssue(string) bool { return false } -func (noopClient) ResumeIssue(string) bool { return false } -func (noopClient) TerminateIssue(string) bool { return false } -func (noopClient) ReanalyzeIssue(string) bool { return false } -func (noopClient) FetchLogs(string) []string { return nil } -func (noopClient) ClearLogs(string) error { return errNotConfigured } -func (noopClient) ClearAllLogs() error { return errNotConfigured } -func (noopClient) ClearIssueSubLogs(string) error { return errNotConfigured } -func (noopClient) ClearSessionSublog(string, string) error { return errNotConfigured } -func (noopClient) FetchSubLogs(string) ([]domain.IssueLogEntry, error) { return nil, nil } -func (noopClient) DispatchReviewer(string) error { return errNotConfigured } +func (noopClient) FetchIssues(context.Context) ([]TrackerIssue, error) { return nil, errNotConfigured } +func (noopClient) CancelIssue(string) bool { return false } +func (noopClient) ResumeIssue(string) bool { return false } +func (noopClient) TerminateIssue(string) bool { return false } +func (noopClient) ReanalyzeIssue(string) bool { return false } +func (noopClient) FetchLogs(string) []string { return nil } +func (noopClient) ClearLogs(string) error { return errNotConfigured } +func (noopClient) ClearAllLogs() error { return errNotConfigured } +func (noopClient) ClearIssueSubLogs(string) error { return errNotConfigured } +func (noopClient) ClearSessionSublog(string, string) error { return errNotConfigured } +func (noopClient) FetchSubLogs(string) ([]domain.IssueLogEntry, error) { return nil, nil } +func (noopClient) DispatchReviewer(string) error { return errNotConfigured } +func (noopClient) CommentOnIssue(context.Context, string, string) error { return errNotConfigured } +func (noopClient) CreateIssue(context.Context, string, string, string, string) (*domain.Issue, error) { + return nil, errNotConfigured +} func (noopClient) UpdateIssueState(context.Context, string, string) error { return errNotConfigured } func (noopClient) SetWorkers(int) {} func (noopClient) BumpWorkers(int) int { return 0 } @@ -160,8 +168,9 @@ func (noopClient) ProfileDefs() map[string]ProfileDef { retu func (noopClient) AvailableModels() map[string][]ModelOption { return nil } func (noopClient) ReviewerConfig() (string, bool) { return "", false } func (noopClient) SetReviewerConfig(string, bool) error { return nil } -func (noopClient) UpsertProfile(string, ProfileDef) error { return errNotConfigured } +func (noopClient) UpsertProfile(string, ProfileDef, string) error { return errNotConfigured } func (noopClient) DeleteProfile(string) error { return errNotConfigured } +func (noopClient) SetAutomations([]AutomationDef) error { return errNotConfigured } func (noopClient) SetAgentMode(string) error { return errNotConfigured } func (noopClient) SetAutoClearWorkspace(bool) error { return errNotConfigured } func (noopClient) ClearAllWorkspaces() error { return errNotConfigured } @@ -188,6 +197,8 @@ type FuncClient struct { ClearIssueSubLogsFn func(string) error ClearSessionSublogFn func(string, string) error DispatchReviewerFn func(string) error + CommentOnIssueFn func(context.Context, string, string) error + CreateIssueFn func(context.Context, string, string, string, string) (*domain.Issue, error) UpdateIssueStateFn func(context.Context, string, string) error SetWorkersFn func(int) BumpWorkersFn func(int) int @@ -197,8 +208,9 @@ type FuncClient struct { AvailableModelsFn func() map[string][]ModelOption ReviewerConfigFn func() (string, bool) SetReviewerConfigFn func(string, bool) error - UpsertProfileFn func(string, ProfileDef) error + UpsertProfileFn func(string, ProfileDef, string) error DeleteProfileFn func(string) error + SetAutomationsFn func([]AutomationDef) error SetAgentModeFn func(string) error SetAutoClearWorkspaceFn func(bool) error ClearAllWorkspacesFn func() error @@ -208,6 +220,8 @@ type FuncClient struct { AddSSHHostFn func(string, string) error RemoveSSHHostFn func(string) error SetDispatchStrategyFn func(string) error + ProvideInputFn func(string, string) bool + DismissInputFn func(string) bool } func (c *FuncClient) FetchIssues(ctx context.Context) ([]TrackerIssue, error) { @@ -282,6 +296,18 @@ func (c *FuncClient) DispatchReviewer(id string) error { } return errNotConfigured } +func (c *FuncClient) CommentOnIssue(ctx context.Context, identifier, body string) error { + if c.CommentOnIssueFn != nil { + return c.CommentOnIssueFn(ctx, identifier, body) + } + return errNotConfigured +} +func (c *FuncClient) CreateIssue(ctx context.Context, identifier, title, body, state string) (*domain.Issue, error) { + if c.CreateIssueFn != nil { + return c.CreateIssueFn(ctx, identifier, title, body, state) + } + return nil, errNotConfigured +} func (c *FuncClient) UpdateIssueState(ctx context.Context, id, state string) error { if c.UpdateIssueStateFn != nil { return c.UpdateIssueStateFn(ctx, id, state) @@ -333,9 +359,9 @@ func (c *FuncClient) SetReviewerConfig(profile string, autoReview bool) error { } return nil } -func (c *FuncClient) UpsertProfile(name string, def ProfileDef) error { +func (c *FuncClient) UpsertProfile(name string, def ProfileDef, originalName string) error { if c.UpsertProfileFn != nil { - return c.UpsertProfileFn(name, def) + return c.UpsertProfileFn(name, def, originalName) } return errNotConfigured } @@ -345,6 +371,12 @@ func (c *FuncClient) DeleteProfile(name string) error { } return errNotConfigured } +func (c *FuncClient) SetAutomations(automations []AutomationDef) error { + if c.SetAutomationsFn != nil { + return c.SetAutomationsFn(automations) + } + return errNotConfigured +} func (c *FuncClient) SetAgentMode(mode string) error { if c.SetAgentModeFn != nil { return c.SetAgentModeFn(mode) @@ -393,9 +425,19 @@ func (c *FuncClient) SetDispatchStrategy(strategy string) error { } return errNotConfigured } -func (c *FuncClient) ProvideInput(identifier, message string) bool { return false } -func (c *FuncClient) DismissInput(identifier string) bool { return false } -func (c *FuncClient) SetInlineInput(bool) error { return errNotConfigured } +func (c *FuncClient) ProvideInput(identifier, message string) bool { + if c.ProvideInputFn != nil { + return c.ProvideInputFn(identifier, message) + } + return false +} +func (c *FuncClient) DismissInput(identifier string) bool { + if c.DismissInputFn != nil { + return c.DismissInputFn(identifier) + } + return false +} +func (c *FuncClient) SetInlineInput(bool) error { return errNotConfigured } // StateSnapshot is the payload returned by GET /api/v1/state. type StateSnapshot struct { @@ -458,6 +500,8 @@ type StateSnapshot struct { // InlineInput indicates whether agent input-required signals are posted as // tracker comments (true) or queued in the dashboard UI (false). InlineInput bool `json:"inlineInput,omitempty"` + // Automations is the configured set of lightweight cron or event-driven helper rules. + Automations []AutomationDef `json:"automations,omitempty"` // InputRequired lists issues whose agent is either waiting for human input // or has already received a reply that is pending resume. InputRequired []InputRequiredRow `json:"inputRequired,omitempty"` @@ -482,9 +526,42 @@ type SSHHostInfo struct { // ProfileDef is the JSON representation of one named agent profile. type ProfileDef struct { - Command string `json:"command"` - Prompt string `json:"prompt,omitempty"` - Backend string `json:"backend,omitempty"` + Command string `json:"command"` + Prompt string `json:"prompt,omitempty"` + Backend string `json:"backend,omitempty"` + Enabled bool `json:"enabled"` + AllowedActions []string `json:"allowedActions,omitempty"` + CreateIssueState string `json:"createIssueState,omitempty"` +} + +type AutomationTriggerDef struct { + Type string `json:"type"` + Cron string `json:"cron,omitempty"` + Timezone string `json:"timezone,omitempty"` + State string `json:"state,omitempty"` +} + +type AutomationFilterDef struct { + MatchMode string `json:"matchMode,omitempty"` + States []string `json:"states,omitempty"` + LabelsAny []string `json:"labelsAny,omitempty"` + IdentifierRegex string `json:"identifierRegex,omitempty"` + Limit int `json:"limit,omitempty"` + InputContextRegex string `json:"inputContextRegex,omitempty"` +} + +type AutomationPolicyDef struct { + AutoResume bool `json:"autoResume,omitempty"` +} + +type AutomationDef struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Profile string `json:"profile"` + Instructions string `json:"instructions,omitempty"` + Trigger AutomationTriggerDef `json:"trigger"` + Filter AutomationFilterDef `json:"filter,omitempty"` + Policy AutomationPolicyDef `json:"policy,omitempty"` } // ModelOption represents an available model for a backend (mirrors config.ModelOption for JSON). @@ -597,6 +674,8 @@ type Config struct { // /api/ routes except /api/v1/health. Requests must include the header // "Authorization: Bearer ". APIToken string + // ActionTokenStore validates short-lived per-run grants for agent action routes. + ActionTokenStore *agentactions.Store } // Server is an HTTP server exposing orchestrator state. @@ -610,6 +689,7 @@ type Server struct { projectManager ProjectManager bc *broadcaster apiToken string + actionTokens *agentactions.Store } // New constructs a Server from a Config. Snapshot and RefreshChan must be non-nil. @@ -628,6 +708,7 @@ func New(cfg Config) *Server { projectManager: cfg.ProjectManager, bc: newBroadcaster(), apiToken: cfg.APIToken, + actionTokens: cfg.ActionTokenStore, } s.routes() return s @@ -689,6 +770,10 @@ func (s *Server) routes() { s.router.Route("/api/v1", func(r chi.Router) { // Health check is unauthenticated so load balancers can reach it. r.Get("/health", s.handleHealth) + r.Post("/agent-actions/{identifier}/comment", s.handleAgentComment) + r.Post("/agent-actions/{identifier}/create-issue", s.handleAgentCreateIssue) + r.Post("/agent-actions/{identifier}/move-state", s.handleAgentMoveState) + r.Post("/agent-actions/{identifier}/provide-input", s.handleAgentProvideInput) // If an API token is configured, all remaining routes require it. // Use r.Group to create a sub-router so middleware is applied only to @@ -737,6 +822,7 @@ func (s *Server) routes() { r.Get("/settings/profiles", s.handleListProfiles) r.Put("/settings/profiles/{name}", s.handleUpsertProfile) r.Delete("/settings/profiles/{name}", s.handleDeleteProfile) + r.Put("/settings/automations", s.handleSetAutomations) r.Put("/settings/tracker/states", s.handleUpdateTrackerStates) r.Post("/settings/ssh-hosts", s.handleAddSSHHost) r.Delete("/settings/ssh-hosts/{host}", s.handleRemoveSSHHost) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a268d97..42bef9b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/vnovick/itervox/internal/agentactions" "github.com/vnovick/itervox/internal/config" "github.com/vnovick/itervox/internal/domain" "github.com/vnovick/itervox/internal/server" @@ -267,17 +268,19 @@ func TestSetIssueProfile(t *testing.T) { func TestUpsertProfileIncludesBackend(t *testing.T) { var gotName string var gotDef server.ProfileDef + var gotOriginalName string cfg := makeTestConfig(baseSnap()) cfg.Client = &server.FuncClient{ - UpsertProfileFn: func(name string, def server.ProfileDef) error { + UpsertProfileFn: func(name string, def server.ProfileDef, originalName string) error { gotName = name gotDef = def + gotOriginalName = originalName return nil }, } srv := server.New(cfg) - req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/profiles/codex-fast", bytes.NewBufferString(`{"command":"run-codex-wrapper","prompt":"fast path","backend":"codex"}`)) + req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/profiles/codex-fast", bytes.NewBufferString(`{"command":"run-codex-wrapper","prompt":"fast path","backend":"codex","enabled":false,"allowedActions":["comment","provide_input"],"originalName":"legacy-fast"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.ServeHTTP(w, req) @@ -287,6 +290,9 @@ func TestUpsertProfileIncludesBackend(t *testing.T) { assert.Equal(t, "run-codex-wrapper", gotDef.Command) assert.Equal(t, "fast path", gotDef.Prompt) assert.Equal(t, "codex", gotDef.Backend) + assert.False(t, gotDef.Enabled) + assert.Equal(t, []string{"comment", "provide_input"}, gotDef.AllowedActions) + assert.Equal(t, "legacy-fast", gotOriginalName) } func TestUpdateIssueState(t *testing.T) { @@ -617,7 +623,9 @@ func TestHandleReanalyzeIssue_NotPaused(t *testing.T) { func testServerWithProfiles(t *testing.T) (*server.Server, *map[string]server.ProfileDef) { t.Helper() - defs := map[string]server.ProfileDef{"fast": {Command: "codex", Backend: "codex"}} + defs := map[string]server.ProfileDef{ + "fast": {Command: "codex", Backend: "codex", AllowedActions: []string{"comment"}}, + } cfg := makeTestConfig(baseSnap()) cfg.Client = &server.FuncClient{ ProfileDefsFn: func() map[string]server.ProfileDef { return defs }, @@ -633,6 +641,7 @@ func TestHandleListProfiles_ReturnsProfiles(t *testing.T) { srv.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "fast") + assert.Contains(t, w.Body.String(), "allowedActions") } func TestHandleDeleteProfile_Success(t *testing.T) { @@ -759,6 +768,20 @@ func TestHandleSetReviewer_MissingReviewerProfileReturns400(t *testing.T) { assert.Contains(t, w.Body.String(), "reviewer_profile") } +func TestHandleSetReviewer_InvalidProfileReturns400(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{ + SetReviewerConfigFn: func(string, bool) error { + return config.ErrReviewerProfileNotFound + }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/reviewer", `{"profile":"missing","auto_review":false}`) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid_profile") +} + // ─── handleListProjects / handleGetProjectFilter / handleSetProjectFilter ───── type fakeProjectManager struct { @@ -1550,11 +1573,167 @@ func TestHandleDismissInput_NotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } +func TestHandleAgentComment_Success(t *testing.T) { + store := agentactions.NewStore() + token, err := store.Issue("ENG-1", "run-1", []string{config.AgentActionComment}, "", time.Minute) + require.NoError(t, err) + + var gotIdentifier, gotBody string + cfg := makeTestConfig(baseSnap()) + cfg.ActionTokenStore = store + cfg.Client = &server.FuncClient{ + CommentOnIssueFn: func(_ context.Context, identifier, body string) error { + gotIdentifier = identifier + gotBody = body + return nil + }, + } + srv := server.New(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-actions/ENG-1/comment", bytes.NewBufferString(`{"body":"hello from agent"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ENG-1", gotIdentifier) + assert.Equal(t, "hello from agent", gotBody) +} + +func TestHandleAgentProvideInput_ForbiddenWithoutPermission(t *testing.T) { + store := agentactions.NewStore() + token, err := store.Issue("ENG-1", "run-1", []string{config.AgentActionComment}, "", time.Minute) + require.NoError(t, err) + + var called bool + cfg := makeTestConfig(baseSnap()) + cfg.ActionTokenStore = store + cfg.Client = &server.FuncClient{ + ProvideInputFn: func(string, string) bool { + called = true + return true + }, + } + srv := server.New(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-actions/ENG-1/provide-input", bytes.NewBufferString(`{"message":"continue"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.False(t, called) + assert.Contains(t, w.Body.String(), "agent_action_denied") +} + +func TestHandleAgentMoveState_MissingTokenReturns401(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.ActionTokenStore = agentactions.NewStore() + cfg.Client = &server.FuncClient{ + UpdateIssueStateFn: func(context.Context, string, string) error { return nil }, + } + srv := server.New(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-actions/ENG-1/move-state", bytes.NewBufferString(`{"state":"Done"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "unauthorized") +} + +func TestHandleAgentMoveState_QueuesRefresh(t *testing.T) { + store := agentactions.NewStore() + token, err := store.Issue("ENG-1", "run-1", []string{config.AgentActionMoveState}, "", time.Minute) + require.NoError(t, err) + + refresh := make(chan struct{}, 1) + cfg := makeTestConfig(baseSnap()) + cfg.ActionTokenStore = store + cfg.RefreshChan = refresh + cfg.Client = &server.FuncClient{ + UpdateIssueStateFn: func(context.Context, string, string) error { return nil }, + } + srv := server.New(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-actions/ENG-1/move-state", bytes.NewBufferString(`{"state":"Done"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + select { + case <-refresh: + default: + t.Fatal("expected agent move-state to queue refresh") + } +} + +func TestHandleAgentCreateIssue_Success(t *testing.T) { + store := agentactions.NewStore() + token, err := store.Issue("ENG-1", "run-1", []string{config.AgentActionCreateIssue}, "Todo", time.Minute) + require.NoError(t, err) + + var gotIdentifier string + var gotTitle string + var gotBody string + var gotState string + cfg := makeTestConfig(baseSnap()) + cfg.ActionTokenStore = store + cfg.Client = &server.FuncClient{ + CreateIssueFn: func(_ context.Context, identifier, title, body, state string) (*domain.Issue, error) { + gotIdentifier = identifier + gotTitle = title + gotBody = body + gotState = state + return &domain.Issue{Identifier: "ENG-2", Title: title, State: state}, nil + }, + } + srv := server.New(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-actions/ENG-1/create-issue", bytes.NewBufferString(`{"title":"Follow-up","body":"Add regression coverage","state":"Todo"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ENG-1", gotIdentifier) + assert.Equal(t, "Follow-up", gotTitle) + assert.Equal(t, "Add regression coverage", gotBody) + assert.Equal(t, "Todo", gotState) + assert.Contains(t, w.Body.String(), "ENG-2") +} + +func TestHandleAgentCreateIssue_MissingConfiguredStateReturns400(t *testing.T) { + store := agentactions.NewStore() + token, err := store.Issue("ENG-1", "run-1", []string{config.AgentActionCreateIssue}, "", time.Minute) + require.NoError(t, err) + + cfg := makeTestConfig(baseSnap()) + cfg.ActionTokenStore = store + cfg.Client = &server.FuncClient{} + srv := server.New(cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-actions/ENG-1/create-issue", bytes.NewBufferString(`{"title":"Follow-up"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "create issue state is not configured") +} + // ─── handleUpsertProfile edge cases ────────────────────────────────────────── func TestHandleUpsertProfile_MissingCommand_Returns400(t *testing.T) { cfg := makeTestConfig(baseSnap()) - cfg.Client = &server.FuncClient{UpsertProfileFn: func(string, server.ProfileDef) error { return nil }} + cfg.Client = &server.FuncClient{UpsertProfileFn: func(string, server.ProfileDef, string) error { return nil }} srv := server.New(cfg) w := putJSON(t, srv, "/api/v1/settings/profiles/test", `{"prompt":"hi"}`) assert.Equal(t, http.StatusBadRequest, w.Code) @@ -1563,22 +1742,206 @@ func TestHandleUpsertProfile_MissingCommand_Returns400(t *testing.T) { func TestHandleUpsertProfile_InvalidJSON_Returns400(t *testing.T) { cfg := makeTestConfig(baseSnap()) - cfg.Client = &server.FuncClient{UpsertProfileFn: func(string, server.ProfileDef) error { return nil }} + cfg.Client = &server.FuncClient{UpsertProfileFn: func(string, server.ProfileDef, string) error { return nil }} srv := server.New(cfg) w := putJSON(t, srv, "/api/v1/settings/profiles/test", `not-json`) assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestHandleUpsertProfile_InvalidAllowedActions_Returns400(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{UpsertProfileFn: func(string, server.ProfileDef, string) error { return nil }} + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/profiles/test", `{"command":"claude","allowedActions":["comment","hack_the_daemon"]}`) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid_allowed_actions") +} + +func TestHandleUpsertProfile_CreateIssueRequiresState(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{UpsertProfileFn: func(string, server.ProfileDef, string) error { return nil }} + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/profiles/test", `{"command":"claude","allowedActions":["create_issue"]}`) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "createIssueState") +} + func TestHandleUpsertProfile_ServerError(t *testing.T) { cfg := makeTestConfig(baseSnap()) cfg.Client = &server.FuncClient{ - UpsertProfileFn: func(string, server.ProfileDef) error { return errors.New("disk full") }, + UpsertProfileFn: func(string, server.ProfileDef, string) error { return errors.New("disk full") }, } srv := server.New(cfg) w := putJSON(t, srv, "/api/v1/settings/profiles/test", `{"command":"claude"}`) assert.Equal(t, http.StatusInternalServerError, w.Code) } +func TestHandleUpsertProfile_ConflictReturns409(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{ + UpsertProfileFn: func(string, server.ProfileDef, string) error { + return errors.New(`profile "pm" already exists`) + }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/profiles/pm", `{"command":"claude","originalName":"qa"}`) + + assert.Equal(t, http.StatusConflict, w.Code) + assert.Contains(t, w.Body.String(), "already exists") +} + +func TestHandleSetAutomations_InvalidCronReturns400(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{ + ProfileDefsFn: func() map[string]server.ProfileDef { + return map[string]server.ProfileDef{ + "reviewer": {Command: "claude", Enabled: true}, + } + }, + SetAutomationsFn: func([]server.AutomationDef) error { return nil }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/automations", `{"automations":[{"id":"nightly","enabled":true,"profile":"reviewer","trigger":{"type":"cron","cron":"not-a-cron"},"filter":{}}]}`) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid_cron") +} + +func TestHandleSetAutomations_InputRequiredAcceptsNoCron(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + called := false + cfg.Client = &server.FuncClient{ + ProfileDefsFn: func() map[string]server.ProfileDef { + return map[string]server.ProfileDef{ + "input-responder": {Command: "claude", Enabled: true}, + } + }, + SetAutomationsFn: func(entries []server.AutomationDef) error { + called = true + require.Len(t, entries, 1) + assert.Equal(t, "input_required", entries[0].Trigger.Type) + assert.Equal(t, "input-responder", entries[0].Profile) + assert.Equal(t, "continue|branch", entries[0].Filter.InputContextRegex) + return nil + }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/automations", `{"automations":[{"id":"input-responder","enabled":true,"profile":"input-responder","instructions":"Answer blocked-run questions.","trigger":{"type":"input_required"},"filter":{"inputContextRegex":"continue|branch"}}]}`) + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, called) +} + +func TestHandleSetAutomations_IssueEnteredStateRequiresTriggerState(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{ + ProfileDefsFn: func() map[string]server.ProfileDef { + return map[string]server.ProfileDef{ + "qa": {Command: "claude", Enabled: true}, + } + }, + SetAutomationsFn: func([]server.AutomationDef) error { return nil }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/automations", `{"automations":[{"id":"qa-entry","enabled":true,"profile":"qa","trigger":{"type":"issue_entered_state"}}]}`) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "trigger.state") +} + +func TestHandleSetAutomations_AcceptsExpandedTriggersAndMatchMode(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + called := false + cfg.Client = &server.FuncClient{ + ProfileDefsFn: func() map[string]server.ProfileDef { + return map[string]server.ProfileDef{ + "pm": {Command: "claude", Enabled: true}, + "qa": {Command: "claude", Enabled: true}, + "reviewer": {Command: "claude", Enabled: true}, + } + }, + SetAutomationsFn: func(entries []server.AutomationDef) error { + called = true + require.Len(t, entries, 4) + assert.Equal(t, "tracker_comment_added", entries[0].Trigger.Type) + assert.Equal(t, "any", entries[0].Filter.MatchMode) + assert.Equal(t, "issue_entered_state", entries[1].Trigger.Type) + assert.Equal(t, "Ready for QA", entries[1].Trigger.State) + assert.Equal(t, "issue_moved_to_backlog", entries[2].Trigger.Type) + assert.Equal(t, "run_failed", entries[3].Trigger.Type) + return nil + }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/automations", `{"automations":[ + {"id":"comment-watch","enabled":true,"profile":"pm","trigger":{"type":"tracker_comment_added"},"filter":{"matchMode":"any","labelsAny":["triage"]}}, + {"id":"qa-entry","enabled":true,"profile":"qa","trigger":{"type":"issue_entered_state","state":"Ready for QA"}}, + {"id":"backlog-watch","enabled":true,"profile":"pm","trigger":{"type":"issue_moved_to_backlog"}}, + {"id":"failed-run","enabled":true,"profile":"reviewer","trigger":{"type":"run_failed"}} + ]}`) + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, called) +} + +func TestHandleSetAutomations_RejectsDuplicateIDs(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{ + SetAutomationsFn: func([]server.AutomationDef) error { + t.Fatal("SetAutomations should not be called when duplicate IDs are submitted") + return nil + }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/automations", `{"automations":[ + {"id":"duplicate","enabled":true,"profile":"pm","trigger":{"type":"tracker_comment_added"}}, + {"id":"duplicate","enabled":true,"profile":"qa","trigger":{"type":"run_failed"}} + ]}`) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "duplicate automation id") +} + +func TestHandleSetAutomations_RejectsInvalidRegex(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{ + ProfileDefsFn: func() map[string]server.ProfileDef { + return map[string]server.ProfileDef{ + "pm": {Command: "claude", Enabled: true}, + } + }, + SetAutomationsFn: func([]server.AutomationDef) error { + t.Fatal("SetAutomations should not be called for invalid regex") + return nil + }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/automations", `{"automations":[ + {"id":"comment-watch","enabled":true,"profile":"pm","trigger":{"type":"tracker_comment_added"},"filter":{"identifierRegex":"["}} + ]}`) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid_regex") +} + +func TestHandleSetAutomations_RejectsDisabledProfile(t *testing.T) { + cfg := makeTestConfig(baseSnap()) + cfg.Client = &server.FuncClient{ + ProfileDefsFn: func() map[string]server.ProfileDef { + return map[string]server.ProfileDef{ + "pm": {Command: "claude", Enabled: false}, + } + }, + SetAutomationsFn: func([]server.AutomationDef) error { + t.Fatal("SetAutomations should not be called for disabled profiles") + return nil + }, + } + srv := server.New(cfg) + w := putJSON(t, srv, "/api/v1/settings/automations", `{"automations":[ + {"id":"comment-watch","enabled":true,"profile":"pm","trigger":{"type":"tracker_comment_added"}} + ]}`) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "disabled profile") +} + // ─── handleUpdateTrackerStates edge cases ──────────────────────────────────── func TestHandleUpdateTrackerStates_EmptyActiveStates_Returns400(t *testing.T) { diff --git a/internal/tracker/github/client.go b/internal/tracker/github/client.go index 2c280ca..624c2c0 100644 --- a/internal/tracker/github/client.go +++ b/internal/tracker/github/client.go @@ -504,6 +504,52 @@ func (c *Client) CreateComment(ctx context.Context, issueID, body string) (*doma return comment, nil } +// CreateIssue creates a new GitHub issue in the configured repository. The +// sourceIssueID is accepted for tracker interface parity but not otherwise used. +func (c *Client) CreateIssue(ctx context.Context, _ string, title, body, stateName string) (*domain.Issue, error) { + u := fmt.Sprintf("%s/repos/%s/%s/issues", c.cfg.Endpoint, c.owner, c.repo) + payloadBody := map[string]any{ + "title": title, + "body": body, + } + if stateName = strings.TrimSpace(stateName); stateName != "" { + payloadBody["labels"] = []string{stateName} + } + payload, err := json.Marshal(payloadBody) + if err != nil { + return nil, fmt.Errorf("github_create_issue: marshal: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("github_create_issue: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("github_create_issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("github_create_issue: status %d", resp.StatusCode) + } + var raw map[string]any + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("github_create_issue: decode body: %w", err) + } + derived := deriveState(raw, c.cfg.ActiveStates, c.cfg.TerminalStates) + if derived == "" { + derived = stateName + } + issue := normalizeIssue(raw, derived) + if issue == nil { + return nil, fmt.Errorf("github_create_issue: missing issue fields in response") + } + return issue, nil +} + func (c *Client) get(ctx context.Context, url string) (any, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/internal/tracker/github/client_test.go b/internal/tracker/github/client_test.go index fb7568d..8950909 100644 --- a/internal/tracker/github/client_test.go +++ b/internal/tracker/github/client_test.go @@ -591,3 +591,31 @@ func TestGHMissingPageLinkError(t *testing.T) { assert.NoError(t, err) assert.Empty(t, url) } + +func TestGHCreateIssue(t *testing.T) { + var gotMethod string + var gotPath string + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(ghIssue(7, "Follow-up", "open", []string{"todo"})) + })) + defer ts.Close() + + client := ghclient.NewClient(defaultConfig(ts.URL)) + issue, err := client.CreateIssue(context.Background(), "123", "Follow-up", "Add regression coverage", "todo") + require.NoError(t, err) + require.NotNil(t, issue) + assert.Equal(t, http.MethodPost, gotMethod) + assert.Equal(t, "/repos/owner/repo/issues", gotPath) + assert.Equal(t, "Follow-up", gotBody["title"]) + assert.Equal(t, "Add regression coverage", gotBody["body"]) + assert.Equal(t, []any{"todo"}, gotBody["labels"]) + assert.Equal(t, "#7", issue.Identifier) + assert.Equal(t, "todo", issue.State) +} diff --git a/internal/tracker/linear/client.go b/internal/tracker/linear/client.go index 031db0a..3fbc760 100644 --- a/internal/tracker/linear/client.go +++ b/internal/tracker/linear/client.go @@ -9,6 +9,7 @@ import ( "maps" "net/http" "strconv" + "strings" "sync" "time" @@ -346,6 +347,103 @@ mutation ItervoxCreateComment($issueId: String!, $body: String!) { return nil, fmt.Errorf("linear_create_comment: unexpected response: %v", resp) } +// CreateIssue creates a follow-up issue in the same team/project context as +// sourceIssueID and assigns it to the configured state/column. +func (c *Client) CreateIssue(ctx context.Context, sourceIssueID, title, body, stateName string) (*domain.Issue, error) { + contextResp, err := c.graphql(ctx, QueryCreateIssueContext, map[string]any{"id": sourceIssueID}) + if err != nil { + return nil, fmt.Errorf("linear_create_issue: resolve context: %w", err) + } + data, ok := contextResp["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("linear_create_issue: missing data") + } + sourceIssue, ok := data["issue"].(map[string]any) + if !ok { + return nil, fmt.Errorf("linear_create_issue: missing issue context") + } + team, ok := sourceIssue["team"].(map[string]any) + if !ok { + return nil, fmt.Errorf("linear_create_issue: missing team context") + } + teamID, _ := team["id"].(string) + if teamID == "" { + return nil, fmt.Errorf("linear_create_issue: empty team id") + } + states, _ := team["states"].(map[string]any) + nodes, _ := states["nodes"].([]any) + stateID := "" + for _, rawNode := range nodes { + node, ok := rawNode.(map[string]any) + if !ok { + continue + } + if strings.EqualFold(strings.TrimSpace(stringValue(node["name"])), stateName) { + stateID = stringValue(node["id"]) + break + } + } + if stateID == "" { + return nil, fmt.Errorf("linear_create_issue: state %q not found in source issue team", stateName) + } + + input := map[string]any{ + "teamId": teamID, + "title": title, + "description": body, + "stateId": stateID, + } + if project, ok := sourceIssue["project"].(map[string]any); ok { + if projectID, _ := project["id"].(string); projectID != "" { + input["projectId"] = projectID + } + } + + const mutation = ` +mutation ItervoxCreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + issue { + id + identifier + title + description + priority + state { name } + branchName + url + labels { nodes { name } } + inverseRelations(first: 50) { + nodes { + type + issue { id identifier state { name } } + } + } + createdAt + updatedAt + } + success + } +}` + + resp, err := c.graphql(ctx, mutation, map[string]any{"input": input}) + if err != nil { + return nil, fmt.Errorf("linear_create_issue: issueCreate: %w", err) + } + if data, ok := resp["data"].(map[string]any); ok { + if created, ok := data["issueCreate"].(map[string]any); ok { + if success, _ := created["success"].(bool); success { + if issueNode, ok := created["issue"].(map[string]any); ok { + issue := normalizeIssue(issueNode) + if issue != nil { + return issue, nil + } + } + } + } + } + return nil, fmt.Errorf("linear_create_issue: unexpected response: %v", resp) +} + // SetIssueBranch updates the branchName field on the Linear issue so retried // workers can resume from the correct branch. func (c *Client) SetIssueBranch(ctx context.Context, issueID, branchName string) error { diff --git a/internal/tracker/linear/client_test.go b/internal/tracker/linear/client_test.go index 8aa2249..735a235 100644 --- a/internal/tracker/linear/client_test.go +++ b/internal/tracker/linear/client_test.go @@ -712,6 +712,64 @@ func TestCreateCommentFailure(t *testing.T) { assert.Contains(t, err.Error(), "linear_create_comment") } +func TestCreateIssue(t *testing.T) { + contextResp := map[string]interface{}{ + "data": map[string]interface{}{ + "issue": map[string]interface{}{ + "team": map[string]interface{}{ + "id": "team-1", + "states": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{"id": "state-1", "name": "Todo"}, + map[string]interface{}{"id": "state-2", "name": "Done"}, + }, + }, + }, + "project": map[string]interface{}{"id": "project-1"}, + }, + }, + } + createResp := map[string]interface{}{ + "data": map[string]interface{}{ + "issueCreate": map[string]interface{}{ + "success": true, + "issue": map[string]interface{}{ + "id": "issue-2", + "identifier": "ENG-2", + "title": "Follow-up", + "description": "Add regression coverage", + "priority": float64(0), + "state": map[string]interface{}{"name": "Todo"}, + "labels": map[string]interface{}{"nodes": []interface{}{}}, + "inverseRelations": map[string]interface{}{"nodes": []interface{}{}}, + "url": "https://linear.app/issue/ENG-2", + "createdAt": "2026-04-15T10:00:00Z", + "updatedAt": "2026-04-15T10:00:00Z", + }, + }, + }, + } + srv := queryDispatcher(t, map[string]interface{}{ + "ItervoxResolveCreateIssueContext": contextResp, + "ItervoxCreateIssue": createResp, + }) + defer srv.Close() + + client := linear.NewClient(linear.ClientConfig{ + APIKey: "test-key", + Endpoint: srv.URL, + }) + + issue, err := client.CreateIssue(context.Background(), "issue-1", "Follow-up", "Add regression coverage", "Todo") + require.NoError(t, err) + require.NotNil(t, issue) + assert.Equal(t, "ENG-2", issue.Identifier) + assert.Equal(t, "Follow-up", issue.Title) + assert.Equal(t, "Todo", issue.State) + require.NotNil(t, issue.Description) + assert.Equal(t, "Add regression coverage", *issue.Description) +} + // --------------------------------------------------------------------------- // SetIssueBranch // --------------------------------------------------------------------------- diff --git a/internal/tracker/linear/queries.go b/internal/tracker/linear/queries.go index 7ab8046..41ef3d0 100644 --- a/internal/tracker/linear/queries.go +++ b/internal/tracker/linear/queries.go @@ -63,6 +63,26 @@ query ItervoxIssueDetail($id: String!) { } }` +// QueryCreateIssueContext fetches the source issue's team and project so a +// follow-up issue can be created in the same workspace context. +const QueryCreateIssueContext = ` +query ItervoxResolveCreateIssueContext($id: String!) { + issue(id: $id) { + team { + id + states { + nodes { + id + name + } + } + } + project { + id + } + } +}` + // QueryCandidateIssuesAll fetches paginated issues by state only — no project filter. // Used when the runtime project filter is set to "all issues". const QueryCandidateIssuesAll = ` diff --git a/internal/tracker/managed_comments.go b/internal/tracker/managed_comments.go new file mode 100644 index 0000000..cb7fb3f --- /dev/null +++ b/internal/tracker/managed_comments.go @@ -0,0 +1,21 @@ +package tracker + +import ( + "strings" + + "github.com/vnovick/itervox/internal/domain" +) + +const ManagedCommentMarker = "" + +func MarkManagedComment(body string) string { + trimmed := strings.TrimSpace(body) + if trimmed == "" || strings.Contains(trimmed, ManagedCommentMarker) { + return trimmed + } + return trimmed + "\n\n" + ManagedCommentMarker +} + +func IsManagedComment(comment domain.Comment) bool { + return strings.Contains(comment.Body, ManagedCommentMarker) +} diff --git a/internal/tracker/memory.go b/internal/tracker/memory.go index 55304b9..b2347ab 100644 --- a/internal/tracker/memory.go +++ b/internal/tracker/memory.go @@ -18,6 +18,7 @@ type MemoryTracker struct { terminalStates []string injectedError error nextCommentID int + nextIssueID int } // NewMemoryTracker constructs a MemoryTracker with the given issues and state config. @@ -28,6 +29,7 @@ func NewMemoryTracker(issues []domain.Issue, activeStates, terminalStates []stri issues: cp, activeStates: activeStates, terminalStates: terminalStates, + nextIssueID: maxIssueSuffix(cp), } } @@ -113,9 +115,10 @@ func (m *MemoryTracker) FetchIssueStatesByIDs(ctx context.Context, issueIDs []st return result, nil } -// CreateComment fabricates a tracker comment for tests that care about exact -// comment IDs and author identity. It does not mutate stored issues. -func (m *MemoryTracker) CreateComment(_ context.Context, _, body string) (*domain.Comment, error) { +// CreateComment fabricates a tracker comment for tests and persists it on the +// in-memory issue so local/demo comment-driven flows round-trip through +// FetchIssueDetail. +func (m *MemoryTracker) CreateComment(_ context.Context, issueID, body string) (*domain.Comment, error) { m.mu.Lock() defer m.mu.Unlock() m.nextCommentID++ @@ -125,9 +128,40 @@ func (m *MemoryTracker) CreateComment(_ context.Context, _, body string) (*domai AuthorID: "memory-tracker", AuthorName: "Itervox", } + for i := range m.issues { + if m.issues[i].ID != issueID { + continue + } + m.issues[i].Comments = append(m.issues[i].Comments, *comment) + break + } return comment, nil } +// CreateIssue creates a new in-memory issue for tests and local/demo flows. +func (m *MemoryTracker) CreateIssue(_ context.Context, _ string, title, body, stateName string) (*domain.Issue, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextIssueID++ + id := "id-" + strconv.Itoa(m.nextIssueID) + identifier := "ENG-" + strconv.Itoa(m.nextIssueID) + var description *string + if strings.TrimSpace(body) != "" { + desc := body + description = &desc + } + issue := domain.Issue{ + ID: id, + Identifier: identifier, + Title: title, + State: stateName, + Description: description, + } + m.issues = append(m.issues, issue) + cp := issue + return &cp, nil +} + // UpdateIssueState updates the in-memory state for testing. func (m *MemoryTracker) UpdateIssueState(_ context.Context, issueID, stateName string) error { m.SetIssueState(issueID, stateName) @@ -183,3 +217,23 @@ func (m *MemoryTracker) isActive(state string) bool { } return false } + +func maxIssueSuffix(issues []domain.Issue) int { + maxSuffix := 0 + for _, issue := range issues { + maxSuffix = max(maxSuffix, issueNumericSuffix(issue.ID, "id-")) + maxSuffix = max(maxSuffix, issueNumericSuffix(issue.Identifier, "ENG-")) + } + return maxSuffix +} + +func issueNumericSuffix(value, prefix string) int { + if !strings.HasPrefix(value, prefix) { + return 0 + } + suffix, err := strconv.Atoi(strings.TrimPrefix(value, prefix)) + if err != nil || suffix < 0 { + return 0 + } + return suffix +} diff --git a/internal/tracker/memory_test.go b/internal/tracker/memory_test.go index 0facdf9..2583d74 100644 --- a/internal/tracker/memory_test.go +++ b/internal/tracker/memory_test.go @@ -161,6 +161,54 @@ func TestMemoryTrackerSetIssueBranchUnknownID(t *testing.T) { require.NoError(t, err) } +func TestMemoryTrackerCreateIssue(t *testing.T) { + issues := []domain.Issue{makeIssue("id-1", "ENG-1", "In Progress")} + mem := tracker.NewMemoryTracker(issues, []string{"Todo", "In Progress"}, []string{"Done"}) + + created, err := mem.CreateIssue(context.Background(), "id-1", "Follow-up", "Add regression test", "Todo") + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, "Follow-up", created.Title) + assert.Equal(t, "Todo", created.State) + require.NotNil(t, created.Description) + assert.Equal(t, "Add regression test", *created.Description) + + fetched, err := mem.FetchIssueByIdentifier(context.Background(), created.Identifier) + require.NoError(t, err) + require.NotNil(t, fetched) + assert.Equal(t, created.Identifier, fetched.Identifier) + assert.Equal(t, "Todo", fetched.State) +} + +func TestMemoryTrackerCreateIssueUsesMaxExistingSuffix(t *testing.T) { + issues := []domain.Issue{ + makeIssue("id-2", "ENG-2", "Todo"), + makeIssue("id-9", "ENG-9", "Todo"), + } + mem := tracker.NewMemoryTracker(issues, []string{"Todo"}, []string{"Done"}) + + created, err := mem.CreateIssue(context.Background(), "id-9", "Follow-up", "", "Todo") + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, "id-10", created.ID) + assert.Equal(t, "ENG-10", created.Identifier) +} + +func TestMemoryTrackerCreateCommentPersistsOnIssue(t *testing.T) { + issues := []domain.Issue{makeIssue("id-1", "ENG-1", "Todo")} + mem := tracker.NewMemoryTracker(issues, nil, nil) + + comment, err := mem.CreateComment(context.Background(), "id-1", "a comment") + require.NoError(t, err) + require.NotNil(t, comment) + + issue, err := mem.FetchIssueDetail(context.Background(), "id-1") + require.NoError(t, err) + require.Len(t, issue.Comments, 1) + assert.Equal(t, comment.ID, issue.Comments[0].ID) + assert.Equal(t, "a comment", issue.Comments[0].Body) +} + func TestMemoryTrackerFetchIssueDetail(t *testing.T) { issues := []domain.Issue{makeIssue("id-1", "ENG-1", "Todo")} mem := tracker.NewMemoryTracker(issues, nil, nil) diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go index 8dec9bc..c884203 100644 --- a/internal/tracker/tracker.go +++ b/internal/tracker/tracker.go @@ -60,6 +60,10 @@ type Tracker interface { // non-fatal — callers should not abort a session on comment failure. CreateComment(ctx context.Context, issueID, body string) (*domain.Comment, error) + // CreateIssue creates a follow-up issue in the same tracker/project context as + // sourceIssueID and returns the created issue when the adapter can provide it. + CreateIssue(ctx context.Context, sourceIssueID, title, body, stateName string) (*domain.Issue, error) + // UpdateIssueState transitions the issue to the named state. // For Linear this resolves the state name to an ID; for GitHub it manages labels. // Errors are logged and non-fatal. diff --git a/internal/workflow/loader.go b/internal/workflow/loader.go index 8934a15..c3e012c 100644 --- a/internal/workflow/loader.go +++ b/internal/workflow/loader.go @@ -311,6 +311,43 @@ type ProfileEntry struct { Prompt string // Backend is an optional explicit runner selection override. Backend string + // Enabled controls whether the profile is selectable and dispatchable. + // Nil means omit the field from WORKFLOW.md, which defaults to true. + Enabled *bool + // AllowedActions is the optional allowlist of daemon-backed agent actions. + AllowedActions []string + // CreateIssueState is the target tracker state/column for the create_issue action. + CreateIssueState string +} + +type AutomationTriggerEntry struct { + Type string + Cron string + Timezone string + State string +} + +type AutomationFilterEntry struct { + MatchMode string + States []string + LabelsAny []string + IdentifierRegex string + Limit int + InputContextRegex string +} + +type AutomationPolicyEntry struct { + AutoResume bool +} + +type AutomationEntry struct { + ID string + Enabled bool + Profile string + Instructions string + Trigger AutomationTriggerEntry + Filter AutomationFilterEntry + Policy AutomationPolicyEntry } // PatchProfilesBlock replaces (or inserts) the agent.profiles block in the YAML @@ -378,6 +415,21 @@ func PatchProfilesBlock(path string, profiles map[string]ProfileEntry) error { if entry.Backend != "" { replacement = append(replacement, " backend: "+entry.Backend) } + if entry.Enabled != nil && !*entry.Enabled { + replacement = append(replacement, " enabled: false") + } + if len(entry.AllowedActions) > 0 { + replacement = append(replacement, " allowed_actions:") + for _, action := range entry.AllowedActions { + if action == "" { + continue + } + replacement = append(replacement, " - "+action) + } + } + if entry.CreateIssueState != "" { + replacement = append(replacement, " create_issue_state: "+strconv.Quote(entry.CreateIssueState)) + } if entry.Prompt != "" { replacement = append(replacement, " prompt: "+strconv.Quote(entry.Prompt)) } @@ -439,6 +491,156 @@ func PatchProfilesBlock(path string, profiles map[string]ProfileEntry) error { return os.WriteFile(path, []byte(sb.String()), 0o644) } +// PatchAutomationsBlock replaces (or inserts) the top-level automations block in +// the YAML front matter of the file at path. Passing nil or an empty slice +// removes the automations block entirely. Legacy schedules blocks are removed +// when writing automations so the file has a single source of truth. +func PatchAutomationsBlock(path string, automations []AutomationEntry) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("workflow patch automations: read %s: %w", path, err) + } + content := strings.ReplaceAll(string(data), "\r\n", "\n") + frontLines, bodyLines := splitFrontMatter(content) + if frontLines == nil { + return fmt.Errorf("workflow patch automations: no front matter found in %s", path) + } + + automationsStart := -1 + automationsEnd := -1 + legacySchedulesStart := -1 + legacySchedulesEnd := -1 + for i, line := range frontLines { + if line != "automations:" && line != "schedules:" { + continue + } + if line == "automations:" { + automationsStart = i + } else { + legacySchedulesStart = i + } + j := i + 1 + for j < len(frontLines) { + l := frontLines[j] + if l == "" { + j++ + continue + } + trimmed := strings.TrimLeft(l, " ") + indent := len(l) - len(trimmed) + if indent > 0 { + j++ + } else { + break + } + } + if line == "automations:" { + automationsEnd = j + } else { + legacySchedulesEnd = j + } + } + + var replacement []string + if len(automations) > 0 { + replacement = append(replacement, "automations:") + for _, automation := range automations { + replacement = append(replacement, " - id: "+automation.ID) + replacement = append(replacement, " enabled: "+strconv.FormatBool(automation.Enabled)) + replacement = append(replacement, " profile: "+automation.Profile) + if automation.Instructions != "" { + replacement = append(replacement, " instructions: "+strconv.Quote(automation.Instructions)) + } + replacement = append(replacement, " trigger:") + replacement = append(replacement, " type: "+automation.Trigger.Type) + if automation.Trigger.Cron != "" { + replacement = append(replacement, " cron: "+strconv.Quote(automation.Trigger.Cron)) + } + if automation.Trigger.Timezone != "" { + replacement = append(replacement, " timezone: "+strconv.Quote(automation.Trigger.Timezone)) + } + if automation.Trigger.State != "" { + replacement = append(replacement, " state: "+strconv.Quote(automation.Trigger.State)) + } + filterLines := buildAutomationFilterLines(automation.Filter) + if len(filterLines) > 0 { + replacement = append(replacement, " filter:") + replacement = append(replacement, filterLines...) + } + policyLines := buildAutomationPolicyLines(automation.Policy) + if len(policyLines) > 0 { + replacement = append(replacement, " policy:") + replacement = append(replacement, policyLines...) + } + } + } + + // Remove either existing block if present, preferring the new automations block. + var newFrontLines []string + switch { + case automationsStart >= 0: + newFrontLines = append(newFrontLines, frontLines[:automationsStart]...) + newFrontLines = append(newFrontLines, replacement...) + newFrontLines = append(newFrontLines, frontLines[automationsEnd:]...) + case legacySchedulesStart >= 0: + newFrontLines = append(newFrontLines, frontLines[:legacySchedulesStart]...) + newFrontLines = append(newFrontLines, replacement...) + newFrontLines = append(newFrontLines, frontLines[legacySchedulesEnd:]...) + case len(automations) > 0: + newFrontLines = append(frontLines, replacement...) + default: + return nil + } + + var sb strings.Builder + sb.WriteString("---\n") + sb.WriteString(strings.Join(newFrontLines, "\n")) + sb.WriteString("\n---\n") + sb.WriteString(strings.Join(bodyLines, "\n")) + if len(bodyLines) > 0 && bodyLines[len(bodyLines)-1] != "" { + sb.WriteString("\n") + } + return os.WriteFile(path, []byte(sb.String()), 0o644) +} + +func buildAutomationFilterLines(filter AutomationFilterEntry) []string { + var lines []string + if filter.MatchMode != "" && filter.MatchMode != "all" { + lines = append(lines, " match_mode: "+strconv.Quote(filter.MatchMode)) + } + if len(filter.States) > 0 { + lines = append(lines, " states: "+marshalStringSliceInline(filter.States)) + } + if len(filter.LabelsAny) > 0 { + lines = append(lines, " labels_any: "+marshalStringSliceInline(filter.LabelsAny)) + } + if filter.IdentifierRegex != "" { + lines = append(lines, " identifier_regex: "+strconv.Quote(filter.IdentifierRegex)) + } + if filter.Limit > 0 { + lines = append(lines, " limit: "+strconv.Itoa(filter.Limit)) + } + if filter.InputContextRegex != "" { + lines = append(lines, " input_context_regex: "+strconv.Quote(filter.InputContextRegex)) + } + return lines +} + +func buildAutomationPolicyLines(policy AutomationPolicyEntry) []string { + if !policy.AutoResume { + return nil + } + return []string{" auto_resume: true"} +} + +func marshalStringSliceInline(values []string) string { + data, err := json.Marshal(values) + if err != nil { + return "[]" + } + return string(data) +} + // PatchStringSliceField rewrites a YAML key whose value is an inline sequence // (e.g. `active_states: ["Todo", "In Progress"]`) inside the front matter. // values is marshalled as a JSON-style inline YAML sequence. diff --git a/internal/workflow/loader_test.go b/internal/workflow/loader_test.go index 81413be..cf8ca20 100644 --- a/internal/workflow/loader_test.go +++ b/internal/workflow/loader_test.go @@ -130,10 +130,12 @@ func TestPatchProfilesBlock_Create(t *testing.T) { f := filepath.Join(tmp, "WORKFLOW.md") require.NoError(t, os.WriteFile(f, []byte(content), 0o644)) + disabled := false profiles := map[string]workflow.ProfileEntry{ - "fast": {Command: "claude --model claude-haiku-4-5-20251001"}, + "fast": {Command: "claude --model claude-haiku-4-5-20251001", AllowedActions: []string{"comment", "provide_input"}}, "thorough": {Command: "claude --model claude-opus-4-6"}, - "codex": {Command: "run-codex-wrapper", Backend: "codex"}, + "codex": {Command: "run-codex-wrapper", Backend: "codex", Enabled: &disabled}, + "triage": {Command: "claude --model claude-sonnet-4-6", AllowedActions: []string{"create_issue"}, CreateIssueState: "Todo"}, } require.NoError(t, workflow.PatchProfilesBlock(f, profiles)) @@ -143,11 +145,18 @@ func TestPatchProfilesBlock_Create(t *testing.T) { assert.Contains(t, got, " profiles:") assert.Contains(t, got, " fast:") assert.Contains(t, got, " command: claude --model claude-haiku-4-5-20251001") + assert.Contains(t, got, " allowed_actions:") + assert.Contains(t, got, " - comment") + assert.Contains(t, got, " - provide_input") assert.Contains(t, got, " codex:") assert.Contains(t, got, " command: run-codex-wrapper") assert.Contains(t, got, " backend: codex") + assert.Contains(t, got, " enabled: false") + assert.Contains(t, got, " triage:") + assert.Contains(t, got, " create_issue_state: \"Todo\"") assert.Contains(t, got, " thorough:") assert.Contains(t, got, " command: claude --model claude-opus-4-6") + assert.NotContains(t, got, " fast:\n enabled: false") // Other fields preserved assert.Contains(t, got, "max_concurrent_agents: 3") assert.Contains(t, got, "command: claude") @@ -162,8 +171,15 @@ func TestPatchProfilesBlock_Replace(t *testing.T) { f := filepath.Join(tmp, "WORKFLOW.md") require.NoError(t, os.WriteFile(f, []byte(content), 0o644)) + disabled := false profiles := map[string]workflow.ProfileEntry{ - "fast": {Command: "run-codex-wrapper", Backend: "codex"}, + "fast": { + Command: "run-codex-wrapper", + Backend: "codex", + Enabled: &disabled, + AllowedActions: []string{"move_state", "create_issue"}, + CreateIssueState: "Todo", + }, } require.NoError(t, workflow.PatchProfilesBlock(f, profiles)) @@ -173,6 +189,11 @@ func TestPatchProfilesBlock_Replace(t *testing.T) { assert.Contains(t, got, " fast:") assert.Contains(t, got, " command: run-codex-wrapper") assert.Contains(t, got, " backend: codex") + assert.Contains(t, got, " enabled: false") + assert.Contains(t, got, " allowed_actions:") + assert.Contains(t, got, " - create_issue") + assert.Contains(t, got, " - move_state") + assert.Contains(t, got, " create_issue_state: \"Todo\"") // Old profile gone assert.NotContains(t, got, "old:") // Other fields preserved @@ -180,6 +201,26 @@ func TestPatchProfilesBlock_Replace(t *testing.T) { assert.Contains(t, got, "Body.") } +func TestPatchProfilesBlock_QuotesCreateIssueState(t *testing.T) { + content := "---\nagent:\n command: claude\n---\n\nBody.\n" + tmp := t.TempDir() + f := filepath.Join(tmp, "WORKFLOW.md") + require.NoError(t, os.WriteFile(f, []byte(content), 0o644)) + + profiles := map[string]workflow.ProfileEntry{ + "triage": { + Command: "claude --model claude-sonnet-4-6", + AllowedActions: []string{"create_issue"}, + CreateIssueState: "Todo: needs clarification #1", + }, + } + require.NoError(t, workflow.PatchProfilesBlock(f, profiles)) + + data, err := os.ReadFile(f) + require.NoError(t, err) + assert.Contains(t, string(data), " create_issue_state: \"Todo: needs clarification #1\"") +} + func TestPatchProfilesBlock_Delete(t *testing.T) { // Passing nil profiles removes the block. content := "---\nagent:\n max_concurrent_agents: 2\n profiles:\n fast:\n command: claude --model fast\n---\n\nBody.\n" @@ -199,6 +240,81 @@ func TestPatchProfilesBlock_Delete(t *testing.T) { assert.Contains(t, got, "Body.") } +func TestPatchAutomationsBlock_Create(t *testing.T) { + content := "---\nagent:\n command: claude\n---\n\nPrompt body.\n" + tmp := t.TempDir() + f := filepath.Join(tmp, "WORKFLOW.md") + require.NoError(t, os.WriteFile(f, []byte(content), 0o644)) + + automations := []workflow.AutomationEntry{ + { + ID: "weekday-review", + Enabled: true, + Profile: "reviewer", + Instructions: "Review backlog issues and comment with missing details.", + Trigger: workflow.AutomationTriggerEntry{ + Type: "cron", + Cron: "0 9 * * 1-5", + Timezone: "Asia/Jerusalem", + }, + Filter: workflow.AutomationFilterEntry{ + MatchMode: "any", + States: []string{"Backlog", "Todo"}, + LabelsAny: []string{"bug"}, + IdentifierRegex: "^ENG-", + Limit: 2, + }, + }, + { + ID: "qa-state-entry", + Enabled: true, + Profile: "qa", + Instructions: "Run QA when the issue enters Ready for QA.", + Trigger: workflow.AutomationTriggerEntry{ + Type: "issue_entered_state", + State: "Ready for QA", + }, + }, + { + ID: "input-responder", + Enabled: true, + Profile: "input-responder", + Instructions: "Answer narrow blocked-run questions.", + Trigger: workflow.AutomationTriggerEntry{ + Type: "input_required", + }, + Filter: workflow.AutomationFilterEntry{ + InputContextRegex: "continue|branch", + }, + Policy: workflow.AutomationPolicyEntry{ + AutoResume: true, + }, + }, + } + require.NoError(t, workflow.PatchAutomationsBlock(f, automations)) + + data, err := os.ReadFile(f) + require.NoError(t, err) + got := string(data) + assert.Contains(t, got, "automations:") + assert.Contains(t, got, `type: cron`) + assert.Contains(t, got, `cron: "0 9 * * 1-5"`) + assert.Contains(t, got, `timezone: "Asia/Jerusalem"`) + assert.Contains(t, got, "profile: reviewer") + assert.Contains(t, got, `instructions: "Review backlog issues and comment with missing details."`) + assert.Contains(t, got, `match_mode: "any"`) + assert.Contains(t, got, `states: ["Backlog","Todo"]`) + assert.Contains(t, got, `labels_any: ["bug"]`) + assert.Contains(t, got, `identifier_regex: "^ENG-"`) + assert.Contains(t, got, "limit: 2") + assert.Contains(t, got, `type: issue_entered_state`) + assert.Contains(t, got, `state: "Ready for QA"`) + assert.Contains(t, got, `type: input_required`) + assert.Contains(t, got, `input_context_regex: "continue|branch"`) + assert.Contains(t, got, `auto_resume: true`) + assert.Contains(t, got, "Prompt body.") +} + func TestPatchStringSliceField_Replace(t *testing.T) { content := "---\ntracker:\n active_states: [\"a\", \"b\"]\n terminal_states: [\"Done\"]\n---\n\nBody.\n" tmp := t.TempDir() diff --git a/site/src/content/docs/configuration.mdx b/site/src/content/docs/configuration.mdx index 1092c31..d6be0f3 100644 --- a/site/src/content/docs/configuration.mdx +++ b/site/src/content/docs/configuration.mdx @@ -262,6 +262,9 @@ Named profiles let you configure alternative agent commands selectable per-issue | `command` | string | CLI command for this profile (e.g. `"claude --model claude-haiku-4-5-20251001"`). | | `prompt` | string | Role description rendered through the Liquid template engine (supports `{{ issue.* }}` variables) and appended to the workflow prompt. Works in all agent modes — not just teams. See [Agent Profiles guide](/guides/agent-profiles/#liquid-variables-in-profile-prompts) for available variables. | | `backend` | string | Explicit backend override for this profile (same as the top-level `backend` field). | +| `enabled` | boolean | Optional. Disabled profiles stay in config but are hidden from normal selection and dispatch. | +| `allowed_actions` | string[] | Optional daemon-backed actions the profile may invoke: `comment`, `create_issue`, `move_state`, `provide_input`. | +| `create_issue_state` | string | Required when `allowed_actions` includes `create_issue`; the tracker state/column used for follow-up issues. | ```yaml agent: @@ -283,10 +286,81 @@ agent: thorough: command: claude --model claude-opus-4-6 prompt: You are a thorough senior engineer. Prioritise correctness and test coverage. + allowed_actions: [comment, move_state] + input-responder: + command: claude --model claude-sonnet-4-6 + allowed_actions: [provide_input] + qa: + command: claude --model claude-sonnet-4-6 + allowed_actions: [comment, create_issue, move_state] + create_issue_state: Todo ``` --- +## automations + +Automations dispatch a selected profile when a trigger fires, then layer a small instruction block on top of that profile. + +Supported triggers: + +- `cron` +- `input_required` +- `tracker_comment_added` +- `issue_entered_state` +- `issue_moved_to_backlog` +- `run_failed` + +| Field | Type | Description | +|---|---|---| +| `id` | string | Stable automation identifier. | +| `enabled` | bool | Whether the automation is active. | +| `profile` | string | Name of the agent profile to dispatch. | +| `instructions` | string | Small Markdown/Liquid instruction overlay appended after the selected profile prompt. | +| `trigger.type` | string | Trigger type. | +| `trigger.cron` | string | Five-field cron expression for `cron` triggers. | +| `trigger.timezone` | string | Optional timezone for `cron` triggers. | +| `trigger.state` | string | Required for `issue_entered_state`; the tracker state that must be entered. | +| `filter.match_mode` | string | How populated filters combine: `all` or `any`. | +| `filter.states` | string[] | Issue-state filter. For cron automations, leave empty to use backlog and active states. | +| `filter.labels_any` | string[] | Match issues with at least one of the listed labels. | +| `filter.identifier_regex` | string | Regex matched against issue identifiers like `ENG-42`. | +| `filter.limit` | int | Maximum number of issues to queue from one cron tick or event poll batch. | +| `filter.input_context_regex` | string | Only meaningful for `input_required`; matched against the blocked-agent question text. | +| `policy.auto_resume` | bool | Only meaningful for `input_required`; allows the helper to resume the blocked run via `provide_input`. | + +```yaml +automations: + - id: qa-ready + enabled: true + trigger: + type: issue_entered_state + state: "Ready for QA" + profile: qa + instructions: | + Run the QA routine for this issue. + Comment the results. + If any required check fails, move the issue to Todo. + + - id: pm-backlog-review + enabled: true + trigger: + type: cron + cron: "0 9 * * 1-5" + timezone: "Asia/Jerusalem" + profile: pm + instructions: | + Review backlog issues for missing clarity and acceptance criteria. + Leave one concise comment summarising what is unclear. + filter: + states: ["Backlog"] + limit: 20 +``` + +For the full mental model, trigger semantics, and examples, see the [Automations guide](/guides/automations/). + +--- + ## hooks Shell commands run at lifecycle points in each issue's workspace. All hooks run in the issue's workspace directory. diff --git a/site/src/content/docs/guides/automations.mdx b/site/src/content/docs/guides/automations.mdx new file mode 100644 index 0000000..1a885e2 --- /dev/null +++ b/site/src/content/docs/guides/automations.mdx @@ -0,0 +1,361 @@ +--- +title: Automations +description: How Itervox automations work — triggers, filters, instructions, trigger variables, and practical examples. +sidebar: + order: 5 +--- + +Automations let Itervox dispatch an agent profile automatically when a trigger fires. + +An automation is made of four parts: + +1. **Trigger** — what wakes the automation up. +2. **Profile** — which agent profile runs. +3. **Instructions** — a small prompt overlay added on top of that profile. +4. **Filters** — optional guards that narrow the issues or trigger contexts the automation should handle. + +This is intentionally smaller than the planned Canvas/workflow system. Automations cover the common “run this profile when X happens” cases without requiring a graph editor. + +## Mental model + +When an automation fires, Itervox does **not** invent a new execution engine. It reuses the existing issue worker flow: + +- load the selected issue +- choose the selected profile +- render the main `WORKFLOW.md` prompt +- append the profile prompt +- append the automation instructions +- append daemon-action guidance if that profile has daemon actions enabled +- dispatch a normal worker run + +That means automations inherit the same: + +- backend selection +- model/command +- daemon actions +- tracker integrations +- workspace behavior +- logs and history + +## Configuration shape + +```yaml +automations: + - id: qa-ready + enabled: true + trigger: + type: cron + cron: "0 */2 * * *" + timezone: "UTC" + profile: qa + instructions: | + Run the QA routine for this issue. + Comment the results. + If any required check fails, move the issue to Todo. + filter: + states: ["Ready for QA"] + labels_any: ["qa"] + match_mode: all + limit: 10 +``` + +## Triggers + +### `cron` + +Runs on a fixed five-field cron schedule. + +Use this for repeated sweeps such as: + +- QA validation every two hours +- backlog review every weekday morning +- nightly triage + +Fields: + +- `trigger.cron` +- `trigger.timezone` optional + +### `input_required` + +Fires when a running agent blocks and asks for human input. + +Use this for helper agents that can answer narrow unblocker questions, draft clarifications, or resume low-risk flows automatically. + +Important behavior: + +- the issue still enters the normal input-required state +- the helper automation runs against that same issue +- if the selected profile is allowed to use `provide_input`, it can resume the blocked run through the daemon + +Relevant fields: + +- `filter.input_context_regex` +- `policy.auto_resume` + +### `tracker_comment_added` + +Fires when Itervox observes that the latest tracker comment on an issue has changed. + +Use this when you want to react to new human comments or async feedback. + +Important caveat: + +- this trigger is currently **poll-derived**, not webhook-driven +- Itervox detects it by comparing the latest known comment with the latest currently fetched comment + +### `issue_entered_state` + +Fires when an issue transitions into a specific tracker state. + +Use this when you want state-driven automation such as: + +- run QA when the issue enters `Ready for QA` +- run documentation review when an issue enters `Ready for Docs` + +Required field: + +- `trigger.state` + +### `issue_moved_to_backlog` + +Fires when an issue newly enters one of the configured backlog states. + +Use this for backlog grooming or PM review when issues return to the intake queue. + +### `run_failed` + +Fires when a worker run fails **permanently** and Itervox stops retrying it. + +This is not “a turn had an error and may retry later”. It is the terminal failure case after the retry loop is exhausted. + +Use this for: + +- failure summarizers +- PM escalation +- automatic follow-up issue creation + +## Filters + +Filters are optional. They narrow what an automation is allowed to act on **after** the trigger has matched. + +Supported filters: + +- `states` +- `labels_any` +- `identifier_regex` +- `input_context_regex` for `input_required` +- `limit` +- `match_mode` + +### `match_mode` + +`match_mode` controls how multiple populated filters combine. + +- `all` — every populated filter must match +- `any` — at least one populated filter must match + +Use `all` by default. It is safer and usually reflects intent better. + +Use `any` for broad watch rules, such as: + +- issues in backlog **or** labeled `needs-pm` +- identifiers matching `^ENG-` **or** `^OPS-` + +### `states` + +The meaning of `states` depends on the trigger: + +- for `cron`: it controls which states the scheduler searches +- for event-based triggers: it acts as an extra guard on the issue **after** the trigger fires + +For cron automations, leaving `states` empty means Itervox uses the configured backlog and active states. + +### `labels_any` + +Matches issues that have at least one of the listed labels. + +Current UI behavior: + +- label suggestions come from issues currently visible to Itervox +- free-form typing is still allowed + +That is deliberate. Itervox does not currently fetch a tracker-wide label catalogue, so the UI can only suggest labels it has already seen. + +### `identifier_regex` + +A regular expression matched against the tracker issue identifier, such as: + +- `^ENG-` +- `^(ENG|OPS)-` + +### `input_context_regex` + +Only meaningful for `input_required`. + +It matches the blocked agent’s question text, so you can safely limit helper automations to narrow prompts such as: + +- `continue` +- `branch` +- `which file` +- `test command` + +### `limit` + +Used for batch-style triggers such as: + +- `cron` +- `tracker_comment_added` +- `issue_entered_state` +- `issue_moved_to_backlog` + +If one poll or cron tick finds many matching issues, `limit` caps how many are queued from that batch. + +## Instructions + +Automation instructions are a **small overlay** on top of the selected profile. + +That is the main reason automations exist separately from profiles. The same base profile can be reused across several automations with different framing. + +Examples: + +- a `qa` profile can be used by both a nightly QA sweep and a state-entry QA check +- a `pm` profile can be used by both a backlog review automation and a comment-triage automation + +## Prompt variables + +Automation instructions support the normal issue variables plus trigger-specific variables. + +### Issue variables + +- `{{ issue.identifier }}` +- `{{ issue.title }}` +- `{{ issue.description }}` +- `{{ issue.state }}` +- `{{ issue.labels }}` +- `{{ issue.comments }}` + +### Trigger variables + +- `{{ trigger.type }}` +- `{{ trigger.fired_at }}` +- `{{ trigger.automation_id }}` +- `{{ trigger.cron }}` +- `{{ trigger.timezone }}` +- `{{ trigger.trigger_state }}` +- `{{ trigger.previous_state }}` +- `{{ trigger.current_state }}` +- `{{ trigger.input_context }}` +- `{{ trigger.blocked_profile }}` +- `{{ trigger.blocked_backend }}` +- `{{ trigger.comment.body }}` +- `{{ trigger.comment.author_name }}` +- `{{ trigger.error_message }}` +- `{{ trigger.retry_attempt }}` + +Not every variable is populated for every trigger. For example: + +- `trigger.input_context` is useful for `input_required` +- `trigger.comment.*` is useful for `tracker_comment_added` +- `trigger.previous_state` / `trigger.current_state` are useful for state transitions +- `trigger.error_message` is useful for `run_failed` + +## Daemon actions and automations + +Automations do not define permissions themselves. Permissions still come from the selected **profile**. + +If you want an automation to do tracker or resume work, enable the required daemon actions on the profile, for example: + +- `comment` +- `move_state` +- `provide_input` +- `create_issue` + +That keeps the security model simple: + +- the automation decides **when** to run +- the profile decides **what the agent is allowed to do** + +## Examples + +### 1. Input responder + +```yaml +automations: + - id: input-responder + enabled: true + trigger: + type: input_required + profile: input-responder + instructions: | + Answer only narrow, low-risk unblocker questions. + If the request is ambiguous, state the safest bounded assumption. + If the request needs real human approval, do not invent it. + filter: + input_context_regex: "(branch|continue|which file|test command)" + match_mode: all + policy: + auto_resume: true +``` + +### 2. QA validation + +```yaml +automations: + - id: qa-ready + enabled: true + trigger: + type: issue_entered_state + state: "Ready for QA" + profile: qa + instructions: | + Run the QA routine for this issue. + Comment the results. + If any required check fails, move the issue to Todo. +``` + +### 3. PM backlog review + +```yaml +automations: + - id: pm-backlog-review + enabled: true + trigger: + type: cron + cron: "0 9 * * 1-5" + timezone: "Asia/Jerusalem" + profile: pm + instructions: | + Review backlog issues for missing clarity and acceptance criteria. + Leave one concise comment summarising what is unclear. + filter: + states: ["Backlog"] + limit: 20 +``` + +## UI notes + +The Settings page intentionally mixes strict selectors and flexible free-text entry: + +- **States** use suggestions from tracker configuration plus currently visible issues. +- **Labels** use suggestions from currently visible issues only. +- **Identifier regex** stays free-form because it is inherently custom. + +This is a pragmatic trade-off: + +- states are part of Itervox’s runtime config, so the UI can suggest them with high confidence +- labels are tracker-specific and not globally enumerated today, so suggestions are best-effort only + +## Current limitations + +Automations are intentionally lightweight. + +Current limitations include: + +- no visual graph editor +- no arbitrary branching/action graphs +- no webhook-driven tracker event ingestion +- `tracker_comment_added` is poll-derived +- label suggestions are best-effort, not authoritative + +That is by design. Automations are the small, reliable middle layer between “no automation” and the future full workflow canvas. diff --git a/web/package.json b/web/package.json index 1ac0f4f..1b96e9e 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", "clsx": "^2.1.1", + "lucide-react": "^1.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-helmet-async": "^3.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2b3c552..8e8555b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + lucide-react: + specifier: ^1.7.0 + version: 1.7.0(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -58,7 +61,7 @@ importers: version: 4.3.6 zustand: specifier: ^5.0.0 - version: 5.0.12(@types/react@19.2.14)(react@19.2.4) + version: 5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -1560,6 +1563,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1942,6 +1948,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.7.0: + resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2656,6 +2667,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.4.0: + resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4322,6 +4338,9 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: + optional: true + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4676,6 +4695,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.7.0(react@19.2.4): + dependencies: + react: 19.2.4 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -5707,6 +5730,11 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.4.0(react@19.2.4): + dependencies: + react: 19.2.4 + optional: true + util-deprecate@1.0.2: {} vfile-message@4.0.3: @@ -5851,9 +5879,11 @@ snapshots: zod@4.3.6: {} - zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): + zustand@5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 + immer: 10.2.0 react: 19.2.4 + use-sync-external-store: 1.4.0(react@19.2.4) zwitch@2.0.4: {} diff --git a/web/src/App.tsx b/web/src/App.tsx index 6333e77..9361c0c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,7 +9,16 @@ import { logIdentifiersKey } from './queries/logs'; import IssueDetailSlide from './components/itervox/IssueDetailSlide'; import Toast from './components/common/Toast'; import { PageErrorBoundary } from './components/common/PageErrorBoundary'; +import { ItervoxLogo } from './components/brand/ItervoxLogo'; import { NavLink } from './components/layout/NavLink'; +import { + AgentsIcon, + AutomationsIcon, + DashboardIcon, + LogsIcon, + SettingsIcon, + TimelineIcon, +} from './components/layout/NavIcons'; import { ThemeToggle } from './components/ui/ThemeToggle/ThemeToggle'; import AppHeader from './layout/AppHeader'; import { useFocusTrap } from './hooks/useFocusTrap'; @@ -19,6 +28,8 @@ import { inputRequiredFingerprintValue } from './utils/inputRequired'; const Dashboard = lazy(() => import('./pages/Dashboard')); const Logs = lazy(() => import('./pages/Logs')); const Timeline = lazy(() => import('./pages/Timeline')); +const Agents = lazy(() => import('./pages/Agents')); +const Automations = lazy(() => import('./pages/Automations')); const Settings = lazy(() => import('./pages/Settings')); const NotFound = lazy(() => import('./pages/OtherPage/NotFound')); @@ -31,22 +42,21 @@ function PageLoader() { } const NAV_ITEMS = [ - { to: '/', icon: '◫', label: 'Dashboard' }, - { to: '/timeline', icon: '◷', label: 'Timeline' }, - { to: '/logs', icon: '⌨', label: 'Logs' }, - { to: '/settings', icon: '⚙', label: 'Settings' }, + { to: '/', icon: , label: 'Dashboard' }, + { to: '/timeline', icon: , label: 'Timeline' }, + { to: '/logs', icon: , label: 'Logs' }, + { to: '/agents', icon: , label: 'Agents' }, + { to: '/automations', icon: , label: 'Automations' }, + { to: '/settings', icon: , label: 'Settings' }, ] as const; function SidebarContent() { return ( <> - {/* Logo mark */} -

- S +
+
+ +
{/* Nav links */} @@ -229,6 +239,26 @@ function AppWithSSE() { } /> + }> + + + + + } + /> + }> + + + + + } + /> + + + + + + + + + + + + ITER//VOX + + diff --git a/web/src/components/brand/ItervoxLogo.tsx b/web/src/components/brand/ItervoxLogo.tsx new file mode 100644 index 0000000..53dc735 --- /dev/null +++ b/web/src/components/brand/ItervoxLogo.tsx @@ -0,0 +1,9 @@ +import logoUrl from '../../assets/logo.svg'; + +interface ItervoxLogoProps { + className?: string; +} + +export function ItervoxLogo({ className }: ItervoxLogoProps) { + return Itervox; +} diff --git a/web/src/components/itervox/AgentInfoModal.tsx b/web/src/components/itervox/AgentInfoModal.tsx index 8167fe3..a1ad37d 100644 --- a/web/src/components/itervox/AgentInfoModal.tsx +++ b/web/src/components/itervox/AgentInfoModal.tsx @@ -10,6 +10,8 @@ import { backendBadgeClass, } from '../../pages/Settings/profiles/ProfileEditorFields'; import { + AGENT_ACTION_OPTIONS, + normalizeAllowedActions, applyBackendSelection, applyModelSelection, commandToBackend, @@ -18,6 +20,7 @@ import { inferBackendFromCommand, modelLabel, normalizeCommandForSave, + type AllowedAgentAction, type SupportedBackend, } from '../../pages/Settings/profileCommands'; import { profileColor, profileInitials } from '../../utils/profileColors'; @@ -43,11 +46,22 @@ export const AgentInfoModal = memo(function AgentInfoModal({ // Form state — mirrors ProfileRow approach const initialDraft = profileDef ? draftFromProfileDef(profileDef) - : { backend: 'claude' as SupportedBackend, model: '', command: '', prompt: '' }; + : { + backend: 'claude' as SupportedBackend, + model: '', + command: '', + prompt: '', + allowedActions: [] as AllowedAgentAction[], + createIssueState: '', + }; const [backend, setBackend] = useState(initialDraft.backend); const [model, setModel] = useState(initialDraft.model); const [command, setCommand] = useState(initialDraft.command); const [prompt, setPrompt] = useState(initialDraft.prompt); + const [allowedActions, setAllowedActions] = useState( + initialDraft.allowedActions, + ); + const [createIssueState, setCreateIssueState] = useState(initialDraft.createIssueState); // Reset form when profile changes or modal opens useEffect(() => { @@ -58,6 +72,8 @@ export const AgentInfoModal = memo(function AgentInfoModal({ setModel(draft.model); setCommand(draft.command); setPrompt(draft.prompt); + setAllowedActions(draft.allowedActions); + setCreateIssueState(draft.createIssueState); } setEditing(false); setSaving(false); @@ -71,6 +87,8 @@ export const AgentInfoModal = memo(function AgentInfoModal({ setModel(draft.model); setCommand(draft.command); setPrompt(draft.prompt); + setAllowedActions(draft.allowedActions); + setCreateIssueState(draft.createIssueState); } setEditing(false); }; @@ -82,6 +100,11 @@ export const AgentInfoModal = memo(function AgentInfoModal({ command: normalizeCommandForSave(command, backend), backend, prompt: prompt.trim() || undefined, + enabled: profileDef?.enabled ?? true, + allowedActions: allowedActions.length > 0 ? allowedActions : undefined, + createIssueState: allowedActions.includes('create_issue') + ? createIssueState.trim() || undefined + : undefined, }); setSaving(false); setEditing(false); @@ -94,6 +117,9 @@ export const AgentInfoModal = memo(function AgentInfoModal({ : 'claude'; const profileModel = profileDef ? commandToModel(profileDef.command) : ''; const modelDisplay = profileModel ? modelLabel(inferredBackend, profileModel) : ''; + const actionLabels = AGENT_ACTION_OPTIONS.filter((option) => + (profileDef?.allowedActions ?? []).includes(option.id), + ).map((option) => option.label); return ( {profileDef.prompt}
)} + {!editing && actionLabels.length > 0 && ( +
+ {actionLabels.map((label) => ( + + {label} + + ))} +
+ )} @@ -170,6 +208,8 @@ export const AgentInfoModal = memo(function AgentInfoModal({ model={model} command={command} prompt={prompt} + allowedActions={allowedActions} + createIssueState={createIssueState} onBackendChange={(value) => { const next = applyBackendSelection(command, backend, value); setBackend(value); @@ -187,6 +227,14 @@ export const AgentInfoModal = memo(function AgentInfoModal({ if (inferred) setBackend(inferred); }} onPromptChange={setPrompt} + onAllowedActionsChange={(value) => { + const normalized = normalizeAllowedActions(value); + setAllowedActions(normalized); + if (!normalized.includes('create_issue')) { + setCreateIssueState(''); + } + }} + onCreateIssueStateChange={setCreateIssueState} dynamicModels={availableModels} />
diff --git a/web/src/components/itervox/TagInput.tsx b/web/src/components/itervox/TagInput.tsx index 0fc01c8..9cfb36d 100644 --- a/web/src/components/itervox/TagInput.tsx +++ b/web/src/components/itervox/TagInput.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useId, useMemo, useState } from 'react'; interface TagInputProps { chips: string[]; @@ -7,18 +7,45 @@ interface TagInputProps { chipClassName: string; /** Tailwind classes applied to the Add button. */ addButtonClassName: string; + /** Placeholder text for the inline add input. */ + placeholder?: string; + /** Optional suggestion list shown as quick-add pills and input autocomplete. */ + suggestions?: string[]; + suggestionLabel?: string; } /** * Reusable tag-input: a chip list with an inline text input to add entries * and a remove button on each chip. */ -export function TagInput({ chips, onChange, chipClassName, addButtonClassName }: TagInputProps) { +export function TagInput({ + chips, + onChange, + chipClassName, + addButtonClassName, + placeholder = '+ Add state', + suggestions = [], + suggestionLabel = 'Suggestions', +}: TagInputProps) { const [inputValue, setInputValue] = useState(''); + const datalistId = useId(); + const normalizedChips = useMemo(() => chips.map((chip) => chip.toLowerCase()), [chips]); + const availableSuggestions = useMemo( + () => + suggestions.filter((suggestion, index) => { + const normalized = suggestion.toLowerCase(); + return ( + suggestion.trim() !== '' && + !normalizedChips.includes(normalized) && + suggestions.indexOf(suggestion) === index + ); + }), + [normalizedChips, suggestions], + ); const add = () => { const value = inputValue.trim(); - if (value && !chips.includes(value)) onChange([...chips, value]); + if (value && !normalizedChips.includes(value.toLowerCase())) onChange([...chips, value]); setInputValue(''); }; @@ -27,47 +54,81 @@ export function TagInput({ chips, onChange, chipClassName, addButtonClassName }: }; return ( -
- {chips.map((chip) => ( - - {chip} - - - ))} - + {chip} + + + ))} +
+ +
0 ? datalistId : undefined} value={inputValue} onChange={(e) => { setInputValue(e.target.value); }} onKeyDown={(e) => { - if (e.key === 'Enter') add(); + if (e.key === 'Enter') { + e.preventDefault(); + add(); + } }} - placeholder="+ Add state" - className="w-28 rounded border px-2 py-0.5 text-xs focus:ring-1 focus:outline-none" - style={{ borderColor: 'var(--line)', background: 'var(--panel)', color: 'var(--text)' }} + placeholder={placeholder} + className="min-w-[11rem] flex-1 rounded-[var(--radius-sm)] border border-[var(--line)] bg-[var(--panel-strong)] px-3 py-2 text-xs text-[var(--text)] focus:outline-none" /> - {inputValue.trim() && ( - + {availableSuggestions.length > 0 && ( + + {availableSuggestions.map((suggestion) => ( + )} - + +
+ + {availableSuggestions.length > 0 && ( +
+

{suggestionLabel}

+
+ {availableSuggestions.slice(0, 12).map((suggestion) => ( + + ))} +
+
+ )}
); } diff --git a/web/src/components/itervox/__tests__/AgentInfoModal.test.tsx b/web/src/components/itervox/__tests__/AgentInfoModal.test.tsx index 77efca2..d2ff580 100644 --- a/web/src/components/itervox/__tests__/AgentInfoModal.test.tsx +++ b/web/src/components/itervox/__tests__/AgentInfoModal.test.tsx @@ -163,6 +163,32 @@ describe('AgentInfoModal', () => { expect(savedDef).toHaveProperty('backend'); }); + it('does not preserve createIssueState when create_issue is not allowed', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByText('Edit Profile')); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + }); + + expect(onSave.mock.calls[0][1].createIssueState).toBeUndefined(); + }); + it('exits edit mode after successful save', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render( diff --git a/web/src/components/layout/NavIcons.tsx b/web/src/components/layout/NavIcons.tsx new file mode 100644 index 0000000..aabde52 --- /dev/null +++ b/web/src/components/layout/NavIcons.tsx @@ -0,0 +1,40 @@ +import { + Bot, + LayoutDashboard, + Logs, + type LucideProps, + Settings, + TimerReset, + Workflow, +} from 'lucide-react'; + +function iconProps(className?: string): LucideProps { + return { + className: className ?? 'h-4 w-4', + strokeWidth: 1.8, + }; +} + +export function DashboardIcon({ className }: { className?: string }) { + return ; +} + +export function TimelineIcon({ className }: { className?: string }) { + return ; +} + +export function LogsIcon({ className }: { className?: string }) { + return ; +} + +export function AgentsIcon({ className }: { className?: string }) { + return ; +} + +export function AutomationsIcon({ className }: { className?: string }) { + return ; +} + +export function SettingsIcon({ className }: { className?: string }) { + return ; +} diff --git a/web/src/components/layout/NavLink.tsx b/web/src/components/layout/NavLink.tsx index 6c26013..388e2f6 100644 --- a/web/src/components/layout/NavLink.tsx +++ b/web/src/components/layout/NavLink.tsx @@ -1,8 +1,9 @@ +import type { ReactNode } from 'react'; import { Link, useMatch, useResolvedPath } from 'react-router'; interface NavLinkProps { to: string; - icon: string; + icon: ReactNode; label: string; } @@ -27,7 +28,9 @@ export function NavLink({ to, icon, label }: NavLinkProps) { } className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]" > - + ); } diff --git a/web/src/components/layout/__tests__/NavIcons.test.tsx b/web/src/components/layout/__tests__/NavIcons.test.tsx new file mode 100644 index 0000000..509596d --- /dev/null +++ b/web/src/components/layout/__tests__/NavIcons.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { AgentsIcon, AutomationsIcon, SettingsIcon } from '../NavIcons'; + +describe('NavIcons', () => { + it('renders Lucide sidebar icons for agents, automations, and settings', () => { + const { container } = render( +
+ + + + + + + + + +
, + ); + + expect(screen.getByLabelText('agents').querySelector('.lucide-bot')).toBeInTheDocument(); + expect( + screen.getByLabelText('automations').querySelector('.lucide-workflow'), + ).toBeInTheDocument(); + expect(screen.getByLabelText('settings').querySelector('.lucide-settings')).toBeInTheDocument(); + expect(container.querySelectorAll('svg.lucide')).toHaveLength(3); + }); +}); diff --git a/web/src/hooks/useSettingsActions.ts b/web/src/hooks/useSettingsActions.ts index 3e7c6b2..6f2b009 100644 --- a/web/src/hooks/useSettingsActions.ts +++ b/web/src/hooks/useSettingsActions.ts @@ -47,11 +47,23 @@ const actions = { command: string, backend?: string, prompt?: string, + enabled?: boolean, + allowedActions?: string[], + createIssueState?: string, + originalName?: string, ): Promise => settingsFetch( `/api/v1/settings/profiles/${encodeURIComponent(name)}`, 'PUT', - { command, backend: backend ?? '', prompt: prompt ?? '' }, + { + command, + backend: backend ?? '', + prompt: prompt ?? '', + enabled: enabled ?? true, + allowedActions: allowedActions ?? [], + createIssueState: createIssueState ?? '', + originalName: originalName ?? '', + }, `Failed to save profile "${name}".`, ), @@ -120,6 +132,14 @@ const actions = { { profile, auto_review: autoReview }, 'Failed to update reviewer settings.', ), + + setAutomations: async (automations: unknown[]): Promise => + settingsFetch( + '/api/v1/settings/automations', + 'PUT', + { automations }, + 'Failed to update automations.', + ), }; export function useSettingsActions() { diff --git a/web/src/pages/Agents/index.tsx b/web/src/pages/Agents/index.tsx new file mode 100644 index 0000000..a3a90ee --- /dev/null +++ b/web/src/pages/Agents/index.tsx @@ -0,0 +1,88 @@ +import PageMeta from '../../components/common/PageMeta'; +import { CapacityCard } from '../Settings/CapacityCard'; +import { ProfilesCard } from '../Settings/ProfilesCard'; +import { ReviewerCard } from '../Settings/ReviewerCard'; +import { useSettingsPageData } from '../Settings/useSettingsPageData'; + +export default function Agents() { + const { + profileDefs, + availableModels, + trackerStateOptions, + reviewerProfile, + autoReview, + autoClearWorkspace, + reviewerProfileOptions, + upsertProfile, + deleteProfile, + setReviewerConfig, + } = useSettingsPageData(); + + return ( + <> + +
+
+

Agents

+

+ Manage agent profiles, reviewer behavior, and execution capacity. Profiles remain synced + with{' '} + + WORKFLOW.md + + . +

+
+ +
+
+

+ Profiles +

+ +
+ +
+
+

+ Code Review Agent +

+ +
+ +
+

+ Capacity +

+ +
+
+
+
+ + ); +} diff --git a/web/src/pages/Automations/index.tsx b/web/src/pages/Automations/index.tsx new file mode 100644 index 0000000..77191b9 --- /dev/null +++ b/web/src/pages/Automations/index.tsx @@ -0,0 +1,49 @@ +import PageMeta from '../../components/common/PageMeta'; +import { Card } from '../../components/ui/Card/Card'; +import { AutomationsCard } from '../Settings/AutomationsCard'; +import { useSettingsPageData } from '../Settings/useSettingsPageData'; + +export default function Automations() { + const { + automations, + automationProfileOptions, + trackerStateOptions, + automationLabelOptions, + setAutomations, + } = useSettingsPageData(); + + return ( + <> + +
+
+

Automations

+

+ Configure cron and event-driven helper runs. This page is the stepping stone toward the + future Canvas workflow surface. +

+
+ + +

Automation scope

+

+ Use this page for practical automations today: scheduled QA checks, backlog review, and + helper agents that react to input-required events. The broader visual workflow canvas + will build on top of this surface later. +

+
+ + +
+ + ); +} diff --git a/web/src/pages/Dashboard/components/HeroStats.tsx b/web/src/pages/Dashboard/components/HeroStats.tsx index ee0a330..5d9372e 100644 --- a/web/src/pages/Dashboard/components/HeroStats.tsx +++ b/web/src/pages/Dashboard/components/HeroStats.tsx @@ -29,16 +29,17 @@ function StatTile({ } export function HeroStats() { - const { running, paused, retrying, max } = useItervoxStore( + const { running, paused, retrying, inputRequired, max } = useItervoxStore( useShallow((s) => ({ running: s.snapshot?.running.length ?? 0, paused: s.snapshot?.paused.length ?? 0, retrying: s.snapshot?.retrying.length ?? 0, + inputRequired: (s.snapshot?.inputRequired ?? []).length, max: s.snapshot?.maxConcurrentAgents ?? 0, })), ); return ( -
+
0 ? 'var(--danger)' : undefined} /> + 0 ? 'var(--warning)' : undefined} + /> 0 ? `${String(running)}/${String(max)}` : '—'} diff --git a/web/src/pages/Dashboard/components/__tests__/HeroStats.test.tsx b/web/src/pages/Dashboard/components/__tests__/HeroStats.test.tsx new file mode 100644 index 0000000..728134d --- /dev/null +++ b/web/src/pages/Dashboard/components/__tests__/HeroStats.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { HeroStats } from '../HeroStats'; +import { useItervoxStore } from '../../../../store/itervoxStore'; + +describe('HeroStats', () => { + it('counts both input-required and pending-input-resume entries as blocked', () => { + useItervoxStore.setState({ + snapshot: { + generatedAt: new Date().toISOString(), + counts: { running: 0, retrying: 0, paused: 0 }, + running: [], + retrying: [], + paused: [], + maxConcurrentAgents: 3, + inputRequired: [ + { + identifier: 'ENG-1', + sessionId: 's1', + state: 'input_required', + context: 'Need approval', + queuedAt: new Date().toISOString(), + }, + { + identifier: 'ENG-2', + sessionId: 's2', + state: 'pending_input_resume', + context: 'Waiting to resume', + queuedAt: new Date().toISOString(), + }, + ], + }, + }); + + render(); + + expect(screen.getByText('Input Required')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/Dashboard/index.tsx b/web/src/pages/Dashboard/index.tsx index 9185844..f771a0a 100644 --- a/web/src/pages/Dashboard/index.tsx +++ b/web/src/pages/Dashboard/index.tsx @@ -190,8 +190,28 @@ export default function Dashboard() { const { upsertProfile } = useSettingsActions(); const handleEditProfile = useCallback( - async (name: string, def: { command: string; backend?: string; prompt?: string }) => { - await upsertProfile(name, def.command, def.backend, def.prompt); + async ( + name: string, + def: { + command: string; + backend?: string; + prompt?: string; + enabled?: boolean; + allowedActions?: string[]; + createIssueState?: string; + originalName?: string; + }, + ) => { + await upsertProfile( + name, + def.command, + def.backend, + def.prompt, + def.enabled, + def.allowedActions, + def.createIssueState, + def.originalName, + ); }, [upsertProfile], ); diff --git a/web/src/pages/Settings/AutomationsCard.tsx b/web/src/pages/Settings/AutomationsCard.tsx new file mode 100644 index 0000000..3373091 --- /dev/null +++ b/web/src/pages/Settings/AutomationsCard.tsx @@ -0,0 +1,225 @@ +import { useMemo, useState } from 'react'; +import type { AutomationDef } from '../../types/schemas'; +import { Card } from '../../components/ui/Card/Card'; +import { AutomationFormModal } from './automations/AutomationFormModal'; +import { AutomationRow } from './automations/AutomationRow'; +import { SuggestedAutomationCard } from './automations/SuggestedAutomationCard'; +import { + SUGGESTED_AUTOMATIONS, + type SuggestedAutomation, +} from './automations/suggestedAutomations'; +import { + automationDefFromValues, + automationValuesFromDef, + automationValuesFromSuggestion, + emptyAutomationValues, + type AutomationFormValues, +} from './automations/automationForm'; + +type AutomationModalState = { + title: string; + subtitle?: string; + submitLabel: string; + index: number | null; + initialValues: AutomationFormValues; +}; + +type AutomationStatus = { + kind: 'success' | 'error'; + message: string; +}; + +export function AutomationsCard({ + automations, + availableProfiles, + availableStates, + availableLabels, + onSave, +}: { + automations: AutomationDef[]; + availableProfiles: string[]; + availableStates: string[]; + availableLabels: string[]; + onSave: (automations: AutomationDef[]) => Promise; +}) { + const [modalState, setModalState] = useState(null); + const [status, setStatus] = useState(null); + + const automationList = useMemo( + () => [...automations].sort((a, b) => a.id.localeCompare(b.id)), + [automations], + ); + const suggestedToShow = useMemo( + () => + SUGGESTED_AUTOMATIONS.filter( + (suggestion) => !automations.some((item) => item.id === suggestion.id), + ), + [automations], + ); + + const openAddModal = () => { + setModalState({ + title: 'Add Automation', + subtitle: 'Lightweight cron and event-driven helpers before the full workflow canvas lands.', + submitLabel: 'Create Automation', + index: null, + initialValues: emptyAutomationValues(availableProfiles[0], automations), + }); + }; + + const openEditModal = (index: number, automation: AutomationDef) => { + setModalState({ + title: `Edit "${automation.id}"`, + subtitle: 'Update the trigger, selected profile, and automation-specific instructions.', + submitLabel: 'Save Changes', + index, + initialValues: automationValuesFromDef(automation), + }); + }; + + const openTemplateModal = (suggestion: SuggestedAutomation) => { + if (!availableProfiles.includes(suggestion.profile)) { + setStatus({ + kind: 'error', + message: `Template "${suggestion.label}" requires the "${suggestion.profile}" profile.`, + }); + return; + } + setModalState({ + title: `Use "${suggestion.label}" Template`, + subtitle: suggestion.description, + submitLabel: 'Create Automation', + index: null, + initialValues: automationValuesFromSuggestion(suggestion), + }); + }; + + const saveAutomations = async (nextAutomations: AutomationDef[]) => { + const seen = new Set(); + for (const automation of nextAutomations) { + const key = automation.id.trim().toLowerCase(); + if (seen.has(key)) { + setStatus({ kind: 'error', message: 'Automation IDs must be unique.' }); + return false; + } + seen.add(key); + } + setStatus(null); + const ok = await onSave(nextAutomations); + if (ok) { + setStatus({ + kind: 'success', + message: 'Saved to WORKFLOW.md. The daemon will reload automations shortly.', + }); + } + return ok; + }; + + return ( + <> + + +
+

Automations

+

+ Cron and event-driven helper rules layered on top of your agent profiles. +

+
+ +
+ + {availableProfiles.length === 0 && ( +
+

+ Create at least one agent profile before adding automations. +

+
+ )} + + {automationList.length === 0 ? ( +
+
+

No automations configured.

+
+
+ ) : ( +
+ {automationList.map((automation, index) => ( + { + openEditModal(index, automation); + }} + onDelete={async () => { + await saveAutomations(automationList.filter((_, rowIndex) => rowIndex !== index)); + }} + /> + ))} +
+ )} + + {suggestedToShow.length > 0 && ( + +

+ Start From A Template +

+
+ {suggestedToShow.map((suggestion) => ( + + ))} +
+
+ )} +
+ + {status && ( +

+ {status.message} +

+ )} + + {modalState && ( + { + setModalState(null); + }} + onSubmit={async (values) => { + const nextAutomation = automationDefFromValues(values); + const nextAutomations = + modalState.index === null + ? [...automationList, nextAutomation] + : automationList.map((automation, index) => + index === modalState.index ? nextAutomation : automation, + ); + return saveAutomations(nextAutomations); + }} + /> + )} + + ); +} diff --git a/web/src/pages/Settings/ProfilesCard.tsx b/web/src/pages/Settings/ProfilesCard.tsx index bae4748..8ebc7bb 100644 --- a/web/src/pages/Settings/ProfilesCard.tsx +++ b/web/src/pages/Settings/ProfilesCard.tsx @@ -1,73 +1,56 @@ -import { useState, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; +import { useMemo, useState } from 'react'; import type { ProfileDef } from '../../types/schemas'; -import { - applyBackendSelection, - applyModelSelection, - buildCanonicalCommand, - commandToBackend, - commandToModel, - inferBackendFromCommand, - normalizeCommandForSave, -} from './profileCommands'; -import { ProfileEditorFields } from './profiles/ProfileEditorFields'; +import { Card } from '../../components/ui/Card/Card'; +import { ProfileFormModal } from './profiles/ProfileFormModal'; import { ProfileRow } from './profiles/ProfileRow'; -import { SuggestedProfileCard, TemplatePreviewModal } from './profiles/SuggestedProfileCard'; +import { SuggestedProfileCard } from './profiles/SuggestedProfileCard'; import { SUGGESTED_PROFILES, type SuggestedProfile } from './profiles/suggestedProfiles'; - -// ─── Zod schema for add form ───────────────────────────────────────────────── - -const addProfileSchema = z.object({ - name: z - .string() - .min(1, 'Profile name is required.') - .regex(/^\S+$/, 'Profile name must not contain spaces.'), - backend: z.enum(['claude', 'codex']), - model: z.string(), - command: z.string().min(1, 'Command is required.'), - prompt: z.string(), -}); - -type AddProfileValues = z.infer; - -// ─── ProfilesCard ───────────────────────────────────────────────────────────── +import { + emptyProfileValues, + profileValuesFromDef, + profileValuesFromSuggestion, + type ProfileFormValues, +} from './profiles/profileForm'; interface ProfilesCardProps { profileDefs: Record; - onUpsert: (name: string, command: string, backend?: string, prompt?: string) => Promise; + onUpsert: ( + name: string, + command: string, + backend?: string, + prompt?: string, + enabled?: boolean, + allowedActions?: string[], + createIssueState?: string, + originalName?: string, + ) => Promise; onDelete: (name: string) => Promise; availableModels?: Record; + trackerStates?: readonly string[]; } +type ProfileModalState = { + mode: 'add' | 'edit'; + title: string; + subtitle?: string; + submitLabel: string; + initialValues: ProfileFormValues; +}; + +type ProfileStatus = { + kind: 'error'; + message: string; +}; + export function ProfilesCard({ profileDefs, onUpsert, onDelete, availableModels, + trackerStates, }: ProfilesCardProps) { - const [uiState, setUiState] = useState({ adding: false, deleteError: '' }); - const { adding, deleteError } = uiState; - const [quickAddSaving, setQuickAddSaving] = useState(null); - const [previewSuggestion, setPreviewSuggestion] = useState(null); - - const addForm = useForm({ - resolver: zodResolver(addProfileSchema), - defaultValues: { - name: '', - backend: 'claude', - model: '', - command: buildCanonicalCommand('claude', ''), - prompt: '', - }, - }); - const [addBackend, addModel, addCommand, addPrompt] = addForm.watch([ - 'backend', - 'model', - 'command', - 'prompt', - ]); + const [modalState, setModalState] = useState(null); + const [status, setStatus] = useState(null); const profileEntries = useMemo( () => Object.entries(profileDefs).sort(([a], [b]) => a.localeCompare(b)), @@ -78,251 +61,167 @@ export function ProfilesCard({ [profileDefs], ); - const openAddForm = () => { - addForm.reset({ - name: '', - backend: 'claude', - model: '', - command: buildCanonicalCommand('claude', ''), - prompt: '', + const openAddModal = () => { + setModalState({ + mode: 'add', + title: 'Add Agent Profile', + subtitle: + 'Create a reusable agent profile with backend, prompt, and daemon-backed action permissions.', + submitLabel: 'Create Profile', + initialValues: emptyProfileValues(), }); - setUiState((s) => ({ ...s, adding: true })); }; - const handleAddCancel = () => { - addForm.reset(); - setUiState((s) => ({ ...s, adding: false })); + const openEditModal = (name: string, def: ProfileDef) => { + setModalState({ + mode: 'edit', + title: `Edit "${name}"`, + subtitle: 'Update the profile configuration used by issue workers and helper agents.', + submitLabel: 'Save Changes', + initialValues: profileValuesFromDef(name, def), + }); }; - const handleEdit = async (name: string, def: ProfileDef) => { - await onUpsert( - name, - normalizeCommandForSave(def.command, commandToBackend(def.command, def.backend)), - def.backend, - def.prompt, - ); + const openTemplateModal = (suggestion: SuggestedProfile) => { + setModalState({ + mode: 'add', + title: `Use "${suggestion.label}" Template`, + subtitle: suggestion.description, + submitLabel: 'Create Profile', + initialValues: profileValuesFromSuggestion(suggestion), + }); }; const handleDelete = async (name: string) => { - setUiState((s) => ({ ...s, deleteError: '' })); + setStatus(null); const ok = await onDelete(name); - if (!ok) - setUiState((s) => ({ - ...s, - deleteError: `Failed to delete profile "${name}". Check the server logs.`, - })); - }; - - const handleAdd = addForm.handleSubmit(async (values) => { - const ok = await onUpsert( - values.name.trim(), - normalizeCommandForSave(values.command, values.backend), - values.backend, - values.prompt.trim() || undefined, - ); - if (ok) { - addForm.reset(); - setUiState((s) => ({ ...s, adding: false })); - } else { - addForm.setError('root', { message: 'Failed to save profile. Check the server logs.' }); + if (!ok) { + setStatus({ + kind: 'error', + message: `Failed to delete profile "${name}". Check the server logs.`, + }); } - }); + }; - const handleQuickAdd = async (suggestion: SuggestedProfile) => { - setQuickAddSaving(suggestion.id); + const handleToggleEnabled = async (name: string, def: ProfileDef, enabled: boolean) => { await onUpsert( - suggestion.id, - buildCanonicalCommand(suggestion.backend, suggestion.model), - suggestion.backend, - suggestion.prompt, + name, + def.command, + def.backend, + def.prompt, + enabled, + def.allowedActions, + def.createIssueState, + name, ); - setQuickAddSaving(null); + }; + + const hasProfileNameConflict = (name: string, originalName?: string) => { + const normalizedName = name.trim().toLowerCase(); + const normalizedOriginal = originalName?.trim().toLowerCase() ?? ''; + return profileEntries.some(([existingName]) => { + const normalizedExisting = existingName.trim().toLowerCase(); + return normalizedExisting === normalizedName && normalizedExisting !== normalizedOriginal; + }); }; return ( <> -
-
+ +

Agent Profiles

- Select per-issue from the issue detail modal. Backend and model controls stay - backend-aware, and custom wrapper commands are preserved instead of flattened. + Reusable worker presets for backend, model, prompt, and agent actions.

- {!adding && ( - - )} -
- -
- - - - - - - - + + + + + {profileEntries.length > 0 ? ( +
{profileEntries.map(([name, def]) => ( { + openEditModal(name, def); + }} + onToggleEnabled={handleToggleEnabled} onDelete={handleDelete} - availableModels={availableModels} /> ))} - - {adding && ( -
- - - - - )} - - {profileEntries.length === 0 && !adding && ( - - - - )} - -
- Name - - Backend / Model - -
- { - if (e.key === 'Escape') handleAddCancel(); - }} - autoFocus - /> - {addForm.formState.errors.name && ( -

- {addForm.formState.errors.name.message} -

- )} -
- { - const next = applyBackendSelection(addCommand, addBackend, value); - addForm.setValue('backend', value, { shouldValidate: true }); - addForm.setValue('model', next.model); - addForm.setValue('command', next.command, { shouldValidate: true }); - }} - onModelChange={(value) => { - addForm.setValue('model', value); - addForm.setValue( - 'command', - applyModelSelection(addCommand, addBackend, value), - { shouldValidate: true }, - ); - }} - onCommandChange={(value) => { - addForm.setValue('command', value, { shouldValidate: true }); - addForm.setValue('model', commandToModel(value)); - const inferred = inferBackendFromCommand(value); - if (inferred) addForm.setValue('backend', inferred); - }} - onPromptChange={(value) => { - addForm.setValue('prompt', value); - }} - dynamicModels={availableModels} - /> - {addForm.formState.errors.command && ( -

- {addForm.formState.errors.command.message} -

- )} - {addForm.formState.errors.root && ( -

- {addForm.formState.errors.root.message} -

- )} -
- - -
- No profiles configured yet.{' '} - -
-
+
+ ) : ( +
+ No profiles configured yet.{' '} + +
+ )} + {suggestedToShow.length > 0 && ( -
+

- Quick-add templates + Start From A Template

-
- {suggestedToShow.map((s) => ( +
+ {suggestedToShow.map((suggestion) => ( ))}
-
+
)} -
- - {deleteError &&

{deleteError}

} - - { - setPreviewSuggestion(null); - }} - onAdd={handleQuickAdd} - saving={previewSuggestion !== null && quickAddSaving === previewSuggestion.id} - /> + + + {status &&

{status.message}

} + + {modalState && ( + { + setModalState(null); + }} + onSubmit={async (name, def, originalName) => { + setStatus(null); + if (hasProfileNameConflict(name, originalName)) { + setStatus({ kind: 'error', message: 'Profile names must be unique.' }); + return false; + } + return onUpsert( + name, + def.command, + def.backend, + def.prompt, + def.enabled, + def.allowedActions, + def.createIssueState, + originalName, + ); + }} + /> + )} ); } diff --git a/web/src/pages/Settings/__tests__/AutomationsCard.test.tsx b/web/src/pages/Settings/__tests__/AutomationsCard.test.tsx new file mode 100644 index 0000000..18682c6 --- /dev/null +++ b/web/src/pages/Settings/__tests__/AutomationsCard.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { AutomationsCard } from '../AutomationsCard'; + +describe('AutomationsCard', () => { + it('renders suggested automation templates for the three built-in examples', () => { + render( + , + ); + + expect(screen.getByText('Input Responder')).toBeInTheDocument(); + expect(screen.getByText('QA Validation')).toBeInTheDocument(); + expect(screen.getByText('PM Backlog Review')).toBeInTheDocument(); + expect(screen.getAllByText('Use Template')).toHaveLength(3); + }); + + it('disables templates whose required profile is unavailable', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const inputResponder = screen.getByRole('button', { name: /Input Responder/i }); + expect(inputResponder).toBeDisabled(); + + await user.click(inputResponder); + + expect(screen.queryByText(/Use "Input Responder" Template/i)).not.toBeInTheDocument(); + expect( + screen.getByText( + (_, element) => + element?.textContent === 'Create and enable the input-responder profile first.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/Settings/__tests__/ProfileEditorFields.test.tsx b/web/src/pages/Settings/__tests__/ProfileEditorFields.test.tsx new file mode 100644 index 0000000..c83a73a --- /dev/null +++ b/web/src/pages/Settings/__tests__/ProfileEditorFields.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ProfileEditorFields } from '../profiles/ProfileEditorFields'; + +describe('ProfileEditorFields', () => { + it('places daemon actions and prompt variables above a prompt editor with preview controls', () => { + const { container } = render( + , + ); + + const actionsHeading = screen.getByText('Daemon Actions'); + const variablesHeading = screen.getByText('Prompt variables'); + const promptLabel = screen.getByText('Prompt'); + const writeButton = screen.getByRole('button', { name: 'Write' }); + const previewButton = screen.getByRole('button', { name: 'Preview' }); + + expect( + actionsHeading.compareDocumentPosition(promptLabel) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + variablesHeading.compareDocumentPosition(promptLabel) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(writeButton).toBeInTheDocument(); + expect(previewButton).toBeInTheDocument(); + + fireEvent.click(previewButton); + + expect(container.querySelector('strong')).toHaveTextContent('Use {{ issue.title }}.'); + }); + + it('shows a target state field when create issue is allowed', () => { + render( + , + ); + + expect(screen.getByLabelText('Follow-up issue state')).toHaveValue('Todo'); + }); +}); diff --git a/web/src/pages/Settings/__tests__/ProfilesCard.test.tsx b/web/src/pages/Settings/__tests__/ProfilesCard.test.tsx new file mode 100644 index 0000000..6682cbd --- /dev/null +++ b/web/src/pages/Settings/__tests__/ProfilesCard.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { ProfilesCard } from '../ProfilesCard'; + +describe('ProfilesCard', () => { + it('renders saved profiles as cards with active and inactive actions', () => { + render( + , + ); + + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Deactivate' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument(); + }); + + it('blocks renaming a profile to an existing profile name before save', async () => { + const user = userEvent.setup(); + const onUpsert = vi.fn().mockResolvedValue(true); + render( + , + ); + + const qaCard = screen.getByText('qa').closest('article'); + expect(qaCard).not.toBeNull(); + await user.click(within(qaCard as HTMLElement).getByRole('button', { name: 'Edit' })); + + const nameInput = screen.getByLabelText('Profile Name'); + await user.clear(nameInput); + await user.type(nameInput, 'pm'); + await user.click(screen.getByRole('button', { name: 'Save Changes' })); + + expect(onUpsert).not.toHaveBeenCalled(); + expect(screen.getByText('Profile names must be unique.')).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/Settings/__tests__/ScheduleEditorFields.test.tsx b/web/src/pages/Settings/__tests__/ScheduleEditorFields.test.tsx new file mode 100644 index 0000000..1c11e8b --- /dev/null +++ b/web/src/pages/Settings/__tests__/ScheduleEditorFields.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AutomationEditorFields } from '../automations/AutomationEditorFields'; + +describe('AutomationEditorFields', () => { + it('shows automation-specific variable and filter guidance', () => { + render( + , + ); + + expect( + screen.getByText(/leave empty to let Itervox use backlog and active states/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/Automation instructions are rendered with Liquid/i), + ).toBeInTheDocument(); + expect(screen.getByText(/Instruction templates/i)).toBeInTheDocument(); + expect(screen.getByText(/Match the blocked-agent question text/i)).toBeInTheDocument(); + expect(screen.getByText('{{ trigger.input_context }}')).toBeInTheDocument(); + expect( + screen.getAllByText('Match issues that have at least one of these tracker labels.'), + ).toHaveLength(2); + expect( + screen.getByText(/Suggestions come from issues currently visible to Itervox/i), + ).toBeInTheDocument(); + expect(screen.getByText(/How to combine multiple filters/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('+ Add state')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('+ Add label')).toBeInTheDocument(); + }); + + it('shows trigger-state selector for issue-entered-state automations', () => { + render( + , + ); + + expect(screen.getByLabelText('Entered State')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Ready for QA')).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/Settings/__tests__/automationForm.test.ts b/web/src/pages/Settings/__tests__/automationForm.test.ts new file mode 100644 index 0000000..b12081e --- /dev/null +++ b/web/src/pages/Settings/__tests__/automationForm.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { automationFormSchema } from '../automations/automationForm'; + +describe('automationFormSchema', () => { + it('rejects invalid identifier regexes', () => { + const result = automationFormSchema.safeParse({ + id: 'comment-watch', + enabled: true, + profile: 'pm', + instructions: '', + triggerType: 'tracker_comment_added', + triggerState: '', + cron: '', + timezone: '', + matchMode: 'all', + states: [], + labelsAny: [], + identifierRegex: '[', + limit: '', + inputContextRegex: '', + autoResume: false, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error('expected invalid identifier regex to fail'); + } + expect(result.error.issues.some((issue) => issue.path[0] === 'identifierRegex')).toBe(true); + }); + + it('rejects invalid input-context regexes', () => { + const result = automationFormSchema.safeParse({ + id: 'input-responder', + enabled: true, + profile: 'pm', + instructions: '', + triggerType: 'input_required', + triggerState: '', + cron: '', + timezone: '', + matchMode: 'all', + states: [], + labelsAny: [], + identifierRegex: '', + limit: '', + inputContextRegex: '[', + autoResume: false, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error('expected invalid input context regex to fail'); + } + expect(result.error.issues.some((issue) => issue.path[0] === 'inputContextRegex')).toBe(true); + }); +}); diff --git a/web/src/pages/Settings/__tests__/profileCommands.test.ts b/web/src/pages/Settings/__tests__/profileCommands.test.ts index adec05c..c3e32a6 100644 --- a/web/src/pages/Settings/__tests__/profileCommands.test.ts +++ b/web/src/pages/Settings/__tests__/profileCommands.test.ts @@ -54,11 +54,13 @@ describe('profileCommands', () => { command: 'run-codex-wrapper --json', backend: 'codex', prompt: 'Investigate failures.', + allowedActions: ['comment', 'provide_input'], }), ).toMatchObject({ backend: 'codex', command: 'run-codex-wrapper --json', prompt: 'Investigate failures.', + allowedActions: ['comment', 'provide_input'], }); }); }); diff --git a/web/src/pages/Settings/automations/AutomationEditorFields.tsx b/web/src/pages/Settings/automations/AutomationEditorFields.tsx new file mode 100644 index 0000000..6dd6183 --- /dev/null +++ b/web/src/pages/Settings/automations/AutomationEditorFields.tsx @@ -0,0 +1,550 @@ +import { TagInput } from '../../../components/itervox/TagInput'; +import { MarkdownPromptEditor } from '../profiles/MarkdownPromptEditor'; +import { + checkboxCls, + fieldLabelCls, + fieldSurfaceCls, + helperTextCls, + inputCls, + selectCls, +} from '../formStyles'; +import type { AutomationFormValues } from './automationForm'; + +type AutomationTriggerType = AutomationFormValues['triggerType']; + +type InstructionTemplate = { + id: string; + label: string; + description: string; + instruction: string; + triggerTypes?: AutomationTriggerType[]; +}; + +const TRIGGER_OPTIONS: Array<{ + value: AutomationTriggerType; + label: string; + description: string; +}> = [ + { + value: 'cron', + label: 'Cron', + description: 'Runs on a fixed schedule and dispatches matching issues in batches.', + }, + { + value: 'input_required', + label: 'Input Required', + description: 'Dispatches when a running agent blocks and asks for human input.', + }, + { + value: 'tracker_comment_added', + label: 'Tracker Comment Added', + description: 'Polls tracker comments and fires when Itervox sees a new latest comment.', + }, + { + value: 'issue_entered_state', + label: 'Issue Entered State', + description: 'Fires when an issue transitions into a specific tracker state.', + }, + { + value: 'issue_moved_to_backlog', + label: 'Issue Moved To Backlog', + description: 'Fires when an issue newly lands in one of the configured backlog states.', + }, + { + value: 'run_failed', + label: 'Run Failed', + description: 'Fires after a worker run fails permanently and Itervox stops retrying it.', + }, +]; + +const VARIABLE_GROUPS = [ + { + title: 'Issue variables', + values: [ + '{{ issue.identifier }}', + '{{ issue.title }}', + '{{ issue.description }}', + '{{ issue.state }}', + '{{ issue.labels }}', + '{{ issue.comments }}', + ], + }, + { + title: 'Trigger variables', + values: [ + '{{ trigger.type }}', + '{{ trigger.fired_at }}', + '{{ trigger.automation_id }}', + '{{ trigger.cron }}', + '{{ trigger.timezone }}', + '{{ trigger.trigger_state }}', + '{{ trigger.previous_state }}', + '{{ trigger.current_state }}', + '{{ trigger.input_context }}', + '{{ trigger.blocked_profile }}', + '{{ trigger.blocked_backend }}', + '{{ trigger.comment.body }}', + '{{ trigger.comment.author_name }}', + '{{ trigger.error_message }}', + '{{ trigger.retry_attempt }}', + ], + }, +] as const; + +const INSTRUCTION_TEMPLATES: readonly InstructionTemplate[] = [ + { + id: 'input-responder', + label: 'Input responder', + description: 'Low-risk unblocker answers for input-required automations.', + triggerTypes: ['input_required'], + instruction: `Answer only narrow, low-risk unblocker questions. + +- Prefer the safest bounded assumption that keeps work moving. +- If the request is ambiguous, state the assumption explicitly. +- If the request needs real human approval, do not invent it. +- Use \`itervox action provide-input\` only when the profile is allowed to auto-resume.`, + }, + { + id: 'qa-validation', + label: 'QA validation', + description: 'Run checks, comment results, and move the issue back when validation fails.', + triggerTypes: ['cron', 'issue_entered_state', 'tracker_comment_added'], + instruction: `Run the QA routine for this issue. + +- Validate the change against the issue description and tracker comments. +- Comment a concise pass/fail report on the issue. +- If a required check fails, move the issue back to Todo. +- If all required checks pass, explain what was validated.`, + }, + { + id: 'pm-backlog-review', + label: 'PM backlog review', + description: 'Review issue clarity, missing context, and acceptance criteria.', + triggerTypes: ['cron', 'issue_moved_to_backlog', 'tracker_comment_added'], + instruction: `Review the issue for missing product detail. + +- Identify vague requirements, unstated assumptions, and missing acceptance criteria. +- Leave one concise comment summarising what is unclear. +- Do not invent scope that is not supported by the issue context.`, + }, + { + id: 'comment-triage', + label: 'Comment triage', + description: 'Evaluate a newly added tracker comment and decide whether it changes next steps.', + triggerTypes: ['tracker_comment_added'], + instruction: `Review the newly added tracker comment in context. + +- Summarise whether the comment adds actionable new information. +- If the comment resolves a blocker, say what changed. +- If the comment creates new follow-up work, capture it clearly.`, + }, + { + id: 'failure-follow-up', + label: 'Failure follow-up', + description: 'Handle permanently failed runs with concise diagnosis and next-step guidance.', + triggerTypes: ['run_failed'], + instruction: `Review the failed run context. + +- Summarise the likely failure mode using the automation trigger data and issue context. +- Comment the next best step for a human or another agent. +- If the issue should move back to backlog or Todo, do that explicitly.`, + }, +]; + +function triggerDescription(triggerType: AutomationTriggerType): string { + return ( + TRIGGER_OPTIONS.find((option) => option.value === triggerType)?.description ?? + 'Choose what should wake this automation up.' + ); +} + +export function AutomationEditorFields({ + values, + availableProfiles, + availableStates, + availableLabels, + onEnabledChange, + onProfileChange, + onInstructionsChange, + onTriggerTypeChange, + onTriggerStateChange, + onCronChange, + onTimezoneChange, + onMatchModeChange, + onStatesChange, + onLabelsAnyChange, + onIdentifierRegexChange, + onLimitChange, + onInputContextRegexChange, + onAutoResumeChange, +}: { + values: AutomationFormValues; + availableProfiles: string[]; + availableStates: string[]; + availableLabels: string[]; + onEnabledChange: (value: boolean) => void; + onProfileChange: (value: string) => void; + onInstructionsChange: (value: string) => void; + onTriggerTypeChange: (value: AutomationTriggerType) => void; + onTriggerStateChange: (value: string) => void; + onCronChange: (value: string) => void; + onTimezoneChange: (value: string) => void; + onMatchModeChange: (value: AutomationFormValues['matchMode']) => void; + onStatesChange: (value: string[]) => void; + onLabelsAnyChange: (value: string[]) => void; + onIdentifierRegexChange: (value: string) => void; + onLimitChange: (value: string) => void; + onInputContextRegexChange: (value: string) => void; + onAutoResumeChange: (value: boolean) => void; +}) { + const isCron = values.triggerType === 'cron'; + const isInputRequired = values.triggerType === 'input_required'; + const isIssueEnteredState = values.triggerType === 'issue_entered_state'; + const supportsBatchLimit = + values.triggerType === 'cron' || + values.triggerType === 'tracker_comment_added' || + values.triggerType === 'issue_entered_state' || + values.triggerType === 'issue_moved_to_backlog'; + const visibleInstructionTemplates = INSTRUCTION_TEMPLATES.filter( + (template) => !template.triggerTypes || template.triggerTypes.includes(values.triggerType), + ); + + return ( +
+ + +
+
+ + +

+ The selected profile provides the base prompt, backend, and daemon actions. +

+
+ +
+ + +

{triggerDescription(values.triggerType)}

+
+
+ + {isIssueEnteredState && ( +
+ + +

+ Itervox compares the previous observed issue state with the newly fetched state and + fires only when the issue enters this state. +

+
+ )} + + {isCron && ( +
+
+ + { + onCronChange(event.target.value); + }} + placeholder="0 9 * * 1-5" + className={`${inputCls} font-mono text-xs`} + /> +

Five-field cron: minute hour day month weekday.

+
+ +
+ + { + onTimezoneChange(event.target.value); + }} + placeholder="UTC or Asia/Jerusalem" + className={inputCls} + /> +

Leave blank to use the daemon timezone.

+
+
+ )} + + {isInputRequired && ( + + )} + +
+
+
+

Instruction templates

+

+ Start from a reusable instruction block, then tailor it to the selected profile and + trigger. +

+
+
+ {visibleInstructionTemplates.map((template) => ( + + ))} +
+
+ +
+
+

Prompt variables

+

+ Liquid bindings available to this automation. Trigger variables depend on the selected + trigger type. +

+
+
+ {VARIABLE_GROUPS.map((group) => ( +
+

{group.title}

+
+ {group.values.map((value) => ( +

{value}

+ ))} +
+
+ ))} +
+
+
+ + + Automation instructions are rendered with Liquid before each run. Use{' '} + {'{{ issue.* }}'} and{' '} + {'{{ trigger.* }}'} for runtime context. + + } + /> + +
+
+
+ + +

+ All is stricter and is usually what you want. + + Any is useful for broad watch rules, such as + “issues in these states or with these labels”. + +

+
+ +
+ + +

+ For cron automations, leave empty to let Itervox use backlog and active states. + + For event-based automations, this acts as an extra issue-state guard after the + trigger fires. + +

+
+ +
+ + +

+ Match issues that have at least one of these tracker labels. +

+
+
+ +
+
+

Filter guide

+

+ Filters are optional. Use them to narrow an automation to the exact issues and + contexts you trust it to handle. +

+
+ +
+ + { + onIdentifierRegexChange(event.target.value); + }} + placeholder="^ENG-" + className={`${inputCls} font-mono text-xs`} + /> +

+ Apply a regular expression to issue identifiers like ENG-42. +

+
+ + {supportsBatchLimit && ( +
+ + { + onLimitChange(event.target.value); + }} + inputMode="numeric" + placeholder="Blank = no limit" + className={inputCls} + /> +

+ Maximum number of matching issues to queue when one poll or cron tick finds several + candidates at once. +

+
+ )} + + {isInputRequired && ( +
+ + { + onInputContextRegexChange(event.target.value); + }} + placeholder="continue|branch" + className={`${inputCls} font-mono text-xs`} + /> +

+ Match the blocked-agent question text before dispatching the helper profile. +

+
+ )} + +
+

Why states and labels use suggestions

+

Match issues that have at least one of these tracker labels.

+

+ States come from Itervox tracker settings plus issue states it is already seeing, so + the state selectors stay aligned with your actual workflow. +

+

+ Labels are different: Itervox does not fetch a tracker-wide label catalogue today, so + label suggestions are built from issues currently visible to the daemon. Free-form + entry is still supported when the label you want is not in the suggestion list. +

+
+
+
+
+ ); +} diff --git a/web/src/pages/Settings/automations/AutomationFormModal.tsx b/web/src/pages/Settings/automations/AutomationFormModal.tsx new file mode 100644 index 0000000..7d8c887 --- /dev/null +++ b/web/src/pages/Settings/automations/AutomationFormModal.tsx @@ -0,0 +1,179 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Modal, ModalFooter } from '../../../components/ui/modal'; +import { inputCls } from '../formStyles'; +import { AutomationEditorFields } from './AutomationEditorFields'; +import { automationFormSchema, type AutomationFormValues } from './automationForm'; + +export function AutomationFormModal({ + isOpen, + title, + subtitle, + submitLabel, + initialValues, + availableProfiles, + availableStates, + availableLabels, + onClose, + onSubmit, +}: { + isOpen: boolean; + title: string; + subtitle?: string; + submitLabel: string; + initialValues: AutomationFormValues; + availableProfiles: string[]; + availableStates: string[]; + availableLabels: string[]; + onClose: () => void; + onSubmit: (values: AutomationFormValues) => Promise; +}) { + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(automationFormSchema), + defaultValues: initialValues, + }); + + const values = watch(); + + const submit = handleSubmit(async (nextValues) => { + const ok = await onSubmit(nextValues); + if (ok) { + onClose(); + } + }); + + return ( + +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
{ + void submit(event); + }} + className="space-y-4" + > +
+ + + {errors.id && ( +

+ {errors.id.message} +

+ )} +
+ + { + setValue('enabled', value, { shouldValidate: true }); + }} + onProfileChange={(value) => { + setValue('profile', value, { shouldValidate: true }); + }} + onInstructionsChange={(value) => { + setValue('instructions', value); + }} + onTriggerTypeChange={(value) => { + setValue('triggerType', value, { shouldValidate: true }); + if (value !== 'issue_entered_state') { + setValue('triggerState', ''); + } + if (value !== 'input_required') { + setValue('inputContextRegex', ''); + setValue('autoResume', false); + } + }} + onTriggerStateChange={(value) => { + setValue('triggerState', value, { shouldValidate: true }); + }} + onCronChange={(value) => { + setValue('cron', value, { shouldValidate: true }); + }} + onTimezoneChange={(value) => { + setValue('timezone', value); + }} + onMatchModeChange={(value) => { + setValue('matchMode', value, { shouldValidate: true }); + }} + onStatesChange={(value) => { + setValue('states', value, { shouldValidate: true }); + }} + onLabelsAnyChange={(value) => { + setValue('labelsAny', value, { shouldValidate: true }); + }} + onIdentifierRegexChange={(value) => { + setValue('identifierRegex', value); + }} + onLimitChange={(value) => { + setValue('limit', value, { shouldValidate: true }); + }} + onInputContextRegexChange={(value) => { + setValue('inputContextRegex', value); + }} + onAutoResumeChange={(value) => { + setValue('autoResume', value, { shouldValidate: true }); + }} + /> + + {errors.profile && ( +

+ {errors.profile.message} +

+ )} + {errors.cron && ( +

+ {errors.cron.message} +

+ )} + {errors.triggerState && ( +

+ {errors.triggerState.message} +

+ )} + {errors.limit && ( +

+ {errors.limit.message} +

+ )} + + + + + + +
+
+ ); +} diff --git a/web/src/pages/Settings/automations/AutomationRow.tsx b/web/src/pages/Settings/automations/AutomationRow.tsx new file mode 100644 index 0000000..d05d10e --- /dev/null +++ b/web/src/pages/Settings/automations/AutomationRow.tsx @@ -0,0 +1,93 @@ +import type { AutomationDef } from '../../../types/schemas'; + +function triggerSummary(automation: AutomationDef): string { + switch (automation.trigger.type) { + case 'input_required': + return 'Input Required'; + case 'tracker_comment_added': + return 'Tracker Comment Added'; + case 'issue_entered_state': + return automation.trigger.state + ? `Issue Entered State · ${automation.trigger.state}` + : 'Issue Entered State'; + case 'issue_moved_to_backlog': + return 'Issue Moved To Backlog'; + case 'run_failed': + return 'Run Failed'; + default: + return automation.trigger.timezone + ? `${automation.trigger.cron ?? 'Missing cron'} · ${automation.trigger.timezone}` + : (automation.trigger.cron ?? 'Missing cron'); + } +} + +function filterSummary(automation: AutomationDef): string { + const parts: string[] = []; + if (automation.filter?.matchMode === 'any') parts.push('match any'); + if (automation.filter?.states?.length) + parts.push(`states: ${automation.filter.states.join(', ')}`); + if (automation.filter?.labelsAny?.length) + parts.push(`labels: ${automation.filter.labelsAny.join(', ')}`); + if (automation.filter?.identifierRegex) parts.push(`regex: ${automation.filter.identifierRegex}`); + if (automation.filter?.inputContextRegex) + parts.push(`input: ${automation.filter.inputContextRegex}`); + if (automation.filter?.limit && automation.filter.limit > 0) + parts.push(`limit: ${String(automation.filter.limit)}`); + return parts.length > 0 ? parts.join(' · ') : 'No extra filters'; +} + +export function AutomationRow({ + automation, + onEdit, + onDelete, +}: { + automation: AutomationDef; + onEdit: () => void; + onDelete: () => Promise; +}) { + return ( +
+
+
+ {automation.id} + + {automation.enabled ? 'Enabled' : 'Disabled'} + + + {automation.profile} + +
+

{triggerSummary(automation)}

+

{filterSummary(automation)}

+ {automation.instructions && ( +

+ {automation.instructions} +

+ )} +
+ +
+ + +
+
+ ); +} diff --git a/web/src/pages/Settings/automations/SuggestedAutomationCard.tsx b/web/src/pages/Settings/automations/SuggestedAutomationCard.tsx new file mode 100644 index 0000000..511e457 --- /dev/null +++ b/web/src/pages/Settings/automations/SuggestedAutomationCard.tsx @@ -0,0 +1,52 @@ +import type { SuggestedAutomation } from './suggestedAutomations'; + +function triggerLabel(triggerType: SuggestedAutomation['triggerType']) { + return triggerType === 'input_required' ? 'Input Required' : 'Cron'; +} + +export function SuggestedAutomationCard({ + suggestion, + disabled = false, + onUse, +}: { + suggestion: SuggestedAutomation; + disabled?: boolean; + onUse: (suggestion: SuggestedAutomation) => void; +}) { + return ( + + ); +} diff --git a/web/src/pages/Settings/automations/automationForm.ts b/web/src/pages/Settings/automations/automationForm.ts new file mode 100644 index 0000000..590adb4 --- /dev/null +++ b/web/src/pages/Settings/automations/automationForm.ts @@ -0,0 +1,186 @@ +import { z } from 'zod'; +import type { AutomationDef } from '../../../types/schemas'; +import type { SuggestedAutomation } from './suggestedAutomations'; + +function isValidRegex(value: string): boolean { + if (value.trim() === '') return true; + try { + new RegExp(value); + return true; + } catch { + return false; + } +} + +export const automationFormSchema = z + .object({ + id: z + .string() + .min(1, 'Automation ID is required.') + .regex(/^\S+$/, 'Automation ID must not contain spaces.'), + enabled: z.boolean(), + profile: z.string().min(1, 'Profile is required.'), + instructions: z.string(), + triggerType: z.enum([ + 'cron', + 'input_required', + 'tracker_comment_added', + 'issue_entered_state', + 'issue_moved_to_backlog', + 'run_failed', + ]), + triggerState: z.string(), + cron: z.string(), + timezone: z.string(), + matchMode: z.enum(['all', 'any']), + states: z.array(z.string().min(1)), + labelsAny: z.array(z.string().min(1)), + identifierRegex: z.string(), + limit: z.string().refine((value) => value.trim() === '' || /^\d+$/.test(value.trim()), { + message: 'Limit must be a non-negative integer.', + }), + inputContextRegex: z.string(), + autoResume: z.boolean(), + }) + .superRefine((values, ctx) => { + if (values.triggerType === 'cron' && values.cron.trim() === '') { + ctx.addIssue({ + code: 'custom', + path: ['cron'], + message: 'Cron automations require a cron expression.', + }); + } + if (values.triggerType === 'issue_entered_state' && values.triggerState.trim() === '') { + ctx.addIssue({ + code: 'custom', + path: ['triggerState'], + message: 'Issue-entered-state automations require a target state.', + }); + } + if (!isValidRegex(values.identifierRegex)) { + ctx.addIssue({ + code: 'custom', + path: ['identifierRegex'], + message: 'Identifier regex must be valid.', + }); + } + if (!isValidRegex(values.inputContextRegex)) { + ctx.addIssue({ + code: 'custom', + path: ['inputContextRegex'], + message: 'Input-context regex must be valid.', + }); + } + }); + +export type AutomationFormValues = z.infer; + +export function automationValuesFromDef(automation: AutomationDef): AutomationFormValues { + return { + id: automation.id, + enabled: automation.enabled, + profile: automation.profile, + instructions: automation.instructions ?? '', + triggerType: automation.trigger.type, + triggerState: automation.trigger.state ?? '', + cron: automation.trigger.cron ?? '', + timezone: automation.trigger.timezone ?? '', + matchMode: automation.filter?.matchMode ?? 'all', + states: automation.filter?.states ?? [], + labelsAny: automation.filter?.labelsAny ?? [], + identifierRegex: automation.filter?.identifierRegex ?? '', + limit: + automation.filter?.limit !== undefined && automation.filter.limit > 0 + ? String(automation.filter.limit) + : '', + inputContextRegex: automation.filter?.inputContextRegex ?? '', + autoResume: automation.policy?.autoResume ?? false, + }; +} + +export function automationDefFromValues(values: AutomationFormValues): AutomationDef { + const filter: NonNullable = {}; + const trimmedLimit = values.limit.trim(); + const parsedLimit = trimmedLimit === '' ? Number.NaN : Number.parseInt(trimmedLimit, 10); + + if (values.matchMode !== 'all') filter.matchMode = values.matchMode; + if (values.states.length > 0) filter.states = values.states; + if (values.labelsAny.length > 0) filter.labelsAny = values.labelsAny; + if (values.identifierRegex.trim()) filter.identifierRegex = values.identifierRegex.trim(); + if (!Number.isNaN(parsedLimit) && parsedLimit > 0) filter.limit = parsedLimit; + if (values.inputContextRegex.trim()) filter.inputContextRegex = values.inputContextRegex.trim(); + + return { + id: values.id.trim(), + enabled: values.enabled, + profile: values.profile, + instructions: values.instructions.trim() || undefined, + trigger: { + type: values.triggerType, + cron: values.triggerType === 'cron' ? values.cron.trim() : undefined, + timezone: + values.triggerType === 'cron' && values.timezone.trim() + ? values.timezone.trim() + : undefined, + state: + values.triggerType === 'issue_entered_state' && values.triggerState.trim() + ? values.triggerState.trim() + : undefined, + }, + filter: Object.keys(filter).length > 0 ? filter : undefined, + policy: values.autoResume ? { autoResume: true } : undefined, + }; +} + +export function nextAutomationId(automations: readonly AutomationDef[]): string { + let index = automations.length + 1; + while (automations.some((automation) => automation.id === `automation-${String(index)}`)) { + index += 1; + } + return `automation-${String(index)}`; +} + +export function emptyAutomationValues( + defaultProfile: string | undefined, + automations: readonly AutomationDef[], +): AutomationFormValues { + return { + id: nextAutomationId(automations), + enabled: true, + profile: defaultProfile ?? '', + instructions: '', + triggerType: 'cron', + triggerState: '', + cron: '0 9 * * 1-5', + timezone: '', + matchMode: 'all', + states: [], + labelsAny: [], + identifierRegex: '', + limit: '', + inputContextRegex: '', + autoResume: false, + }; +} + +export function automationValuesFromSuggestion( + suggestion: SuggestedAutomation, +): AutomationFormValues { + return { + id: suggestion.id, + enabled: true, + profile: suggestion.profile, + instructions: suggestion.instructions, + triggerType: suggestion.triggerType, + triggerState: suggestion.triggerState ?? '', + cron: suggestion.cron ?? '', + timezone: suggestion.timezone ?? '', + matchMode: suggestion.matchMode ?? 'all', + states: suggestion.states ?? [], + labelsAny: suggestion.labelsAny ?? [], + identifierRegex: suggestion.identifierRegex ?? '', + limit: suggestion.limit ? String(suggestion.limit) : '', + inputContextRegex: suggestion.inputContextRegex ?? '', + autoResume: suggestion.autoResume ?? false, + }; +} diff --git a/web/src/pages/Settings/automations/suggestedAutomations.ts b/web/src/pages/Settings/automations/suggestedAutomations.ts new file mode 100644 index 0000000..ee198c8 --- /dev/null +++ b/web/src/pages/Settings/automations/suggestedAutomations.ts @@ -0,0 +1,77 @@ +export interface SuggestedAutomation { + id: string; + label: string; + description: string; + profile: string; + triggerType: + | 'cron' + | 'input_required' + | 'tracker_comment_added' + | 'issue_entered_state' + | 'issue_moved_to_backlog' + | 'run_failed'; + instructions: string; + triggerState?: string; + cron?: string; + timezone?: string; + matchMode?: 'all' | 'any'; + states?: string[]; + labelsAny?: string[]; + identifierRegex?: string; + limit?: number; + inputContextRegex?: string; + autoResume?: boolean; +} + +export const SUGGESTED_AUTOMATIONS: readonly SuggestedAutomation[] = [ + { + id: 'input-responder', + label: 'Input Responder', + description: + 'Dispatches a helper profile when a run blocks for input, using narrow trigger context and optional auto-resume.', + profile: 'input-responder', + triggerType: 'input_required', + instructions: `Answer only narrow, low-risk unblocker questions. + +- Prefer the safest bounded assumption that keeps work moving. +- If the blocked request is ambiguous, state the assumption explicitly. +- If the request needs real human approval, do not invent it.`, + inputContextRegex: 'continue|branch|which file|test command', + matchMode: 'all', + autoResume: true, + }, + { + id: 'qa-validation', + label: 'QA Validation', + description: + 'Runs a QA profile on issues ready for verification, comments results, and pushes failures back to Todo.', + profile: 'qa', + triggerType: 'cron', + cron: '0 */2 * * *', + matchMode: 'all', + instructions: `Run the QA routine for this issue. + +- Validate the change against the issue description and comments. +- Comment a concise pass/fail report on the issue. +- If any required check fails, move the issue to Todo.`, + states: ['Ready for QA'], + limit: 10, + }, + { + id: 'pm-backlog-review', + label: 'PM Backlog Review', + description: + 'Reviews backlog issues for missing clarity, acceptance criteria, and scope gaps before engineering picks them up.', + profile: 'pm', + triggerType: 'cron', + cron: '0 9 * * 1-5', + matchMode: 'all', + instructions: `Review the issue for missing product detail. + +- Identify vague requirements, unstated assumptions, and missing acceptance criteria. +- Leave one concise comment summarising what is unclear. +- Do not rewrite the task or invent scope that is not supported by context.`, + states: ['Backlog'], + limit: 20, + }, +]; diff --git a/web/src/pages/Settings/formStyles.ts b/web/src/pages/Settings/formStyles.ts new file mode 100644 index 0000000..b488fd8 --- /dev/null +++ b/web/src/pages/Settings/formStyles.ts @@ -0,0 +1,18 @@ +export const fieldLabelCls = + 'mb-2 block text-xs font-medium tracking-wider uppercase text-theme-text-secondary'; + +export const inputCls = + 'w-full rounded-[var(--radius-sm)] border px-3 py-2 text-[13px] focus:outline-none bg-[var(--panel-strong)] border-[var(--line)] text-[var(--text)]'; + +export const selectCls = `${inputCls} cursor-pointer`; + +export const textareaCls = + 'w-full rounded-[var(--radius-sm)] border px-3 py-2 text-xs font-mono focus:outline-none resize-y min-h-[72px] bg-[var(--panel-strong)] border-[var(--line)] text-[var(--text)]'; + +export const helperTextCls = 'text-theme-muted mt-1 text-[11px]'; + +export const fieldSurfaceCls = + 'border-theme-line bg-theme-bg-soft space-y-2 rounded-[var(--radius-sm)] border p-3'; + +export const checkboxCls = + 'mt-0.5 h-3.5 w-3.5 rounded border-[var(--line)] bg-[var(--panel-strong)]'; diff --git a/web/src/pages/Settings/index.tsx b/web/src/pages/Settings/index.tsx index 77f26d3..d4cdb54 100644 --- a/web/src/pages/Settings/index.tsx +++ b/web/src/pages/Settings/index.tsx @@ -1,45 +1,26 @@ -import { useShallow } from 'zustand/react/shallow'; -import { useItervoxStore } from '../../store/itervoxStore'; -import { useSettingsActions } from '../../hooks/useSettingsActions'; import PageMeta from '../../components/common/PageMeta'; -import { ProfilesCard } from './ProfilesCard'; import { TrackerStatesCard } from './TrackerStatesCard'; import { WorkspaceCard } from './WorkspaceCard'; import { ProjectFilterCard } from './ProjectFilterCard'; import { SSHHostsCard } from './SSHHostsCard'; -import { ReviewerCard } from './ReviewerCard'; -import { CapacityCard } from './CapacityCard'; import { ConfirmButton } from '../../components/ui/button/ConfirmButton'; import { useClearAllLogs, useClearAllWorkspaces } from '../../queries/issues'; -import { EMPTY_PROFILE_DEFS, EMPTY_PROFILES, EMPTY_STATES } from '../../utils/constants'; +import { useSettingsPageData } from './useSettingsPageData'; export default function Settings() { - const { activeStates, terminalStates, completionState, autoClearWorkspace } = useItervoxStore( - useShallow((s) => ({ - activeStates: s.snapshot?.activeStates ?? EMPTY_STATES, - terminalStates: s.snapshot?.terminalStates ?? EMPTY_STATES, - completionState: s.snapshot?.completionState ?? '', - autoClearWorkspace: s.snapshot?.autoClearWorkspace ?? false, - })), - ); - const profileDefs = useItervoxStore((s) => s.snapshot?.profileDefs ?? EMPTY_PROFILE_DEFS); - const availableModels = useItervoxStore((s) => s.snapshot?.availableModels); - const availableProfiles = useItervoxStore((s) => s.snapshot?.availableProfiles ?? EMPTY_PROFILES); - const reviewerProfile = useItervoxStore((s) => s.snapshot?.reviewerProfile ?? ''); - const autoReview = useItervoxStore((s) => s.snapshot?.autoReview ?? false); const { - upsertProfile, - deleteProfile, + activeStates, + terminalStates, + completionState, + autoClearWorkspace, + trackerKind, + activeProjectFilter, updateTrackerStates, setAutoClearWorkspace, setProjectFilter, - setReviewerConfig, - } = useSettingsActions(); + } = useSettingsPageData(); const clearAllLogs = useClearAllLogs(); const clearAllWorkspaces = useClearAllWorkspaces(); - const trackerKind = useItervoxStore((s) => s.snapshot?.trackerKind); - const activeProjectFilter = useItervoxStore((s) => s.snapshot?.activeProjectFilter); - const autoReviewEnabled = autoReview && reviewerProfile !== ''; return ( <> @@ -47,11 +28,12 @@ export default function Settings() { title="Itervox | Settings" description="Itervox settings — profiles, tracker states, and workspace" /> -
+

Settings

- Configure agent profiles, tracker states, and workspace behaviour. All settings are also + Configure tracker, workspace, connectivity, and maintenance behaviour. Agent profiles + and automations now live on their own dedicated pages. All settings are also hot-reloaded from{' '} WORKFLOW.md @@ -60,40 +42,6 @@ export default function Settings() {

- {/* ── Profiles ──────────────────────────────────────────────────────── */} -
-

- Profiles -

- -
- - {/* ── Code Review Agent ────────────────────────────────────────────── */} -
-

- Code Review Agent -

- -
- - {/* ── Tracker States ────────────────────────────────────────────────── */}

Tracker States @@ -114,7 +62,6 @@ export default function Settings() {

- {/* ── Workspace ─────────────────────────────────────────────────────── */}

- {/* ── Agents ────────────────────────────────────────────────────── */} -
-

- Agents -

- -
- - {/* ── SSH Hosts ─────────────────────────────────────────────────── */}

- {/* ── Logs ──────────────────────────────────────────────────────────── */}

Logs

- {/* Clear all logs */} -
+

Clear all logs

@@ -175,8 +111,7 @@ export default function Settings() {

- {/* Reset all workspaces */} -
+

Reset all workspaces

diff --git a/web/src/pages/Settings/profileCommands.ts b/web/src/pages/Settings/profileCommands.ts index 00ebde9..faccae2 100644 --- a/web/src/pages/Settings/profileCommands.ts +++ b/web/src/pages/Settings/profileCommands.ts @@ -1,6 +1,13 @@ import type { ProfileDef } from '../../types/schemas'; export type SupportedBackend = 'claude' | 'codex'; +export type AllowedAgentAction = 'comment' | 'create_issue' | 'move_state' | 'provide_input'; + +export interface AllowedAgentActionOption { + id: AllowedAgentAction; + label: string; + description: string; +} export interface ModelOption { id: string; @@ -12,7 +19,33 @@ export interface ProfileCommandDraft { model: string; command: string; prompt: string; -} + enabled: boolean; + allowedActions: AllowedAgentAction[]; + createIssueState: string; +} + +export const AGENT_ACTION_OPTIONS = [ + { + id: 'comment', + label: 'Comment on current issue', + description: 'Post a tracker comment on the issue this agent is already handling.', + }, + { + id: 'create_issue', + label: 'Create follow-up issue', + description: 'Open a new issue in the profile’s configured tracker column/state.', + }, + { + id: 'move_state', + label: 'Move current issue state', + description: 'Transition the current issue to another tracker state through the daemon.', + }, + { + id: 'provide_input', + label: 'Provide input to blocked run', + description: 'Answer an input-required prompt and resume the blocked run through the daemon.', + }, +] satisfies AllowedAgentActionOption[]; export const CLAUDE_MODELS = [ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5 - Fast, cost-effective' }, @@ -36,6 +69,16 @@ export function normalizeBackend(backend: string | undefined | null): SupportedB return backend === 'codex' ? 'codex' : 'claude'; } +export function normalizeAllowedActions( + actions: string[] | undefined | null, +): AllowedAgentAction[] { + if (!actions?.length) return []; + const requested = new Set(actions.map((action) => action.trim()).filter(Boolean)); + return AGENT_ACTION_OPTIONS.filter((option) => requested.has(option.id)).map( + (option) => option.id, + ); +} + export function inferBackendFromCommand(cmd: string | undefined | null): SupportedBackend | null { const token = executableToken(cmd); switch (baseName(token)) { @@ -140,6 +183,9 @@ export function draftFromProfileDef(def: ProfileDef): ProfileCommandDraft { model: commandToModel(def.command), command: normalizeCommandForSave(def.command, backend), prompt: def.prompt ?? '', + enabled: def.enabled ?? true, + allowedActions: normalizeAllowedActions(def.allowedActions), + createIssueState: def.createIssueState ?? '', }; } diff --git a/web/src/pages/Settings/profiles/MarkdownPromptEditor.tsx b/web/src/pages/Settings/profiles/MarkdownPromptEditor.tsx new file mode 100644 index 0000000..1195bef --- /dev/null +++ b/web/src/pages/Settings/profiles/MarkdownPromptEditor.tsx @@ -0,0 +1,83 @@ +import { useDeferredValue, useMemo, useState, type ReactNode } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { proseClass } from '../../../utils/format'; +import { fieldLabelCls, helperTextCls, textareaCls } from '../formStyles'; + +interface MarkdownPromptEditorProps { + value: string; + onChange: (value: string) => void; + label?: string; + placeholder?: string; + helperText?: ReactNode; +} + +export function MarkdownPromptEditor({ + value, + onChange, + label = 'Prompt', + placeholder = 'Write the profile instructions in Markdown. Liquid variables are rendered at runtime.', + helperText, +}: MarkdownPromptEditorProps) { + const [tab, setTab] = useState<'write' | 'preview'>('write'); + const deferredValue = useDeferredValue(value); + + const preview = useMemo(() => deferredValue.trim(), [deferredValue]); + + return ( +

+
+ +
+ {(['write', 'preview'] as const).map((nextTab) => ( + + ))} +
+
+ + {tab === 'write' ? ( +