Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`crates/tinyflows-schedule`** — the schedule model (`Schedule`,
`ActiveHours`, the job/run records) and its pure logic: cron-expression
normalisation, time-zone and active-window aware next-run computation,
validation and the minimum-cadence check. Extracted from OpenHuman's `cron`
domain; the serde shapes are byte-identical and pinned by literal-JSON
fixtures. No runtime, store or config.
- **`tinyflows_catalog::graph_hash::compute_graph_hash`** — the content pin over
a graph plus its `require_approval` flag that a parked run records and a
resume re-checks. Extracted from OpenHuman; the digest is byte-identical to
what that host persisted, and a fixed-vector test keeps it so.
- **`tinyflows_copilot::trail_off`** — `text_looks_like_question` and the
code-span / URL / paragraph heuristics behind the builder-convergence
question backstop.
- A nested inline `sub_workflow` case in the engine-compatibility tests.

### Changed

- **The repository is a virtual workspace; every crate lives under `crates/`.**
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ uses, so a reader who knows one knows them all.
| `crates/tinyflows-sqlite` | SQLite implementations of the catalog and of the engine's checkpoint store, plus the JSON draft store. Every entry point takes a directory, never a host config type. |
| `crates/tinyflows-copilot` | The authoring copilot's *words*: the `workflow_builder` / `flow_discovery` standing archetypes, and the turn brief that opens a builder turn. Names no tool trait, no agent registry, no model client. |
| `crates/tinyflows-adaptive` | The adaptive loop over the engine: select or author a workflow, run it, judge it, learn. |
| `crates/tinyflows-schedule` | Schedule model and pure next-run logic (cron / interval / one-shot, time zones, active hours, cadence floor). No runtime, store or config. |

**Where a new thing goes.** Ask what it depends on, not what it is about. If it
needs storage, it is not `tinyflows-catalog`. If it needs a tool trait or a model
Expand Down
76 changes: 76 additions & 0 deletions Cargo.lock

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

5 changes: 5 additions & 0 deletions crates/tinyflows-catalog/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ tokio-util = { version = "0.7", default-features = false }
# Diagnostics only: the registries and the importers log what they refuse and
# why. No subscriber is installed here — a host that installs none pays nothing.
tracing = { workspace = true }
# `graph_hash` only: the SHA-256 that pins a graph's content for approval
# resume. Hex-encoded, so the digest is the same string a host persisted before
# this moved here.
sha2 = { workspace = true }
hex = "0.4"

[dev-dependencies]
serde_json = { workspace = true }
81 changes: 81 additions & 0 deletions crates/tinyflows-catalog/src/graph_hash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! A stable content hash over a graph and its approval flag.
//!
//! A run that parks for approval records this hash; when it is resumed the host
//! recomputes it and refuses to continue if the graph (or the approval setting)
//! changed while the run was parked. Because the value is persisted, the digest
//! is a compatibility surface: it must stay byte-for-byte identical across
//! releases, so the tests pin a fixed vector.

use serde_json::Value;
use sha2::{Digest, Sha256};
use tinyflows::model::WorkflowGraph;

/// Hashes `graph` together with `require_approval` into a lowercase hex SHA-256.
///
/// The hash is over graph *content*, never incidental key order: the graph is
/// serialized to a JSON value and every object's keys are recursively sorted
/// before the value is rendered, so two graphs that differ only in the order
/// `serde_json` happened to emit their object keys hash identically. Array
/// order is preserved because it is semantically meaningful.
///
/// Returns `None` (never panics) when the graph fails to serialize. The two
/// sides of a resume treat `None` differently: at park time it simply stores no
/// pin, so the run later takes the legacy "unknown, allow with a warning" path;
/// at resume time `Some(expected) != None` is a mismatch, so the run is refused.
/// A hash failure therefore fails closed on resume.
#[must_use]
pub fn compute_graph_hash(graph: &WorkflowGraph, require_approval: bool) -> Option<String> {
let raw = match serde_json::to_value(graph) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "flows",
error = %e,
"[flows] compute_graph_hash: failed to serialize graph to JSON — proceeding without a graph pin"
);
return None;
}
};
let raw = serde_json::json!({ "graph": raw, "require_approval": require_approval });
let canonical = canonicalize_json(&raw);
let serialized = match serde_json::to_string(&canonical) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
target: "flows",
error = %e,
"[flows] compute_graph_hash: failed to serialize canonicalized graph — proceeding without a graph pin"
);
return None;
}
};
let digest = Sha256::digest(serialized.as_bytes());
Some(hex::encode(digest))
}

/// Recursively rewrites every JSON object's keys into sorted order, leaving
/// arrays (whose element order is semantically meaningful) and scalars
/// unchanged.
///
/// Sorting is explicit rather than trusting `serde_json`'s default map order,
/// which is sorted only when the `preserve_order` feature is off; another crate
/// in the build enabling it would otherwise change every persisted hash.
fn canonicalize_json(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut sorted = serde_json::Map::new();
for key in keys {
sorted.insert(key.clone(), canonicalize_json(&map[key]));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()),
other => other.clone(),
}
}

#[cfg(test)]
#[path = "graph_hash_tests.rs"]
mod tests;
Loading
Loading