# Vik Architecture
This document describes current code. It is not a target design.
## Overview
Vik is one Rust binary crate. CLI startup loads `workflow.yml` into
`WorkflowSchema`, builds a `Workflow`, prepares the workflow-scoped workspace
root, installs logging, writes daemon state, and runs the event-driven
orchestrator.
The orchestrator owns intake, shutdown, and drain. `StageSessionManager` owns
running-stage state, issue setup, stage launch, session command senders, and
hook execution behind its own typed channel.
There is no `src/server/` module today. HTTP API docs describe planned work, not
current runtime behavior.
## Folder Structure
```text
src/
|-- main.rs binary entry
|-- cli/ clap parsing and subcommand execution
|-- config/ workflow.yml serde types and diagnostics
|-- workflow/ runtime supervisor built from loaded schema
|-- workspace/ workflow-scoped workspace path layout
|-- logging/ tracing subscriber, spans, retention
|-- shell/ CommandExt wrapper for timeout and cancellation
|-- template/ MiniJinja renderer plus prompt command expansion
|-- agent/ AgentAdapter trait, Codex and Claude Code adapters
|-- session/ session command/state channels, snapshots, JSONL writer
|-- hooks/ after_create, before_run, after_run shell hooks
|-- orchestrator/ intake loop and stage-session manager
|-- daemon/ detach, signals, lifecycle, state file
|-- context/ issue intake data and issue-run runtime context
`-- utils/ shared path helpers
```
Layout rules:
- Multi-file modules use `<name>/mod.rs`.
- Single-file leaves may stay as `<name>.rs`.
- Vik-owned path derivation belongs in `src/workspace/`.
- Platform detach and signal code belongs under `src/daemon/{detach,signals}/`.
## Layer Map
```mermaid
graph TD
main[main.rs] --> cli[cli]
cli --> workflow[workflow]
cli --> daemon[daemon]
cli --> logging[logging]
cli --> orchestrator[orchestrator]
cli --> workspace[workspace]
workflow --> config[config]
workflow --> hooks[hooks]
workflow --> workspace
workflow --> utils[utils]
orchestrator --> workflow
orchestrator --> session[session]
orchestrator --> hooks
orchestrator --> context[context]
orchestrator --> logging
session --> agent[agent]
session --> config
session --> context
session --> shell[shell]
session --> template[template]
session --> workflow
context --> workflow
context --> config
context --> hooks
hooks --> context
hooks --> logging
hooks --> shell
hooks --> template
agent --> config
template --> shell
daemon --> logging
```
Important current boundaries:
- `orchestrator` does not import `agent` or `shell`.
- `agent` adapters do not spawn subprocesses directly.
- `SessionFactory` is the orchestrator-to-session spawn seam.
- `Workflow` is the path/config carrier passed into runtime layers.
- `Workspace` accessors produce logs, sessions, service, and issue paths.
- `IssueRun` owns issue workspace preparation and `after_create`.
- `IssueStage` carries issue run context, stage schema, and session log path.
## Startup
```mermaid
sequenceDiagram
participant Op as Operator
participant CLI as cli::run
participant Loader as WorkflowSchemaLoader
participant Wf as Workflow
participant Log as logging::init
participant D as daemon
participant O as Orchestrator
Op->>CLI: vik run [-d] [workflow.yml]
CLI->>Loader: load(workflow path)
Loader-->>CLI: LoadedWorkflowSchema
CLI->>Wf: Workflow::try_from(loaded)
CLI->>Wf: workspace.ensure_root()
opt detached
CLI->>D: detach(log_dir)
D-->>CLI: parent exits, child continues
end
CLI->>Log: init(workspace.logs_dir)
CLI->>D: install_shutdown_handler()
CLI->>D: write state.json
CLI->>O: Orchestrator::new(workflow).run(shutdown)
```
`--port` resolves a socket address, but the server path is `todo!` today.
## Orchestrator Runtime
`Orchestrator::run` starts one `IntakeLoop` task and one
`StageSessionManager`, then selects over:
- shutdown token
- orchestrator event channel
- stage-session manager drain
- `IntakeEvent::Issue`
- `IntakeEvent::Failed`
- `IntakeEvent::Stopped`
`StageSessionManager` owns stage matching and session state. It reserves stage
keys before async setup starts. Reserved stages have no session command sender
yet. The concurrency cap counts distinct issue ids, not stage count.
Dispatch flow:
1. Intake emits an issue.
2. Orchestrator passes the issue to `StageSessionManager::try_spawn`.
3. The manager wraps it in `IssueRun`, matches stages by exact `state`, and
reserves `(issue_id, stage_name)`.
4. The manager's issue setup task ensures
`<workflow-workspace-root>/issues/<issue_id>/` exists.
5. If setup created the issue workspace, it runs `after_create`; existing
issue workspaces skip `after_create`.
6. The manager runs `before_run`.
7. The manager asks `SessionFactory` to create session command/state channels
for the runtime `IssueStage`.
8. The session task emits `SessionState` changes while it owns the child
process and `SessionSnapshot`.
9. The manager runs `after_run` after terminal state, except cancellation, then
removes the stage. When the last stage exits, the manager emits `Drained`.
## Intake
`IntakeLoop` runs `issues.pull.command` from the workflow file directory, waits
for command completion, and parses stdout as `Issues(Vec<Issue>)` JSON.
The sleep between intake cycles is `issues.pull.idle_sec`.
## Session
`SessionFactory` holds `Arc<Workflow>` and resolves the agent profile for each
runtime `IssueStage`. It creates a session command sender and state receiver,
then spawns a session task.
Session spawn:
1. `SessionFactory` resolves the agent profile and adapter.
2. It returns `SessionCommandSender` plus `SessionStateReceiver` and spawns the task.
3. The session task emits `Preparing`.
4. Resolve the stage prompt source. For `prompt_file`, resolve the path through
`IssueStage.workflow().resolve_path` and read the file. For `prompt`, use
the inline text directly.
6. Render MiniJinja with serialized `IssueStage` context.
7. Expand prompt commands with ``!`exec(command)` ``.
8. Build provider command.
9. Spawn the child process and emit `Running`.
10. Select over session commands and provider stdout lines.
11. Write decoded `AgentEvent` JSONL and update the task-owned
`SessionSnapshot`.
12. On terminal state, log the final snapshot summary and close the state
channel.
`SessionCommandSender` supports `cancel()` and one-time `snapshot()` requests.
`SessionStateReceiver` emits only state changes. It does not emit `UnStarted`.
Session logs live at:
```text
<workflow-workspace-root>/sessions/<issue.id>/<stage_name>-<uuid-v7>.jsonl
```
Current code uses a hardcoded one-hour child timeout. There is no stall
watchdog config in workflow schema.
## Agents
`AgentAdapter` has two methods:
- `build_command(&self, profile, prompt) -> AgentCommand`
- `map_event(&self, value) -> Vec<AgentEvent>`
Adapters keep provider-specific parsing local. Valid provider lines that do not
map to a semantic event become `AgentEvent::Unknown` with the full parsed
provider JSON in `raw`. Tool calls and subagent/delegation events are retained
as provider-neutral observation records.
`get_adapter(runtime)` returns a stateless adapter:
- `CodexAdapter`
- `ClaudeCodeAdapter`
`AgentProfileSchema.args` is forwarded into provider CLI flags before fixed
provider flags.
## Template Context
`JinjaRenderer::new()` captures process env under `env` and uses strict
undefined-variable behavior.
There is no `src/template/context.rs` module. Prompt and hook renderers pass
`Serialize` values directly into MiniJinja.
Runtime template contexts:
- `IssueRun` is used for `after_create`.
- `IssueStage` is used for stage `before_run`, stage `after_run`, and prompts.
Both serialize to the same current field shape:
- `workflow_path`
- `workspace_root`
- `issue`
The `issue` object contains:
- `id`, `title`, `description`, and `state`
- `workdir`
- extra issue payload fields from the pull command
`IssueStage` keeps stage schema and log path for Rust callers. Its `Serialize`
implementation extends `IssueRun` context with `issue.stage`.
`JinjaRenderer` also adds process env under `env`. Bindings like root-level
`stage`, `workspace.root`, `workflow`, `loop`, `profile`, `cwd`, and root-level
issue fields like `id` are not produced by current code.
## Hooks
`HookRunner` owns direct async methods:
- `after_issue_workdir_created`
- `before_issue_stage_run`
- `after_issue_stage_run`
Workflow field names remain `after_create`, `before_run`, and `after_run`.
Internal hook names used for logging are `after_issue_workdir_create`,
`before_issue_stage_run`, and `after_issue_stage_run`.
Hook execution:
1. Return `Ok(())` immediately when the hook body is missing.
2. Render with strict MiniJinja.
3. Run `sh -c` or `cmd /C` in the issue workspace.
4. Apply a hardcoded 30-second timeout.
5. Return `Result<(), HookError>`.
Hooks do not run prompt-command expansion.
## Workspace And State
The YAML `workspace.root` names a workspace home. `Workflow` resolves it against
the workflow file directory when relative, then appends
`workflows/<workflow-path-key>/`. `<workflow-path-key>` is the absolute workflow
file path with `/` replaced by `-`. `Workspace::ensure_root()` creates the
workflow-scoped root recursively when it is missing.
`Workspace` memoizes these path helpers:
- `root()`
- `logs_dir()`
- `sessions_dir()`
- `service_dir()`
- `service_state_file()`
- `issues_dir()`
- `issue_workdir(issue_id)`
- `issue_sessions_dir(issue_id)`
Runtime artifacts:
| `<root>/service/state.json` | daemon | pid and lifecycle state |
| `<root>/logs/vik.log.YYYY-MM-DD` | logging | INFO+ log events |
| `<root>/logs/vik-error.log.YYYY-MM-DD` | logging | ERROR-only log events |
| `<root>/sessions/<issue_id>/<stage_name>-<uuid-v7>.jsonl` | session | AgentEvent stream with retained provider evidence |
| `<root>/issues/<issue_id>/` | operator | issue workspace |
## Daemon
Daemon modules:
- `detach/`: Unix detach and Windows unsupported stub.
- `signals/`: SIGINT/SIGTERM/SIGHUP handling and pid liveness helpers.
- `state.rs`: atomic state JSON read/write/remove.
- `lifecycle.rs`: status, stop, restart stop phase, uninstall.
`stop` sends SIGTERM and waits up to 30 seconds for the daemon pid to exit.
The daemon itself cancels intake and running sessions when the shutdown token
trips.
## Where New Behavior Lands
| new workflow field | `src/config/`, then `Workflow` accessor if used |
| new CLI subcommand | `src/cli/<name>.rs` and `src/cli/mod.rs` |
| new Vik-owned path | `src/workspace/mod.rs` |
| new agent provider | `src/agent/adapters/<provider>/` and `get_adapter` |
| new prompt or hook binding | `src/context/run.rs` serialization or renderer call site |
| new hook trigger point | `src/hooks/` plus `StageSessionManager` call site |
| HTTP API implementation | new server module plus CLI `drive_runtime` |
## Related Documents
- [`CONTEXT.md`](../../CONTEXT.md)
- [`PRD.md`](../PRD.md)
- [`code-conventions.md`](./code-conventions.md)
- [`review-checklist.md`](./review-checklist.md)
- [ADRs](../adr/)