Wingman Architecture Decisions
This document records hard architecture constraints for Wingman. It is written for maintainers and AI coding agents. Do not introduce code that violates these decisions without an explicit architecture change approved by the project owner.
Non-Negotiable Control Plane
Wingman has two separate control paths:
- Remote orchestration:
runtime agent -> outbound transport -> Go orchestrator -> dashboard. - Local standalone UI:
Tauri UI -> Tauri Rust backend -> local IPC -> runtime.
The runtime must not become a general-purpose HTTP or WebSocket server for control traffic.
Remote Orchestration
The Go server is the central orchestrator.
- Runtime connects outbound to the Go orchestrator as an agent.
- The Go server owns authentication, authorization, dashboard APIs, node registry, command dispatch, status aggregation, logs, and remote events.
- Dashboard and remote clients connect only to the Go server, not directly to runtime.
- The Go server should not depend on dialing a runtime listener such as
localhost:8888. - Agent communication must use a clearly versioned transport protocol with bounded frame sizes and cross-language tests.
The actual command flow, using the wire message names defined in docs/protocols.md, is:
runtime -> server: Notify body type=agent.register
server -> runtime: Notify body type=agent.register_ack
runtime -> server: Notify body type=agent.heartbeat
(server updates registry state; no ack frame is sent)
server -> runtime: Request body method=run_script
runtime -> server: Response (same frame sequence as the Request)
runtime -> server: Notify body type=agent.event {event: "script_output"}
runtime -> server: Notify body type=agent.event {event: "script_state"}Every frame is a fixed 16-byte header followed by a JSON body (libs/transport/include/wingman/transport/session/session.hpp: MessageHeader = length / sequence / type / reserved). There are no dedicated agent_id / command_id / timeout wire fields:
- command identity is carried by the frame header
sequence, which the Response frame echoes back; - agent identity is implied by the (authenticated) connection;
- the command payload is the JSON body itself, with a
methodfield (e.g.run_script) selecting the handler; timeoutis a server-side send parameter (SendCommandWithTimeout), not a wire field.
Every command result is a Response frame with the same sequence; its body carries success, and on failure error, plus data when the command returns data.
Local Standalone UI
Local UI control must use local IPC, not runtime WebSocket/HTTP.
Preferred IPC selection:
- Windows: default to Named Pipe.
- macOS/Linux: default to Unix Domain Socket.
- Windows Unix Domain Socket may be supported through runtime capability detection, but it is not the default primary path.
- Local TCP is for explicit debug fallback only and must be disabled by default.
Recommended configuration shape:
enum class IpcTransport {
Auto,
NamedPipe,
UnixSocket,
LocalTcp
};
struct IpcConfig {
IpcTransport transport = IpcTransport::Auto;
std::string endpoint;
bool allowTcpFallback = false;
};Auto mode should resolve as:
Windows:
NamedPipe first
UnixSocket only if explicitly preferred or Named Pipe is unavailable and AF_UNIX probes successfully
LocalTcp only if allowTcpFallback is true
macOS/Linux:
UnixSocket first
LocalTcp only if allowTcpFallback is trueLocal IPC Wire Contract
The local IPC wire format is:
uint32 little-endian payload_length
JSON envelope bytesThe JSON envelope is transport-level and must stay independent from Tauri, browser, HTTP, and WebSocket concepts:
{
"type": 0,
"method": "system.getStatus",
"payload": {},
"id": 1,
"timestamp": 1715299200000
}type uses the C++ IpcMessageType numeric enum:
0: request1: response2: event3: error
The dispatcher response is carried inside the response envelope payload. GUI code should not parse or construct WebSocket JSON-RPC messages for local runtime control.
Forbidden Changes
Do not add these without explicit owner approval:
- A runtime HTTP server for local UI control.
- A runtime WebSocket server for local UI control.
- Browser-to-runtime direct control APIs.
- Dashboard code that connects directly to
ws://127.0.0.1:<runtime-port>. - Go orchestrator code that assumes runtime is a passive listener.
- New TCP listeners in runtime for production control paths.
- New third-party network server frameworks in runtime for local UI control.
- Browser/remote clients connecting directly to guacd (the pixel-plane guacd port stays loopback/intranet-only; all browser traffic goes through the Go server's ticket-gated
/api/remote/guacamolegateway).
If a local control feature is needed, implement it through the IPC abstraction.
Allowed WebSocket Usage
WebSocket is allowed at the Go server boundary for dashboard/browser communication.
dashboard <-> Go server WebSocketThis includes the remote-desktop pixel-plane gateway (/api/remote/guacamole): the browser-facing side is WebSocket carrying the Guacamole instruction stream, the guacd-facing side is plain TCP. The runtime agent is not involved in any part of it — desktop sessions terminate on the Go server and connect to the agent's registered LAN address directly (one-time ticket-gated, see docs/remote-gateway-guacamole-design.md).
WebSocket is not allowed as the runtime local-control mechanism.
Dispatcher Reuse
Command handling may be shared across local IPC and remote agent commands. Prefer a transport-agnostic command dispatcher:
IPC frame -> CommandDispatcher -> runtime services
agent command -> CommandDispatcher -> runtime servicesThe dispatcher must not depend on WebSocket, HTTP, or browser concepts.
Trigger Commands
trigger.* RPC handlers (trigger.list/add/remove/update/toggle) are registered on both the local IPC dispatcher and the runtime's remote dispatcher. The Agent owns a single shared TriggerManager (passed to LocalIpcServer), so the Tauri UI and the Go orchestrator see the same trigger set. Agent::handleRemoteCommand forwards any trigger.* command through the shared RPC dispatcher; the Go server exposes the full trigger management surface to the dashboard, all under agents:manage:
GET /api/agents/:agentId/triggers(read-only, any authenticated user)POST /api/agents/:agentId/triggers/togglePOST /api/agents/:agentId/triggers→trigger.addPUT /api/agents/:agentId/triggers/:triggerId→trigger.updateDELETE /api/agents/:agentId/triggers/:triggerId→trigger.remove
No new transport or listener is introduced.
Remote Config Commands (Local IPC Only)
config.getRemote / config.setRemote expose the runtime's remote-registration config (orchestrator address + A3-P1 register token, see docs/agent-token-auth-design.md) to the Tauri UI through local IPC:
config.getRemotereturns the currently effective{serverIp, serverPort, registerToken}.config.setRemoteperforms a partial update (missing fields keep current values), validates the result (non-emptyserverIp, integer port 1–65535, token ≤ 256 chars), then hot-applies:Agent::applyRemoteConfigrebuilds the remote client (stop → dispose screen/dispatcher → re-init → reconnect if it was running) and writes the new values back toagent.tomlso they survive restarts.
There is deliberately no corresponding remote agent command. The remote config must not be writable from the Go server / dashboard side — an agent that lets its orchestrator rewrite its own registration target would let a compromised server silently redirect agents. The write path is local-UI-only, matching the trust model of the local IPC boundary.
Implementation: rpc::RemoteConfigAccess (get/apply callbacks) is injected into LocalIpcServer by Agent before start(), following the setStatusProviders setter-before-start pattern; the handler layer (config_handler.cpp) stays decoupled from the Agent type.
Android Reverse JNI Bridge
The Android agent (see docs/android-agent-design.md) keeps the hard constraint that jni_bridge.cpp is the only translation unit touching JNIEnv. Platform capabilities (gesture injection via AccessibilityService#dispatchGesture, screen capture via MediaProjection) reach the C++ core through one narrow seam:
platform::android::AndroidHostBridge(lib/wingman, JNI-free) is an abstract interface with synchronous blocking semantics (tap/swipe/longPress/captureFrame/screenSize); a global setter (setGlobalHostBridge, same pattern assetGlobalRecorder) lets the JNI layer inject the implementation before the agent starts.jni_bridge.cppimplements it (JniHostBridge): GlobalRef + cached method IDs (noFindClassfrom non-main threads), thread_local attach/detach guards for script threads, seq/promise hand-off for gesture completion, and a latest-frame cache fed by pushed frames (nativeOnFrame).- Script-facing APIs (
wingman.input/screen/vision) and thescreenshot.captureagent command consume the bridge; when the bridge is absent or permissions are ungranted they degrade to false/nil instead of erroring.
This adds no new listeners, transports, or control planes: everything is in-process JNI between the Kotlin shell and the C++ core, and the agent still only holds outbound TCP connections. Remote config remains local-only per the Remote Config Commands decision above; the Android agent additionally accepts screenshot.capture from the Go server via the existing generic command path (same JSON shape as desktop).
Display Selection
Multi-monitor capture is an extension of the existing screenshot path, not a new control channel.
- Display enumeration flows through a dedicated RPC method
screen.listMonitorson the same dispatcher that already servesscreenshot.capture. No new listener, port, or transport is introduced. - Display selection flows through the existing
screenshot.capturepayload as an optionaldisplayIdfield. Omitting it preserves the current primary-monitor behavior, so existing callers do not break. - The platform abstraction
IScreen(seedocs/platform-abstraction-design.md) is the single source of truth for monitor enumeration and per-monitor bounds. The legacy staticwingman::Screenclass is frozen for the screenshot path and will be removed separately; new screenshot code must route throughIScreenrather than extendingScreenwith a parallel multi-monitor API.
This keeps display selection inside the IPC/agent transport contract and avoids a second screen abstraction.
Runtime-to-UI Event Delivery (Pull, not Push)
The runtime surfaces events (log lines, trigger fires, screenshot frames, script state changes) to the local Tauri UI over the existing local IPC, but uses a pull/drain model, not unsolicited push frames:
runtime subsystem -> EventBuffer (in-process, bounded queue)
GUI poller -> RPC `events.drain` -> drains + clears the buffer -> dispatch to storesRationale: the Rust IPC client (apps/gui/src-tauri/src/ipc/client.rs) is a synchronous request/response client over Windows named pipes (blocking IO). If the runtime pushed unsolicited type=2 event frames, they would interleave with response frames and desynchronize the single reader. A pull model keeps the client single-request/single-response and avoids an async reader loop.
This is an allowed variation of the wire contract above: events still travel over the local IPC only (never HTTP/WebSocket). The type=2 event frame remains defined for transports that can multiplex; the local-UI path simply chooses drain-over-RPC. Do not introduce a background reader/type=2 push path in the Rust client without first resolving the blocking-IO multiplexing.
Hook points: EventBuffer::instance().push(method, payload) is fed by a spdlog sink (log.line), a TriggerManager::setOnFired callback (trigger.fired), script lifecycle transitions in StandaloneMode (script.state_changed), RemoteClient connection transitions (connection.state_changed, runtime→Go link only), and LocalIpcServer client session changes (connection.ipc_client, local UI attach/detach).
The local RPC system.getStatus reports remoteConnected/remoteState (the runtime→Go link) and ipcClientConnected so the GUI heartbeat can refresh link indicators without waiting for events. These status fields and events stay on the local IPC path and are never forwarded to the Go server.
Screenshots are NOT pushed via the event drain. Full screenshots are large base64 payloads; periodic capture would flood the bounded buffer and the pull model. Screenshots stay on-demand via the existing screenshot.capture RPC (the GUI requests a frame when it needs one). ScreenshotReporter is Windows-only and HTTP-oriented (serverUrl); it is not wired into the local event path. A future "screenshot available" lightweight signal, if needed, should carry only a notification and let the GUI pull the heavy payload.
Agent Groups & Batch Operations
Agent grouping (tags) and batch operations live entirely in the Go server control plane; the C++ runtime gains no new commands and no new transport.
Tag persistence
- Tags are attached to in-memory
AgentInfo(internal/agent) and persisted as a JSON text column onmodels.Agent(tags). - Persistence goes through a
TagStorecallback interface injected intoRegistry(SetTagStore), so theinternal/agentpackage keeps its zero-DB testability (same decoupling convention asAgentRegistrar). The gorm implementation ishandlers.NewAgentTagStore. - Lock discipline: all DB IO happens outside
Registry.mu.SetTagsmutates memory + broadcasts under the lock, then saves out-of-lock (failure is logged only; memory stays the runtime truth).Registerloads persisted tags before taking the lock: reconnect keeps in-memory tags, cold start (server restart) restores them from the DB.
Batch endpoints
Fan-out is implemented in the Go server by looping over the existing per-agent commands (run_script 30s / stop_script 10s / trigger.add 10s / asset.sync 10s) over the existing agent TCP connection. No new command, listener, or transport is introduced.
| Endpoint | Permission | Underlying command |
|---|---|---|
POST /api/agents/batch/run-script | scripts:run | run_script |
POST /api/agents/batch/stop-script | scripts:run | stop_script |
POST /api/agents/batch/trigger | agents:manage | trigger.add |
POST /api/assets/sync | assets:manage | asset.sync |
Semantics:
- Selector
{agentIds?, tags?}: union (OR) of exact IDs and tag matches, deduplicated; empty selector → 400, >500 items → 400, zero matches →200 {total: 0}. - Dispatch uses a bounded semaphore (8 concurrent per request); each agent gets its own command timeout. Offline / disconnected agents stay in the result as
"agent offline". - Partial failure is not an overall failure: the response is always
200 {"success": true, "data": {total, succeeded, failed, results[]}}with a per-agent outcome. Script path resolution happens once before any dispatch (invalid path → 400, zero dispatch). - Each request writes one audit log (
script.batch_run/script.batch_stop/agent.batch_trigger_add) whose meta carries the selector and per-agent summary. DB writes happen serially after fan-out. - The workflow engine is intentionally unchanged; mapping tags to workflow workers is left for a future decision.
Asset Sync (A4 precursor)
Template images and other script companions are stored server-side (internal/assets, <AssetsDir>/<name> + .meta/<name>.json sidecar carrying sha256/size/timestamp, no DB row) and pushed with asset.sync: payload carries contentBase64 inline (same 1MB budget as inline scripts — the 16MB frame limit leaves ample headroom) plus the sha256 for the runtime to verify; the agent writes to <scriptDir>/assets/<name> atomically and reports the absolute path back so scripts can reference it. Name allow-lists are enforced identically on both ends (assets.SanitizeName / asset_handler.cpp), rejecting path traversal before any filesystem touch. Large assets still need a URL-pull path — deferred to the A4 rollout.
The Dashboard consumes these endpoints from the Agents page (row selection
- batch toolbar + result table); the trigger form is shared with the Monitor page as a callback-style component.
Team inbox push (team-scoped fan-out)
Teams are runtime state held by the in-memory TeamManager on the agent channel (created via POST /api/teams, joined by runtime agents via team.join); a server restart loses them by design. The HTTP face only adapts: GET /api/teams / GET /api/teams/:teamId read the current view (agents:manage), and POST /api/teams/:teamId/messages fans a message out to every member with a bound device. Delivery reuses the inbox path — SendMessageToAgent enqueues into the member's in-memory inbox buffer and the notifier callback emits the existing inbox.message notify frame over the agent TCP connection; offline members keep the buffered message until ack or disconnect purge. Members without a bound device (including the team leader created with the team) are reported as skipped in the per-member summary instead of enqueueing against an empty agentID. On the runtime side the frames cross back into the script layer through the same agent channel: outbound wingman.team.* calls emit a team.outbound EventHub event that apps/agent forwards as team.join / team.leave / team.vote_* / team.status_report / team.broadcast notifies, and inbound inbox.message notifies are converted to a team.inbound event that drives the team module state machine, then auto-acked (inbox.ack). The Android runtime does not consume inbox.message (see platforms.md).
Scripting Language Strategy (Dual-Language, Tiered Primacy)
Decision (2026-09-19): Wingman keeps both Lua and Python as script languages. Consolidating to a single language is explicitly rejected.
Why the dual-language tax is small by construction
- Script modules are written once in C++ as
ModuleDescriptors (lib/wingman/src/script/modules/module_registry.cpp) and bound generically by both engines (Lua via sol2, Python via pybind11, which additionally registers snake_case aliases). A new module costs one C++ file plus a Python.pyi; dual-language support is not a per-feature rewrite. - Total engine glue is ~1.2k LOC (
libs/lua/src550 +libs/python/src729). The Python-only burden is thelibs/python/typingstubs; Lua has no parallel annotation layer to maintain.
Tiered primacy
| Surface | Primary language | Rationale |
|---|---|---|
| Desktop (Win/Linux/macOS) | Python | LLM-assisted script authoring, larger talent pool, adjacent ml/AI scripting |
Android on-device agent (planned, see docs/mobile-support-feasibility.md) | Lua | ~0.3 MB footprint vs ~20-30 MB embedded CPython, ~1 ms engine startup, per-script Lua state with no GIL for concurrent scripts |
Interpreter speed is not the deciding factor: the script layer only orchestrates, while wall time is dominated by native capture / OpenCV matching / input injection. Where efficiency does matter (startup, per-instance memory, concurrency, APK size), Lua wins — and those are exactly the multi-instance and mobile constraints. LLM-era authoring convenience is Python's win, and it is equally strategic. Cutting either language forfeits one of these; keeping both forfeits neither.
Cost-reduction commitments (keeping the tax near zero)
- Generate
libs/python/typing/*.pyifromModuleDescriptorsignature strings instead of hand-maintaining stubs. - New docs and examples are written Python-first; Lua examples are kept as secondary references, not duplicated prose.
- Module global-state cleanup converges on a single registry mechanism instead of per-engine manual
cleanup*Module()declarations.
Non-goals
- Dropping Lua: it is the only viable language for the mobile agent path (footprint/GIL/startup) and the migration path from the Lua-based mobile game automation user base.
- Dropping Python: it carries desktop scripting UX and LLM-era accessibility.
- Introducing a third language (e.g. JavaScript for Auto.js script compatibility) requires a separate decision.
Platform Macro Boundary
Platform-conditional macros (_WIN32, __APPLE__, __linux__, __ANDROID__, _MSC_VER, ...) are confined to the thin implementation layer lib/wingman/src/platform/<os>/. The common layers (src/ outside platform/, include/wingman/platform/, libs/, apps/ production code) carry zero platform macros.
- Enforced by
scripts/check_platform_boundary.sh, run as the first CI job (Platform Boundary Guard). - Historical debt frozen at P0 (2026-09-19) lives in
scripts/platform_boundary_allowlist.txt: shrink-only. Adding new files to the allowlist requires maintainer approval and PR justification. - Rules and namespace conventions:
docs/platform-abstraction-design.md§8. New platform tenants (e.g.androidfor the on-device agent) join by implementing the existing interfaces in their own directory — no common- layer changes, no new platform branches in common code.
Documentation Requirement
When changing runtime control, local UI, or remote orchestration code, update this document and docs/architecture.md in the same change. If implementation is experimental, mark it explicitly as experimental instead of presenting it as the stable architecture.
Four-Layer Model: Core / Runtime / Agent / Control Plane
Decision (2026-10-04): Wingman's architecture is formally a four-layer model, with a hard Control Plane / Execution Plane boundary:
CONTROL PLANE (Go server, orchestrator/)
Dashboard (React) · RBAC · Audit · Agent Registry · Workflow engine
│
│ Agent TCP (outbound, authenticated register)
▼
AGENT LAYER (runtime identity, apps/agent + Android agent)
identity · register · heartbeat · capability · command · event
│
▼
RUNTIME LAYER (Execution Plane, apps/agent)
ScriptManager · Lua/Python engines · CommandDispatcher · TriggerManager
│
▼
AUTOMATION CORE (lib/wingman)
screen · input · window · vision · OCR · ML · process · kv · trigger · behaviorLayer ownership rules:
lib/wingman(Automation Core) must not know Agent / Dashboard / Workflow / User / Team concepts. It is the platform's most reusable asset and the portable core for the Android agent (see Platform Macro Boundary anddocs/platform-abstraction-design.md).- The Runtime layer owns script engines, the transport-agnostic
CommandDispatcher, execution lifecycle (script.run/stop, local IPC plus remote agent commands — see Dispatcher Reuse) and the local UI path (Tauri → IPC). - The Agent layer is the runtime's remote identity: what registers with the Go server, what is addressable by
agent_id(plus A3-P1 registration token,docs/agent-token-auth-design.md), and what owns the outbound TCP session. New with this decision: an Agent carries a capability set (see Capability System below), not just{agentId, hostname, platform}. - The Control Plane (Go server) is the only remote entry point. It owns registry, RBAC, audit, workflow scheduling, and all dashboard APIs. It must not dial runtimes (see Non-Negotiable Control Plane).
Consequences:
- Workflow steps are written against capabilities/roles, not hardcoded
agent_ids, once the Capability System lands (below). - The remote-desktop pixel plane (guacd gateway,
docs/remote-gateway-guacamole-design.md) stays in the Control Plane; the Agent layer is not part of it. - Existing naming is kept: "runtime" (Execution Plane) and "server"/"orchestrator" (Control Plane). New components must not blur these names.
- App naming (2026-10-09): the desktop app
apps/runtimeis renamedapps/agent(binarywingman-runtime→wingman-agent). The agent family is named by role: desktop agent (apps/agent), Android agent, and the planned load-test agent (apps/loadgen, seedocs/load-testing-design.md). The layer term "Runtime Layer" (Execution Plane) is unchanged — it names the architectural layer, not the app directory. Historical documents that say "runtime" for the app should be read as "desktop agent".
Automation Primitive Boundaries
Decision (2026-10-04): the five automation primitives have non-overlapping responsibilities. When a new feature can be realized with two primitives, the boundary below decides; when it still fits both, prefer the lighter primitive (Trigger < Script < Behavior Tree < Workflow < Team).
| Primitive | Responsibility | Example | Owner |
|---|---|---|---|
| Trigger | 事件 → 动作(单次/循环条件反应) | WHEN pixel becomes red THEN press F1 | lib/wingman TriggerManager, exposed over IPC + agent RPC (trigger.*) |
| Script | 过程逻辑(确定性步骤序列) | 检查背包 → 出售 → 断言结果 | Lua/Python engines over shared ModuleDescriptor modules |
| Behavior Tree | 实时决策(高频循环状态决策) | 战斗 AI:IsDead→Restart / HasTarget→Attack / else Patrol | lib/wingman behavior_tree |
| Workflow | 跨 Agent / 跨任务 / 长生命周期编排(DAG,可等待、重试、超时) | Agent A 准备 → Agent B 执行 → 截图步骤 → 条件分支 | Go server workflow engine |
| Team | 多个 Agent 的协同分组(角色 + 投票/收件箱等协商机制) | 团队副本分工 | Go server (计划中,见 ROADMAP「远程编排」Team/Vote/Inbox) |
Consequences:
- The four-level escalation path (Trigger → Script → BT → Workflow) is the documented default; features that jump a level must state why in review.
- Workflow must not duplicate Behavior Tree decision loops: BT is for millisecond-to-second in-loop decisions on one node; Workflow is for minute-to-hour pipelines across nodes.
- Scripts stay the unit of execution for both local UI and remote command (
run_script/stop_script); Workflow steps reference scripts, not inline logic, until an explicit inline-step decision is made.
Capability System
Decision (2026-10-04): Agent registration reports a standardized capability set, and scheduling is written against capabilities instead of platforms.
Capability identifiers are dotted namespaces, versioned at the server:
screen.capture screen.stream screen.listMonitors
input.mouse input.keyboard input.touch
window.enumerate window.activate
process.spawn
vision.image vision.color
ocr
ml.onnxRegistration payload (both desktop runtime and Android agent, same shape):
{
"agentId": "pc-shanghai-01",
"platform": "windows",
"arch": "x64",
"version": "1.2.0",
"capabilities": ["screen.capture", "input.mouse", "input.keyboard", "window.enumerate", "vision.image", "ml.onnx"]
}Rules:
- The Go server owns the capability vocabulary and persists it on
models.Agent(alongside tags, sameTagStore-style decoupling). Unknown capability names are accepted but flagged in the registry UI asunverifieduntil a server review adds them to the vocabulary. - The runtime derives its capability set from the platform implementation actually compiled in (a build without OCR omits
ocr), never from config only. - Workflow steps may declare
requires: {platform, capabilities[]}; the scheduler matches agents against it and reports a scheduling error listing candidate agents instead of failing silently. This supersedes the current "workflow workers by tags" note in Agent Groups & Batch Operations.
Status: v1 landed (2026-10-04). The wire fields (platform, capabilities) exist in agent.register (docs/android-agent-design.md, docs/protocols.md); the desktop runtime derives its set from the compiled platform implementations (ML build gates ml.onnx; touch/stream never claimed), the Android agent carries them via agent config; the Go server owns the vocabulary, persists capabilities on models.Agent, and flags unknown names as unverified (unknownCapabilities); the Dashboard Agents page shows both; workflow selectAgent filters candidates on requires: {platform, capabilities[]} and reports the unsatisfied requirement with the online agent list on mismatch. Module-level granularity (file IO, tray, hotkey) is intentionally outside the v1 vocabulary; revisit when a scheduler needs it. Agent identity hardening (registration token → device identity) proceeds per docs/agent-token-auth-design.md.
Execution as the Platform Core Object
Decision (2026-10-04): executions — not commands — are the platform's core tracked object. A single Execution model unifies script runs, workflow step runs, and agent-side command runs:
{
"executionId": "...",
"workflowId": "...", "stepId": "...",
"agentId": "...",
"scriptId": "...",
"status": "running",
"startedAt": "...", "finishedAt": "...",
"timeout": 30,
"retry": 0,
"logs": [], "artifacts": [], "result": {}
}Status model: pending → queued → running → {succeeded | failed | cancelled | timeout | lost} (stopping in flight).
Rules:
- The server is the source of truth for
Execution; runtime reports into it via the same-sequenceResponse frame of each command and Notifyagent.event(script_output/script_state) — existing protocol, unchanged (naming perdocs/protocols.md). - Artifacts (screenshots, captured frames, stdout/stderr, result.json, ML output) are first-class children of an execution, addressed as
execution/{id}/artifacts/{name}and listed in the Dashboard Execution view. The deployment-screenshot path (.github/workflows/deploy-server.yml→deploy-screenshotartifact upload,docs/remote-gateway-guacamole-design.md) remains a deploy-time concern; the platform artifact model is orthogonal and server-side. - Audit log entries reference
executionIdwhen one exists.
Status: v1 landed (2026-10-04). models.Execution exists in the Go server; run_script, batch fan-out (offline agents skipped with single-send semantics), and workflow steps (one record per attempt, WorkflowID/StepID attached, errStepTimeout sentinel distinguishing timeout from cancelled) all write execution records; the Dashboard Executions page exposes the unified read-only view (list + detail with result/artifacts). Known v1 gaps, tracked for later: screenshot steps do not create executions yet; wait/condition steps are server-internal (not agent-dispatched) and stay unwired; artifacts are recorded as references, the execution/{id}/artifacts/{name} store is not built (the deploy-screenshot path remains deploy-time).