Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8c1dc4d
vcli-runtime: scaffold crate + workspace wiring
blaksmatic Apr 19, 2026
e89e4c4
vcli-runtime: RuntimeError with stable code() strings
blaksmatic Apr 19, 2026
6507136
vcli-runtime: SchedulerCommand enum
blaksmatic Apr 19, 2026
e02b698
vcli-runtime: RuntimeClock trait with SystemRuntimeClock + ManualClock
blaksmatic Apr 19, 2026
c3472c6
vcli-runtime: EventEmitter wrapper stamps Events with clock
blaksmatic Apr 19, 2026
fa568bf
vcli-runtime: legal program-state transition table
blaksmatic Apr 19, 2026
5566afc
vcli-runtime: RunningProgram container with body_cursor + watch_state
blaksmatic Apr 19, 2026
52d5077
vcli-runtime: trigger evaluation (OnSubmit + OnPredicate + Manual)
blaksmatic Apr 19, 2026
f1e552a
vcli-runtime: \$pred.match.{center,bbox} resolver
blaksmatic Apr 19, 2026
bfba26f
vcli-runtime: watch lifetime bookkeeping + fire decision
blaksmatic Apr 19, 2026
6a6e616
vcli-runtime: priority-based action arbiter
blaksmatic Apr 19, 2026
7a4f8ca
vcli-runtime: input postcondition + novelty_timeout evaluator
blaksmatic Apr 19, 2026
78bcd2a
vcli-runtime: cross-program predicate dedup helper
blaksmatic Apr 19, 2026
64b7980
vcli-runtime: per-tick budget gate + pressure streak
blaksmatic Apr 19, 2026
1621393
vcli-runtime: body step executor (one step per tick)
blaksmatic Apr 19, 2026
22def1b
vcli-runtime: Scheduler::new + run_until_shutdown skeleton
blaksmatic Apr 19, 2026
095c7f0
vcli-runtime: fleshed-out tick_once (capture, watches, body, arbiter)
blaksmatic Apr 19, 2026
cb0a2d7
vcli-runtime: apply cargo fmt
blaksmatic Apr 19, 2026
c721330
vcli-runtime: scenario-test mocks (ScriptedCapture, RecordingInputSink)
blaksmatic Apr 19, 2026
ccc2cba
vcli-runtime: scenario one_shot_watch + tick wiring to pass it
blaksmatic Apr 19, 2026
a97238d
vcli-runtime: scenario persistent_watch
blaksmatic Apr 19, 2026
3ad9508
vcli-runtime: scenario until_predicate
blaksmatic Apr 19, 2026
5ba1140
vcli-runtime: scenario watch_timeout
blaksmatic Apr 19, 2026
5246d0e
vcli-runtime: scenario while_true_retry
blaksmatic Apr 19, 2026
6d51ec4
vcli-runtime: scenario wait_for_timeout
blaksmatic Apr 19, 2026
c0bde17
vcli-runtime: scenario assert_failure
blaksmatic Apr 19, 2026
f7a2904
vcli-runtime: scenario input_postcondition + empty-step watches bypas…
blaksmatic Apr 19, 2026
947893b
vcli-runtime: scenario novelty_timeout
blaksmatic Apr 19, 2026
7eff8c9
vcli-runtime: scenario action_conflict
blaksmatic Apr 19, 2026
ffa7c68
vcli-runtime: scenario predicate_dedup
blaksmatic Apr 19, 2026
0e53a4d
vcli-runtime: scenario elapsed_ms_since_true
blaksmatic Apr 19, 2026
80ea3c8
vcli-runtime: scenario daemon_restart_marker
blaksmatic Apr 19, 2026
43d341e
vcli-runtime: workspace gate clean (fmt + clippy -D warnings + tests)
blaksmatic Apr 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"crates/vcli-input",
"crates/vcli-ipc",
"crates/vcli-perception",
"crates/vcli-runtime",
"crates/vcli-store",
]

Expand Down Expand Up @@ -38,6 +39,9 @@ hex = "0.4"

tracing = "0.1"

# Added by vcli-runtime lane
crossbeam-channel = "0.5"

# Platform-specific (used by vcli-input + vcli-capture)
core-graphics = { version = "0.24", features = ["highsierra"] }
core-foundation = "0.10"
Expand Down
77 changes: 77 additions & 0 deletions crates/vcli-runtime/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
[package]
name = "vcli-runtime"
version.workspace = true
edition.workspace = true
rust-version.workspace= true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Deterministic 10 fps scheduler that wires Capture → Perception → Arbiter → Input for vcli programs."

[dependencies]
vcli-core = { path = "../vcli-core" }
vcli-capture = { path = "../vcli-capture" }
vcli-input = { path = "../vcli-input" }
vcli-perception = { path = "../vcli-perception" }

crossbeam-channel = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde_json = { workspace = true }

[dev-dependencies]
vcli-dsl = { path = "../vcli-dsl" }
proptest = { workspace = true }
tempfile = { workspace = true }

[[test]]
name = "one_shot_watch"
path = "tests/scenarios/one_shot_watch.rs"

[[test]]
name = "persistent_watch"
path = "tests/scenarios/persistent_watch.rs"

[[test]]
name = "until_predicate"
path = "tests/scenarios/until_predicate.rs"

[[test]]
name = "watch_timeout"
path = "tests/scenarios/watch_timeout.rs"

