Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion iii-directory/Cargo.lock

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

1 change: 1 addition & 0 deletions iii-directory/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ path = "src/lib.rs"

[dependencies]
iii-sdk = "=0.16.0-next.2"
arc-swap = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "fs", "process"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
60 changes: 44 additions & 16 deletions iii-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,24 +78,50 @@ npx skills add iii-hq/workers --all

## Configuration

Runtime settings live in the **`configuration` worker** under id
**`iii-directory`** (the same pattern `database` and `storage` use). At boot
the worker registers its JSON Schema, reads the live value via
`configuration::get` (the configuration worker env-expands `${VAR}`), and binds
a `configuration` trigger so it re-fetches on change.

Persisted values default to `./data/configuration/iii-directory.yaml` (fs
adapter). Edit that file directly, call `configuration::set id=iii-directory`,
or use the console Workers tab — all three propagate without a redeploy.

### Fields

```yaml
# Folder that backs every read (`directory::skills::list`,
# `directory::skills::get`, `directory::prompts::*`) and every write
# from `directory::skills::download`. Relative paths are resolved
# against the process current working directory; absolute paths are
# used as-is.
skills_folder: ./skills

# Workers registry base URL — used by `directory::skills::download`
# and the `directory::registry::*` proxies when a `worker=` source is
# specified. Override for self-hosted deployments.
registry_url: https://api.workers.iii.dev

# Timeout for a single download (`git clone` or HTTP request) in ms.
download_timeout_ms: 60000
# TOPOLOGY — changing any of these requires a worker restart.
skills_folder: ~/.iii/skills # read/write root for skills + prompts
local_skills_folder: ./.iii/skills # project-scoped overrides (whole-namespace local-wins)
auto_download: true # subscribe to worker-add + run the boot reconcile

# TUNABLE — hot-reload live on `configuration:updated`.
registry_url: https://api.workers.iii.dev # workers registry base URL
download_timeout_ms: 60000 # per git-clone / HTTP request timeout (ms)
registry_cache_ttl_ms: 60000 # in-process TTL for registry::workers::* responses
filter_unregistered: true # hide skills whose namespace isn't an installed worker
```

The folder is created on first download if it doesn't exist.
The `skills_folder` is created on first download if it doesn't exist.

### Zero-config default + seed

With no seed and no stored value the worker uses built-in defaults
(`skills_folder: ~/.iii/skills`, `registry_url: https://api.workers.iii.dev`).
Pass `--config <path>` to supply a YAML seed: when present and no value is
stored yet, its contents become `initial_value` on `configuration::register`
(see [`config.yaml.example`](config.yaml.example)). Engine-managed deployments
inline the config under the worker entry; the engine delivers it via `--config`.

### Hot reload

On `configuration::set` (or an external edit to the persisted file), the worker
re-fetches the authoritative value. Tunable changes apply in place and the
registry caches are cleared so a repointed `registry_url` takes effect
immediately. Topology changes (`skills_folder` / `local_skills_folder` /
`auto_download`) are refused with a "restart required" log; the previous
configuration is kept until the worker restarts.

---

Expand Down Expand Up @@ -265,7 +291,9 @@ block on downstream latency.
### Run from source

```bash
cargo run --release -- --url ws://127.0.0.1:49134 --config ./config.yaml
# --config is an optional YAML seed (see config.yaml.example); omit it to
# rely on the value stored in the `configuration` worker (or built-in defaults).
cargo run --release -- --url ws://127.0.0.1:49134 --config ./config.yaml.example
```

### Tests
Expand Down
16 changes: 0 additions & 16 deletions iii-directory/config.yaml

This file was deleted.

