feat: execute durable standalone Jobs through Host-owned Internal Workers (T-708)

This commit is contained in:
2026-10-07 00:08:01 +09:00
parent 3c424fd17d
commit 13055d7ee1
30 changed files with 5108 additions and 153 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "job"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
manifest.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
+336
View File
@@ -0,0 +1,336 @@
//! Transport-neutral immutable Job intent and structured-result contract.
//!
//! Hosts own execution, authority, persistence and lifecycle state. This crate
//! deliberately contains no runner, state machine, Runtime or domain grants.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8;
pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64;
pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120;
pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600;
pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024;
pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024;
pub const DEFAULT_MAX_ATTEMPTS: u8 = 2;
pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3;
pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024;
pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024;
pub const MAX_JOB_PURPOSE_BYTES: usize = 120;
pub const MAX_JOB_REFERENCE_BYTES: usize = 512;
pub const MAX_JOB_FAILURE_BYTES: usize = 1024;
pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum JobError {
#[error("{0}")]
InvalidInput(String),
}
pub type Result<T> = std::result::Result<T, JobError>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct JobLimits {
#[serde(default = "default_max_concurrent_jobs")]
pub max_concurrent_jobs: u16,
#[serde(default = "default_job_timeout_seconds")]
pub timeout_seconds: u32,
#[serde(default = "default_max_result_bytes")]
pub max_result_bytes: u32,
#[serde(default = "default_max_attempts")]
pub max_attempts: u8,
}
impl Default for JobLimits {
fn default() -> Self {
Self {
max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
timeout_seconds: DEFAULT_JOB_TIMEOUT_SECONDS,
max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
max_attempts: DEFAULT_MAX_ATTEMPTS,
}
}
}
impl JobLimits {
pub fn validate(&self) -> Result<()> {
if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS
{
return Err(JobError::InvalidInput(format!(
"Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}"
)));
}
if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS {
return Err(JobError::InvalidInput(format!(
"Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}"
)));
}
if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES {
return Err(JobError::InvalidInput(format!(
"Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}"
)));
}
if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS {
return Err(JobError::InvalidInput(format!(
"Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}"
)));
}
Ok(())
}
}
const fn default_max_concurrent_jobs() -> u16 {
DEFAULT_MAX_CONCURRENT_JOBS
}
const fn default_job_timeout_seconds() -> u32 {
DEFAULT_JOB_TIMEOUT_SECONDS
}
const fn default_max_result_bytes() -> u32 {
DEFAULT_MAX_RESULT_BYTES
}
const fn default_max_attempts() -> u8 {
DEFAULT_MAX_ATTEMPTS
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobRequest {
/// Stable caller-selected idempotency key within the host's namespace.
pub job_id: String,
pub purpose: String,
/// Revision fenced by the structured result.
pub input_revision: String,
pub input_ref: String,
/// Bounded immutable snapshot; must not contain credentials.
pub input: Value,
pub instruction: String,
/// Manifest registry selector, never raw source or a filesystem path.
pub profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serialization_key: Option<String>,
#[serde(default)]
pub limits: JobLimits,
}
impl JobRequest {
pub fn validate(&self) -> Result<()> {
self.validate_fields()?;
self.validate_input()?;
self.limits.validate()
}
/// Request metadata validation, separable so adapters can retain their
/// domain-validation ordering before validating input and limits.
pub fn validate_fields(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
validate_identifier("input_revision", &self.input_revision, 256)?;
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
self.profile_selector()?;
if let Some(key) = &self.serialization_key {
validate_identifier("serialization_key", key, MAX_JOB_REFERENCE_BYTES)?;
}
Ok(())
}
pub fn validate_input(&self) -> Result<()> {
let input = serde_json::to_vec(&self.input)
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
if input.len() > MAX_JOB_INPUT_BYTES {
return Err(JobError::InvalidInput(format!(
"Job input exceeds {MAX_JOB_INPUT_BYTES} bytes"
)));
}
Ok(())
}
/// Syntax only; registry existence and resolution are host authority.
pub fn profile_selector(&self) -> Result<manifest::ProfileSelector> {
profile_selector(&self.profile)
}
pub fn fingerprint(&self) -> Result<String> {
self.validate()?;
fingerprint(self)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
worker_input(self, attempt_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobResultSubmission {
pub job_id: String,
pub attempt_id: String,
pub input_revision: String,
pub result: Value,
}
/// Client-facing lifecycle vocabulary, not a runner or transition policy.
/// A variant's presence does not imply every host supports that operation.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JobState {
Pending,
Completed,
Failed,
Unknown,
Cancelled,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JobAttemptState {
Reserved,
Dispatching,
Dispatched,
Completed,
Failed,
Unknown,
Cancelled,
}
/// Transport-neutral read snapshot. Job status/result and the corresponding
/// attempt identity/status remain distinct; host identities, delivery and
/// resource cleanup are deliberately not part of the model outcome.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobOutcome {
pub job_id: String,
pub input_revision: String,
pub attempt_id: String,
pub attempt: u8,
pub state: JobState,
pub attempt_state: JobAttemptState,
pub result: Option<Value>,
pub failure_category: Option<String>,
pub failure_detail: Option<String>,
}
pub fn profile_selector(profile: &str) -> Result<manifest::ProfileSelector> {
use manifest::ProfileSelector;
validate_identifier("profile", profile, 256)?;
let selector = ProfileSelector::parse_cli(profile);
let valid = match &selector {
ProfileSelector::Default => true,
ProfileSelector::Path { .. } => false,
ProfileSelector::Named { source, name } => {
!name.is_empty()
&& !name.starts_with('.')
&& !name.contains(['/', '\\', ':'])
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
&& ![".toml", ".json", ".dcdl", ".nix"]
.iter()
.any(|suffix| name.ends_with(suffix))
&& (source.is_some() || name != "inherit")
}
};
if !valid {
return Err(JobError::InvalidInput(
"Job profile must be a manifest registry selector, not raw source or a path".into(),
));
}
Ok(selector)
}
/// Hash the full serialized host intent, not just its neutral projection.
/// Callers must validate the complete intent first. JSON field ordering and
/// omission rules are intentionally preserved for durable replay compatibility.
pub fn fingerprint<T: Serialize + ?Sized>(intent: &T) -> Result<String> {
let encoded = serde_json::to_vec(intent)
.map_err(|error| JobError::InvalidInput(format!("serialize Job intent: {error}")))?;
Ok(sha256(&encoded))
}
pub fn worker_input(request: &JobRequest, attempt_id: &str) -> Result<String> {
worker_input_with_label(request, attempt_id, "Job")
}
/// Adapter-specific capability label; only the host, never the model, selects it.
/// Keeps an existing host's prompt envelope byte-for-byte compatible.
pub fn worker_input_with_label(
request: &JobRequest,
attempt_id: &str,
label: &str,
) -> Result<String> {
let input = serde_json::to_string(&request.input)
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
Ok(format!(
"{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.",
request.instruction,
request.job_id,
attempt_id,
request.input_revision,
request.input_ref,
input
))
}
pub fn attempt_id(job_id: &str, attempt: u8) -> String {
format!("{job_id}:attempt:{attempt}")
}
/// Bound the encoded JSON before hashing. Returns (digest, exact JSON).
pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> {
let encoded = serde_json::to_string(result)
.map_err(|error| JobError::InvalidInput(format!("serialize Job result: {error}")))?;
if encoded.len() > max_bytes as usize {
return Err(JobError::InvalidInput(format!(
"Job result exceeds {max_bytes} bytes"
)));
}
Ok((sha256(encoded.as_bytes()), encoded))
}
pub fn bounded_failure_detail(detail: &str) -> String {
if detail.len() <= MAX_JOB_FAILURE_BYTES {
return detail.to_string();
}
let mut end = MAX_JOB_FAILURE_BYTES;
while !detail.is_char_boundary(end) {
end -= 1;
}
detail[..end].to_string()
}
pub fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> {
validate_bounded_text(field, value, max_bytes)?;
if value.chars().any(char::is_control) {
return Err(JobError::InvalidInput(format!(
"Job {field} must not contain control characters"
)));
}
Ok(())
}
pub fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> {
if value.is_empty() || value.trim() != value {
return Err(JobError::InvalidInput(format!(
"Job {field} must be non-empty and trimmed"
)));
}
if value.len() > max_bytes {
return Err(JobError::InvalidInput(format!(
"Job {field} exceeds {max_bytes} bytes"
)));
}
Ok(())
}
pub fn sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(7 + digest.len() * 2);
output.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
}
+308
View File
@@ -0,0 +1,308 @@
use job::*;
use serde_json::json;
fn request() -> JobRequest {
JobRequest {
job_id: "check:r7".into(),
purpose: "snapshot_check".into(),
input_revision: "7".into(),
input_ref: "snapshot://7".into(),
input: json!({"title": "Check me"}),
instruction: "Check this immutable snapshot.".into(),
profile: "project:custom-check".into(),
serialization_key: None,
limits: JobLimits::default(),
}
}
#[test]
fn shared_lifecycle_enums_round_trip_all_host_states() {
for (state, name) in [
(JobState::Pending, "pending"),
(JobState::Completed, "completed"),
(JobState::Failed, "failed"),
(JobState::Unknown, "unknown"),
(JobState::Cancelled, "cancelled"),
] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
assert_eq!(
serde_json::from_value::<JobState>(json!(name)).unwrap(),
state
);
}
for (state, name) in [
(JobAttemptState::Reserved, "reserved"),
(JobAttemptState::Dispatching, "dispatching"),
(JobAttemptState::Dispatched, "dispatched"),
(JobAttemptState::Completed, "completed"),
(JobAttemptState::Failed, "failed"),
(JobAttemptState::Unknown, "unknown"),
(JobAttemptState::Cancelled, "cancelled"),
] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
assert_eq!(
serde_json::from_value::<JobAttemptState>(json!(name)).unwrap(),
state
);
}
assert!(serde_json::from_value::<JobState>(json!("running")).is_err());
assert!(serde_json::from_value::<JobAttemptState>(json!("idle")).is_err());
}
#[test]
fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
let mut outcome = JobOutcome {
job_id: "job-1".into(),
input_revision: "7".into(),
attempt_id: "job-1:attempt:2".into(),
attempt: 2,
state: JobState::Pending,
attempt_state: JobAttemptState::Dispatching,
result: None,
failure_category: None,
failure_detail: None,
};
let encoded = serde_json::to_value(&outcome).unwrap();
assert_eq!(
encoded,
json!({
"job_id": "job-1", "input_revision": "7", "attempt_id": "job-1:attempt:2",
"attempt": 2, "state": "pending", "attempt_state": "dispatching",
"result": null, "failure_category": null, "failure_detail": null
})
);
assert_eq!(
serde_json::from_value::<JobOutcome>(encoded.clone()).unwrap(),
outcome
);
let mut with_host_identity = encoded;
with_host_identity["workspace_id"] = json!("workspace-a");
assert!(serde_json::from_value::<JobOutcome>(with_host_identity).is_err());
for (state, attempt_state, result, category, detail) in [
(
JobState::Completed,
JobAttemptState::Completed,
Some(json!({"ok": true})),
None,
None,
),
(
JobState::Failed,
JobAttemptState::Failed,
None,
Some("execution"),
Some("known failure"),
),
(
JobState::Unknown,
JobAttemptState::Unknown,
None,
Some("interrupted"),
Some("requires reconciliation"),
),
(
JobState::Cancelled,
JobAttemptState::Cancelled,
None,
Some("cancelled"),
Some("caller cancelled"),
),
] {
outcome.state = state;
outcome.attempt_state = attempt_state;
outcome.result = result;
outcome.failure_category = category.map(str::to_owned);
outcome.failure_detail = detail.map(str::to_owned);
assert_eq!(
serde_json::from_str::<JobOutcome>(&serde_json::to_string(&outcome).unwrap()).unwrap(),
outcome
);
}
}
#[test]
fn neutral_request_and_result_have_strict_round_trip_contracts() {
let request = request();
let encoded = serde_json::to_value(&request).unwrap();
assert_eq!(encoded.as_object().unwrap().len(), 8);
assert!(encoded.get("serialization_key").is_none());
assert_eq!(
serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(),
request
);
let mut with_grant = encoded.clone();
with_grant["grants"] = json!({});
assert!(serde_json::from_value::<JobRequest>(with_grant).is_err());
let mut with_provenance = encoded;
with_provenance["source_worker"] = json!({});
assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err());
let submission = JobResultSubmission {
job_id: request.job_id,
attempt_id: "check:r7:attempt:1".into(),
input_revision: request.input_revision,
result: json!({"ok": true}),
};
let mut encoded = serde_json::to_value(&submission).unwrap();
assert_eq!(
serde_json::from_value::<JobResultSubmission>(encoded.clone()).unwrap(),
submission
);
encoded["authority"] = json!("profile");
assert!(serde_json::from_value::<JobResultSubmission>(encoded).is_err());
assert_eq!(
serde_json::from_str::<JobLimits>("{}").unwrap(),
JobLimits::default()
);
assert!(serde_json::from_str::<JobLimits>(r#"{"turns":999}"#).is_err());
}
#[test]
fn fingerprint_covers_every_immutable_field_and_preserves_replay() {
let request = request();
let digest = request.fingerprint().unwrap();
let decoded: JobRequest =
serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
assert_eq!(digest, decoded.fingerprint().unwrap());
assert_eq!(digest, fingerprint(&request).unwrap());
for mutate in [
|r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.input = json!({"title": "changed"}),
|r: &mut JobRequest| r.instruction.push('x'),
|r: &mut JobRequest| r.profile = "builtin:backend-job".into(),
|r: &mut JobRequest| r.serialization_key = Some("resource:7".into()),
|r: &mut JobRequest| r.limits.max_attempts = 3,
] {
let mut changed = request.clone();
mutate(&mut changed);
assert_ne!(digest, changed.fingerprint().unwrap());
}
}
#[test]
fn registry_profiles_are_not_authority_or_a_fixed_allowlist() {
for profile in [
"default",
"custom",
"project:custom",
"user:custom",
"builtin:future-job",
] {
profile_selector(profile).unwrap();
}
for profile in [
"",
" inherit",
"inherit",
"path:custom",
"./a.toml",
"/tmp/a",
"project:../a",
"project:a.toml",
"builtin:",
"unknown:a",
"project:a:b",
"C:\\custom",
"{\"worker\":{}}",
] {
assert!(profile_selector(profile).is_err(), "accepted {profile}");
}
}
#[test]
fn validation_enforces_utf8_bytes_and_metadata_bounds() {
let mut r = request();
r.job_id = "x".repeat(256);
r.input_revision = "x".repeat(256);
r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES);
r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES);
r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES);
r.serialization_key = Some("x".repeat(MAX_JOB_REFERENCE_BYTES));
r.input = json!("x".repeat(MAX_JOB_INPUT_BYTES - 2));
r.validate().unwrap();
for mutate in [
|r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.instruction.push('x'),
|r: &mut JobRequest| r.serialization_key.as_mut().unwrap().push('x'),
|r: &mut JobRequest| r.input = json!("x".repeat(MAX_JOB_INPUT_BYTES - 1)),
] {
let mut changed = r.clone();
mutate(&mut changed);
assert!(changed.validate().is_err());
assert!(changed.fingerprint().is_err());
}
assert!(validate_identifier("id", "a\nb", 256).is_err());
assert!(validate_bounded_text("text", " untrimmed", 256).is_err());
assert!(validate_bounded_text("text", "", 256).is_err());
assert!(validate_bounded_text("text", "界", 2).is_err());
validate_bounded_text("text", "a\nb", 256).unwrap();
}
#[test]
fn all_limit_dimensions_reject_zero_and_above_absolute_bounds() {
let maximum = JobLimits {
max_concurrent_jobs: ABSOLUTE_MAX_CONCURRENT_JOBS,
timeout_seconds: MAX_JOB_TIMEOUT_SECONDS,
max_result_bytes: ABSOLUTE_MAX_RESULT_BYTES,
max_attempts: ABSOLUTE_MAX_ATTEMPTS,
};
maximum.validate().unwrap();
for mutate in [
|l: &mut JobLimits| l.max_concurrent_jobs = 0,
|l: &mut JobLimits| l.max_concurrent_jobs += 1,
|l: &mut JobLimits| l.timeout_seconds = 0,
|l: &mut JobLimits| l.timeout_seconds += 1,
|l: &mut JobLimits| l.max_result_bytes = 0,
|l: &mut JobLimits| l.max_result_bytes += 1,
|l: &mut JobLimits| l.max_attempts = 0,
|l: &mut JobLimits| l.max_attempts += 1,
] {
let mut changed = maximum.clone();
mutate(&mut changed);
assert!(changed.validate().is_err());
}
}
#[test]
fn result_digest_and_failure_detail_bound_exact_utf8_encoding() {
let result = json!({"ok": true});
let (digest, encoded) = result_digest(&result, 11).unwrap();
assert_eq!(encoded, r#"{"ok":true}"#);
assert_eq!(digest, sha256(encoded.as_bytes()));
assert!(result_digest(&result, 10).is_err());
let unicode = json!("界");
assert!(result_digest(&unicode, 4).is_err());
result_digest(&unicode, 5).unwrap();
let detail = "界".repeat(MAX_JOB_FAILURE_BYTES);
let bounded = bounded_failure_detail(&detail);
assert_eq!(bounded.len(), MAX_JOB_FAILURE_BYTES - 1);
assert!(detail.starts_with(&bounded));
assert_eq!(bounded_failure_detail("known failure"), "known failure");
}
#[test]
fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() {
let request = request();
let attempt = attempt_id(&request.job_id, 2);
assert_eq!(attempt, "check:r7:attempt:2");
let input = request.worker_input(&attempt).unwrap();
assert!(input.starts_with(&request.instruction));
for field in [
"Job envelope (immutable):",
"job_id: check:r7",
"attempt_id: check:r7:attempt:2",
"input_revision: 7",
"input_ref: snapshot://7",
r#"input_json: {"title":"Check me"}"#,
"structured Job result capability",
"Final prose and Worker Idle/Stopped state are not result authority.",
] {
assert!(input.contains(field), "missing {field}");
}
}