[[test]]
name = "while_true_retry"
path = "tests/scenarios/while_true_retry.rs"

[[test]]
name = "wait_for_timeout"
path = "tests/scenarios/wait_for_timeout.rs"

[[test]]
name = "assert_failure"
path = "tests/scenarios/assert_failure.rs"

[[test]]
name = "input_postcondition"
path = "tests/scenarios/input_postcondition.rs"

[[test]]
name = "novelty_timeout"
path = "tests/scenarios/novelty_timeout.rs"

[[test]]
name = "action_conflict"
path = "tests/scenarios/action_conflict.rs"

[[test]]
name = "predicate_dedup"
path = "tests/scenarios/predicate_dedup.rs"

[[test]]
name = "elapsed_ms_since_true"
path = "tests/scenarios/elapsed_ms_since_true.rs"

[[test]]
name = "daemon_restart_marker"
path = "tests/scenarios/daemon_restart_marker.rs"
13 changes: 13 additions & 0 deletions crates/vcli-runtime/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
fn main() {
#[cfg(target_os = "macos")]
{
// vcli-runtime pulls in vcli-capture → screencapturekit transitively.
// screencapturekit's Swift bridge references libswift_Concurrency.dylib.
// vcli-capture sets this rpath for its own artifacts; we repeat it here
// so vcli-runtime's test + binary output can load the Swift dylib too.
let xcode_swift55_path =
"/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain\
/usr/lib/swift-5.5/macosx";
println!("cargo:rustc-link-arg=-Wl,-rpath,{xcode_swift55_path}");
}
}
155 changes: 155 additions & 0 deletions crates/vcli-runtime/src/arbiter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! Action arbitration. Spec §403.

use vcli_core::program::Priority;
use vcli_core::ProgramId;

/// One pending action for the arbiter to consider.
#[derive(Debug, Clone)]
pub struct Candidate<T> {
/// Source program.
pub program_id: ProgramId,
/// Static priority from `program.priority`.
pub priority: Priority,
/// The action / step payload (opaque to the arbiter).
pub payload: T,
}

/// Outcome of arbitration for one candidate.
#[derive(Debug, Clone)]
pub struct Decision<T> {
/// Source program.
pub program_id: ProgramId,
/// Payload (passed through unchanged).
pub payload: T,
/// True if this payload should dispatch.
pub dispatch: bool,
/// If not dispatched, the conflicting winner's id.
pub loser_of: Option<ProgramId>,
}

/// Resolve conflicts: at most one dispatch per tick. Ordering:
/// 1. highest `Priority` wins
/// 2. tiebreak by lexicographic `ProgramId` (larger wins, for determinism)
///
/// If `candidates.len() <= 1` everyone dispatches.
pub fn resolve<T: Clone>(candidates: Vec<Candidate<T>>) -> Vec<Decision<T>> {
if candidates.len() <= 1 {
return candidates
.into_iter()
.map(|c| Decision {
program_id: c.program_id,
payload: c.payload,
dispatch: true,
loser_of: None,
})
.collect();
}
let winner = candidates
.iter()
.max_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.program_id.to_string().cmp(&b.program_id.to_string()))
})
.map(|c| c.program_id)
.expect("len > 1 handled above");
candidates
.into_iter()
.map(|c| {
let is_winner = c.program_id == winner;
Decision {
program_id: c.program_id,
payload: c.payload,
dispatch: is_winner,
loser_of: if is_winner { None } else { Some(winner) },
}
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

fn id(n: u8) -> ProgramId {
let s = format!("{n:02x}345678-1234-4567-8910-111213141516");
s.parse().unwrap()
}

#[test]
fn single_candidate_always_dispatches() {
let out = resolve(vec![Candidate {
program_id: id(1),
priority: Priority(0),
payload: "a",
}]);
assert_eq!(out.len(), 1);
assert!(out[0].dispatch);
assert!(out[0].loser_of.is_none());
}

#[test]
fn higher_priority_wins() {
let out = resolve(vec![
Candidate {
program_id: id(1),
priority: Priority(0),
payload: "a",
},
Candidate {
program_id: id(2),
priority: Priority(5),
payload: "b",
},
]);
let dispatched: Vec<_> = out.iter().filter(|d| d.dispatch).collect();
assert_eq!(dispatched.len(), 1);
assert_eq!(dispatched[0].program_id, id(2));
assert_eq!(
out.iter().find(|d| !d.dispatch).unwrap().loser_of,
Some(id(2))
);
}

#[test]
fn tie_breaks_by_program_id_lex_desc() {
let out = resolve(vec![
Candidate {
program_id: id(1),
priority: Priority(0),
payload: "a",
},
Candidate {
program_id: id(2),
priority: Priority(0),
payload: "b",
},
]);
let winner = out.iter().find(|d| d.dispatch).unwrap().program_id;
assert_eq!(winner, id(2));
}

#[test]
fn three_way_drops_two_losers() {
let out = resolve(vec![
Candidate {
program_id: id(1),
priority: Priority(3),
payload: "a",
},
Candidate {
program_id: id(2),
priority: Priority(3),
payload: "b",
},
Candidate {
program_id: id(3),
priority: Priority(3),
payload: "c",
},
]);
let dispatched: Vec<_> = out.iter().filter(|d| d.dispatch).collect();
assert_eq!(dispatched.len(), 1);
assert_eq!(dispatched[0].program_id, id(3));
}
}
Loading
Loading