34 changes: 34 additions & 0 deletions iii-directory/config.yaml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Optional seed file for first-time registration
# (`iii-directory --config ./config.yaml.example`).
#
# As of this version, iii-directory's runtime config lives in the
# `configuration` worker under id `iii-directory` (the same pattern
# `database` and `storage` use). This file is only a SEED: when the worker
# is launched with `--config <this file>` AND no value is yet stored for id
# `iii-directory`, its contents are passed as `initial_value` on
# `configuration::register` (the configuration worker env-expands `${VAR}`).
# After that first register the stored value is authoritative; edit it with
# `configuration::set id=iii-directory` (or the console Workers tab), or by
# editing `./data/configuration/iii-directory.yaml` directly.
#
# Tunable fields hot-reload on change (registry_url, download_timeout_ms,
# registry_cache_ttl_ms, filter_unregistered). Topology fields require a
# worker restart (skills_folder, local_skills_folder, auto_download).
#
# When omitted entirely, the worker seeds the built-in defaults
# (skills_folder: ~/.iii/skills, registry_url: https://api.workers.iii.dev).

# Folder that backs every read (`directory::skills::list`,
# `directory::skills::get`, `directory::prompts::*`) and every write
# from `directory::skills::download`. Relative paths are resolved
# against the process current working directory; absolute paths are
# used as-is.
skills_folder: ./skills

# Workers registry base URL — used by `directory::skills::download` and
# the `directory::registry::*` proxies when a `worker=` source is
# specified.
registry_url: https://api.workers.iii.dev

# Timeout for a single download (`git clone` or HTTP request) in ms.
download_timeout_ms: 60000
164 changes: 163 additions & 1 deletion iii-directory/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@
//! glob arrays, no scopes — everything lives on disk under one root.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::Result;
use arc_swap::ArcSwap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Shared, hot-reloadable config handle. Handlers snapshot the current
/// value per call (`handle.load_full()`, lock-free) so a
/// `configuration:updated` reload that `store`s a new value takes effect
/// on the next invocation without re-registering any function.
pub type SharedConfig = Arc<ArcSwap<SkillsConfig>>;

/// Default base URL for the workers registry. Overrideable via
/// `registry_url:` in the config so self-hosted deployments can repoint.
Expand Down Expand Up @@ -51,7 +61,7 @@ fn default_auto_download() -> bool {
true
}

#[derive(Deserialize, Serialize, Debug, Clone)]
#[derive(Deserialize, Serialize, Debug, Clone, JsonSchema)]
pub struct SkillsConfig {
/// Folder that backs every read (`directory::skills::list`,
/// `directory::skills::get`, `directory::prompts::*`) and every
Expand Down Expand Up @@ -173,6 +183,74 @@ impl SkillsConfig {
pub fn registry_base(&self) -> &str {
self.registry_url.trim_end_matches('/')
}

/// Restart-requiring fields. A `configuration:updated` reload that
/// changes any of these is refused (logged "restart required"):
/// `skills_folder` / `local_skills_folder` are the on-disk read/write
/// roots baked into running tasks, and `auto_download` wires the
/// `worker`-trigger subscription + boot reconcile at startup — none can
/// be re-wired safely in place.
pub fn topology(&self) -> Topology {
Topology {
skills_folder: self.skills_folder.clone(),
local_skills_folder: self.local_skills_folder.clone(),
auto_download: self.auto_download,
}
}

/// JSON Schema registered with the `configuration` worker so the
/// console can render an editor for the `iii-directory` entry.
pub fn json_schema() -> Value {
let root = schemars::schema_for!(SkillsConfig);
let mut schema =
serde_json::to_value(&root.schema).expect("SkillsConfig JSON Schema serializes");
if let Some(obj) = schema.as_object_mut() {
if !root.definitions.is_empty() {
obj.insert(
"definitions".into(),
serde_json::to_value(&root.definitions).expect("definitions serialize"),
);
}
obj.insert("example".into(), SkillsConfig::default().to_json());
}
schema
}

/// Parse a YAML seed file. Used only to build `initial_value` on first
/// `configuration::register`; the configuration worker env-expands
/// `${VAR}` on read, so no local expansion is done here.
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}"))
}

/// Read and parse a YAML seed file from disk.
pub fn from_file(path: &str) -> Result<Self, String> {
let raw = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?;
Self::from_yaml(&raw)
}

/// Parse the authoritative value returned by `configuration::get`.
pub fn from_json(value: &Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}"))
}

/// Serialize for `initial_value` on register.
pub fn to_json(&self) -> Value {
serde_json::to_value(self).expect("SkillsConfig serializes")
}

/// Wrap into a shared, hot-reloadable handle (see [`SharedConfig`]).
pub fn into_shared(self) -> SharedConfig {
Arc::new(ArcSwap::from_pointee(self))
}
}

/// Restart-requiring subset of [`SkillsConfig`] (see [`SkillsConfig::topology`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Topology {
pub skills_folder: String,
pub local_skills_folder: String,
pub auto_download: bool,
}

pub fn load_config(path: &str) -> Result<SkillsConfig> {
Expand Down Expand Up @@ -300,4 +378,88 @@ auto_download: false
};
assert_eq!(cfg.registry_base(), "https://api.example");
}

#[test]
fn json_schema_is_object_with_known_properties() {
let schema = SkillsConfig::json_schema();
let props = schema
.get("properties")
.and_then(|p| p.as_object())
.unwrap();
for field in [
"skills_folder",
"local_skills_folder",
"registry_url",
"download_timeout_ms",
"registry_cache_ttl_ms",
"filter_unregistered",
"auto_download",
] {
assert!(props.contains_key(field), "schema missing {field}");
}
assert!(schema.get("example").is_some());
}

#[test]
fn to_json_from_json_roundtrip() {
let cfg = SkillsConfig {
skills_folder: "./my-skills".into(),
registry_url: "https://example.com/registry".into(),
download_timeout_ms: 1234,
registry_cache_ttl_ms: 5678,
filter_unregistered: false,
auto_download: false,
..SkillsConfig::default()
};
let back = SkillsConfig::from_json(&cfg.to_json()).unwrap();
assert_eq!(back.skills_folder, cfg.skills_folder);
assert_eq!(back.registry_url, cfg.registry_url);
assert_eq!(back.download_timeout_ms, cfg.download_timeout_ms);
assert_eq!(back.registry_cache_ttl_ms, cfg.registry_cache_ttl_ms);
assert_eq!(back.filter_unregistered, cfg.filter_unregistered);
assert_eq!(back.auto_download, cfg.auto_download);
}

#[test]
fn from_yaml_matches_from_json_for_seed_shape() {
let yaml = "skills_folder: ./s\nregistry_url: https://r\ndownload_timeout_ms: 10\n";
let from_yaml = SkillsConfig::from_yaml(yaml).unwrap();
let from_json = SkillsConfig::from_json(&from_yaml.to_json()).unwrap();
assert_eq!(from_yaml.skills_folder, from_json.skills_folder);
assert_eq!(from_yaml.registry_url, from_json.registry_url);
assert_eq!(from_yaml.download_timeout_ms, from_json.download_timeout_ms);
}

#[test]
fn topology_equal_when_only_tunables_differ() {
let base = SkillsConfig::default();
let tuned = SkillsConfig {
registry_url: "https://other".into(),
download_timeout_ms: 1,
registry_cache_ttl_ms: 2,
filter_unregistered: !base.filter_unregistered,
..base.clone()
};
assert_eq!(base.topology(), tuned.topology());
}

#[test]
fn topology_differs_when_a_topology_field_changes() {
let base = SkillsConfig::default();
let folder = SkillsConfig {
skills_folder: "/other".into(),
..base.clone()
};
let local = SkillsConfig {
local_skills_folder: "/other-local".into(),
..base.clone()
};
let auto = SkillsConfig {
auto_download: !base.auto_download,
..base.clone()
};
assert_ne!(base.topology(), folder.topology());
assert_ne!(base.topology(), local.topology());
assert_ne!(base.topology(), auto.topology());
}
}
Loading
Loading