@feltdb/core 0.4.12 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/dist/cli/commands.js +5 -2
  2. package/dist/cli/index.js +1 -1
  3. package/dist/create/cli.js +16 -1
  4. package/dist/create/create.js +30 -16
  5. package/dist/create/docker-compose-generator.js +48 -9
  6. package/dist/create/package-versions.js +1 -1
  7. package/dist/create/server-source/Cargo.lock +2238 -0
  8. package/dist/create/server-source/Cargo.toml +7 -0
  9. package/dist/create/server-source/crates/feltdb/Cargo.lock +175 -0
  10. package/dist/create/server-source/crates/feltdb/Cargo.toml +79 -0
  11. package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +370 -0
  12. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +589 -0
  13. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +259 -0
  14. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +446 -0
  15. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +272 -0
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +378 -0
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +414 -0
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +486 -0
  19. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +273 -0
  20. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +587 -0
  21. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +513 -0
  22. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +395 -0
  23. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +469 -0
  24. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +409 -0
  25. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +627 -0
  26. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +383 -0
  27. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +298 -0
  28. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +1698 -0
  29. package/dist/create/server-source/crates/feltdb/src/acquisition.rs +286 -0
  30. package/dist/create/server-source/crates/feltdb/src/admission.rs +192 -0
  31. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +477 -0
  32. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +566 -0
  33. package/dist/create/server-source/crates/feltdb/src/analytics.rs +475 -0
  34. package/dist/create/server-source/crates/feltdb/src/application.rs +2244 -0
  35. package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +1070 -0
  36. package/dist/create/server-source/crates/feltdb/src/authorization.rs +1030 -0
  37. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +266 -0
  38. package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +8 -0
  39. package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +418 -0
  40. package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +482 -0
  41. package/dist/create/server-source/crates/feltdb/src/capability.rs +903 -0
  42. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +261 -0
  43. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +78 -0
  44. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +2214 -0
  45. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +194 -0
  46. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +427 -0
  47. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +453 -0
  48. package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +465 -0
  49. package/dist/create/server-source/crates/feltdb/src/convergence.rs +618 -0
  50. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +418 -0
  51. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +380 -0
  52. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +362 -0
  53. package/dist/create/server-source/crates/feltdb/src/cron.rs +294 -0
  54. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +474 -0
  55. package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +1003 -0
  56. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +536 -0
  57. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +364 -0
  58. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +235 -0
  59. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +207 -0
  60. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +316 -0
  61. package/dist/create/server-source/crates/feltdb/src/execution.rs +426 -0
  62. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +219 -0
  63. package/dist/create/server-source/crates/feltdb/src/indexing.rs +779 -0
  64. package/dist/create/server-source/crates/feltdb/src/lib.rs +2838 -0
  65. package/dist/create/server-source/crates/feltdb/src/materialization.rs +184 -0
  66. package/dist/create/server-source/crates/feltdb/src/metrics.rs +267 -0
  67. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +311 -0
  68. package/dist/create/server-source/crates/feltdb/src/observability.rs +366 -0
  69. package/dist/create/server-source/crates/feltdb/src/operation.rs +251 -0
  70. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +438 -0
  71. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +344 -0
  72. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +477 -0
  73. package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +166 -0
  74. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +261 -0
  75. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +560 -0
  76. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +3226 -0
  77. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +201 -0
  78. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +263 -0
  79. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +484 -0
  80. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +216 -0
  81. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +281 -0
  82. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +323 -0
  83. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +573 -0
  84. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +404 -0
  85. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +418 -0
  86. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +381 -0
  87. package/dist/create/server-source/crates/feltdb/src/phase_1c_three_node.rs +523 -0
  88. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +334 -0
  89. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +306 -0
  90. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +355 -0
  91. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +395 -0
  92. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +346 -0
  93. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +430 -0
  94. package/dist/create/server-source/crates/feltdb/src/production_api.rs +400 -0
  95. package/dist/create/server-source/crates/feltdb/src/provenance.rs +207 -0
  96. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +217 -0
  97. package/dist/create/server-source/crates/feltdb/src/references.rs +404 -0
  98. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +401 -0
  99. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +160 -0
  100. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +132 -0
  101. package/dist/create/server-source/crates/feltdb/src/routing.rs +409 -0
  102. package/dist/create/server-source/crates/feltdb/src/sharding.rs +523 -0
  103. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +3118 -0
  104. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +291 -0
  105. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +376 -0
  106. package/dist/create/server-source/crates/feltdb/src/storage.rs +267 -0
  107. package/dist/create/server-source/crates/feltdb/src/submission.rs +477 -0
  108. package/dist/create/server-source/crates/feltdb/src/sync.rs +483 -0
  109. package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +822 -0
  110. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +366 -0
  111. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +473 -0
  112. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +949 -0
  113. package/dist/create/server-source/crates/feltdb/src/transactions.rs +646 -0
  114. package/dist/create/server-source/crates/feltdb/src/trigger.rs +390 -0
  115. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +928 -0
  116. package/dist/create/server-source/crates/feltdb/src/workflow.rs +713 -0
  117. package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +510 -0
  118. package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +354 -0
  119. package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +594 -0
  120. package/dist/create/server-source/crates/feltdb/src/workload.rs +1310 -0
  121. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +23 -0
  122. package/dist/create/server-source/crates/feltdb-server/README.md +79 -0
  123. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +73 -0
  124. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +425 -0
  125. package/dist/create/server-source/crates/feltdb-server/src/artifacts.rs +449 -0
  126. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +59 -0
  127. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +308 -0
  128. package/dist/create/server-source/crates/feltdb-server/src/authorization.rs +228 -0
  129. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +416 -0
  130. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +218 -0
  131. package/dist/create/server-source/crates/feltdb-server/src/certification.rs +223 -0
  132. package/dist/create/server-source/crates/feltdb-server/src/clock.rs +132 -0
  133. package/dist/create/server-source/crates/feltdb-server/src/cluster.rs +165 -0
  134. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +1044 -0
  135. package/dist/create/server-source/crates/feltdb-server/src/content.rs +111 -0
  136. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +250 -0
  137. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +78 -0
  138. package/dist/create/server-source/crates/feltdb-server/src/key_provider.rs +247 -0
  139. package/dist/create/server-source/crates/feltdb-server/src/leases.rs +123 -0
  140. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +25 -0
  141. package/dist/create/server-source/crates/feltdb-server/src/main.rs +8715 -0
  142. package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +97 -0
  143. package/dist/create/server-source/crates/feltdb-server/src/portable_bundle.rs +350 -0
  144. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +510 -0
  145. package/dist/create/server-source/crates/feltdb-server/src/providers.rs +269 -0
  146. package/dist/create/server-source/crates/feltdb-server/src/releases.rs +2563 -0
  147. package/dist/create/server-source/crates/feltdb-server/src/sessions.rs +156 -0
  148. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +1097 -0
  149. package/dist/create/server-source/crates/feltdb-server/src/versions.rs +264 -0
  150. package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +31 -0
  151. package/dist/create/server-source/crates/feltdb-wasm/src/lib.rs +1086 -0
  152. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  153. package/dist/flowspec.d.ts +2 -0
  154. package/dist/flowspec.d.ts.map +1 -1
  155. package/dist/flowspec.js +28 -4
  156. package/dist/state-contract.d.ts +7 -1
  157. package/dist/state-contract.d.ts.map +1 -1
  158. package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
  159. package/dist/studio/components/index.js +1 -1
  160. package/dist/studio/{components-Q0Nx6gLE.js → components-bHTARBin.js} +156 -46
  161. package/dist/studio/index.js +31 -28
  162. package/dist/studio-app/assets/{index-BHOmLZF-.js → index-BqKquLuU.js} +8 -8
  163. package/dist/studio-app/index.html +1 -1
  164. package/package.json +1 -1
@@ -0,0 +1,1310 @@
1
+ //! Durable workload authority. Workers execute; this state machine decides.
2
+ use crate::authorization::{SignedGrant, Subject};
3
+ use serde::{Deserialize, Serialize};
4
+ use serde_json::Value;
5
+ use sha2::{Digest, Sha256};
6
+ use std::collections::{BTreeMap, BTreeSet};
7
+ use std::fs;
8
+ use std::path::{Path, PathBuf};
9
+ pub const WORKLOAD_CONTRACT_VERSION: u32 = 1;
10
+
11
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
12
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
13
+ pub enum WorkloadState {
14
+ Created,
15
+ Ready,
16
+ Claimed,
17
+ Running,
18
+ Checkpointed,
19
+ Recovering,
20
+ CancellationRequested,
21
+ Succeeded,
22
+ Failed,
23
+ Cancelled,
24
+ TimedOut,
25
+ DeadLetter,
26
+ }
27
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28
+ #[serde(rename_all = "snake_case")]
29
+ pub enum BackoffPolicy {
30
+ Fixed,
31
+ Exponential,
32
+ }
33
+ #[derive(Debug, Clone, Serialize, Deserialize)]
34
+ pub struct WorkloadRetryPolicy {
35
+ pub max_attempts: u32,
36
+ pub initial_delay_ms: u64,
37
+ pub max_delay_ms: u64,
38
+ pub backoff: BackoffPolicy,
39
+ #[serde(default)]
40
+ pub retry_on: BTreeSet<String>,
41
+ }
42
+ impl Default for WorkloadRetryPolicy {
43
+ fn default() -> Self {
44
+ Self {
45
+ max_attempts: 3,
46
+ initial_delay_ms: 1000,
47
+ max_delay_ms: 300_000,
48
+ backoff: BackoffPolicy::Exponential,
49
+ retry_on: [
50
+ "timeout".into(),
51
+ "worker_failure".into(),
52
+ "transient_connection".into(),
53
+ ]
54
+ .into(),
55
+ }
56
+ }
57
+ }
58
+ #[derive(Debug, Clone, Serialize, Deserialize, Default)]
59
+ pub struct WorkloadTimeouts {
60
+ pub queue_ms: Option<u64>,
61
+ pub claim_ms: Option<u64>,
62
+ pub execution_ms: Option<u64>,
63
+ pub heartbeat_ms: Option<u64>,
64
+ pub overall_ms: Option<u64>,
65
+ }
66
+ #[derive(Debug, Clone, Serialize, Deserialize)]
67
+ pub struct WorkloadDependency {
68
+ pub workload_id: String,
69
+ #[serde(default = "succeeded_state")]
70
+ pub required_state: WorkloadState,
71
+ }
72
+ fn succeeded_state() -> WorkloadState {
73
+ WorkloadState::Succeeded
74
+ }
75
+ #[derive(Debug, Clone, Serialize, Deserialize)]
76
+ pub struct WorkloadClaim {
77
+ pub claim_id: String,
78
+ pub workload_id: String,
79
+ pub worker_id: String,
80
+ pub lease_id: String,
81
+ pub fencing_token: u64,
82
+ pub capability_snapshot: BTreeSet<String>,
83
+ pub claimed_at: i64,
84
+ pub expires_at: i64,
85
+ pub last_heartbeat: i64,
86
+ }
87
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
88
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
89
+ pub enum AttemptStatus {
90
+ Claimed,
91
+ Running,
92
+ Checkpointed,
93
+ Succeeded,
94
+ Failed,
95
+ TimedOut,
96
+ Cancelled,
97
+ WorkerLost,
98
+ }
99
+ #[derive(Debug, Clone, Serialize, Deserialize)]
100
+ pub struct WorkloadAttempt {
101
+ pub attempt_id: String,
102
+ pub workload_id: String,
103
+ pub worker_id: String,
104
+ pub claim_id: String,
105
+ pub fencing_token: u64,
106
+ pub started_at: Option<i64>,
107
+ pub ended_at: Option<i64>,
108
+ pub input_hash: String,
109
+ pub result_hash: Option<String>,
110
+ pub error: Option<String>,
111
+ #[serde(default)]
112
+ pub resource_usage: BTreeMap<String, u64>,
113
+ pub status: AttemptStatus,
114
+ }
115
+ #[derive(Debug, Clone, Serialize, Deserialize)]
116
+ pub struct WorkloadCheckpoint {
117
+ pub checkpoint_id: String,
118
+ pub workload_id: String,
119
+ pub attempt_id: String,
120
+ pub sequence: u64,
121
+ pub state: Value,
122
+ pub state_hash: String,
123
+ pub created_at: i64,
124
+ pub produced_by: String,
125
+ }
126
+ #[derive(Debug, Clone, Serialize, Deserialize)]
127
+ pub struct WorkloadResult {
128
+ pub result_id: String,
129
+ pub workload_id: String,
130
+ pub attempt_id: String,
131
+ pub status: String,
132
+ pub output: Value,
133
+ pub output_hash: String,
134
+ #[serde(default)]
135
+ pub artifacts: Vec<String>,
136
+ #[serde(default)]
137
+ pub provenance: BTreeMap<String, String>,
138
+ pub produced_by: String,
139
+ pub created_at: i64,
140
+ pub schema_version: u64,
141
+ }
142
+ #[derive(Debug, Clone, Serialize, Deserialize)]
143
+ pub struct WorkloadExecutionContext {
144
+ pub workload_id: String,
145
+ pub attempt_id: String,
146
+ pub worker_id: String,
147
+ pub capabilities: BTreeSet<String>,
148
+ pub connection_scopes: BTreeSet<String>,
149
+ pub secret_references: Vec<String>,
150
+ pub expires_at: i64,
151
+ pub fencing_token: u64,
152
+ }
153
+ #[derive(Debug, Clone, Serialize, Deserialize)]
154
+ pub struct WorkloadEvent {
155
+ pub sequence: u64,
156
+ pub event_type: String,
157
+ pub from: Option<WorkloadState>,
158
+ pub to: WorkloadState,
159
+ pub actor: String,
160
+ pub at: i64,
161
+ #[serde(default)]
162
+ pub evidence: BTreeMap<String, String>,
163
+ }
164
+ #[derive(Debug, Clone, Serialize, Deserialize)]
165
+ pub struct Workload {
166
+ pub contract_version: u32,
167
+ pub workload_id: String,
168
+ pub tenant_id: String,
169
+ pub application_id: String,
170
+ pub environment: String,
171
+ pub definition_id: String,
172
+ pub definition_revision: String,
173
+ pub trigger: String,
174
+ pub input: Value,
175
+ pub input_hash: String,
176
+ pub requested_by: Subject,
177
+ pub authorization_snapshot: SignedGrant,
178
+ pub capability_snapshot: BTreeSet<String>,
179
+ pub connection_scopes: BTreeSet<String>,
180
+ pub priority: i32,
181
+ pub idempotency_key: String,
182
+ pub created_at: i64,
183
+ pub updated_at: i64,
184
+ pub state: WorkloadState,
185
+ pub execution_history: Vec<WorkloadEvent>,
186
+ pub attempts: Vec<WorkloadAttempt>,
187
+ pub checkpoints: Vec<WorkloadCheckpoint>,
188
+ pub result: Option<WorkloadResult>,
189
+ pub retry_policy: WorkloadRetryPolicy,
190
+ pub timeouts: WorkloadTimeouts,
191
+ pub attempt_count: u32,
192
+ pub retry_count: u32,
193
+ pub next_attempt_at: Option<i64>,
194
+ pub last_failure: Option<String>,
195
+ pub current_claim: Option<WorkloadClaim>,
196
+ pub fencing_token: u64,
197
+ #[serde(default)]
198
+ pub dependencies: Vec<WorkloadDependency>,
199
+ }
200
+ #[derive(Debug, Clone, Serialize, Deserialize)]
201
+ pub struct CreateWorkload {
202
+ pub tenant_id: String,
203
+ pub application_id: String,
204
+ pub environment: String,
205
+ pub definition_id: String,
206
+ pub definition_revision: String,
207
+ pub trigger: String,
208
+ pub input: Value,
209
+ pub requested_by: Subject,
210
+ pub authorization_snapshot: SignedGrant,
211
+ pub capability_snapshot: BTreeSet<String>,
212
+ #[serde(default)]
213
+ pub connection_scopes: BTreeSet<String>,
214
+ #[serde(default)]
215
+ pub priority: i32,
216
+ pub idempotency_key: String,
217
+ #[serde(default)]
218
+ pub retry_policy: WorkloadRetryPolicy,
219
+ #[serde(default)]
220
+ pub timeouts: WorkloadTimeouts,
221
+ #[serde(default)]
222
+ pub dependencies: Vec<WorkloadDependency>,
223
+ pub created_at: i64,
224
+ }
225
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
226
+ pub struct WorkloadError {
227
+ pub code: String,
228
+ pub message: String,
229
+ }
230
+ impl WorkloadError {
231
+ fn new(code: &str, message: impl Into<String>) -> Self {
232
+ Self {
233
+ code: code.into(),
234
+ message: message.into(),
235
+ }
236
+ }
237
+ }
238
+ impl std::fmt::Display for WorkloadError {
239
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240
+ write!(f, "{}: {}", self.code, self.message)
241
+ }
242
+ }
243
+ impl std::error::Error for WorkloadError {}
244
+
245
+ #[derive(Debug, Clone, Serialize, Deserialize)]
246
+ pub struct WorkloadStore {
247
+ #[serde(skip)]
248
+ path: PathBuf,
249
+ #[serde(default)]
250
+ pub workloads: BTreeMap<String, Workload>,
251
+ #[serde(default)]
252
+ idempotency: BTreeMap<String, String>,
253
+ #[serde(default)]
254
+ result_ids: BTreeMap<String, String>,
255
+ #[serde(default)]
256
+ event_sequence: u64,
257
+ }
258
+ impl WorkloadStore {
259
+ pub fn load(path: impl AsRef<Path>) -> Result<Self, WorkloadError> {
260
+ let path = path.as_ref().to_path_buf();
261
+ if !path.exists() {
262
+ return Ok(Self {
263
+ path,
264
+ workloads: BTreeMap::new(),
265
+ idempotency: BTreeMap::new(),
266
+ result_ids: BTreeMap::new(),
267
+ event_sequence: 0,
268
+ });
269
+ }
270
+ let mut value: Self = serde_json::from_slice(&fs::read(&path).map_err(io)?).map_err(io)?;
271
+ value.path = path;
272
+ Ok(value)
273
+ }
274
+ fn persist(&self) -> Result<(), WorkloadError> {
275
+ if let Some(parent) = self.path.parent() {
276
+ fs::create_dir_all(parent).map_err(io)?;
277
+ }
278
+ let temp = self.path.with_extension("tmp");
279
+ fs::write(&temp, serde_json::to_vec_pretty(self).map_err(io)?).map_err(io)?;
280
+ fs::rename(temp, &self.path).map_err(io)
281
+ }
282
+ pub fn create(&mut self, input: CreateWorkload) -> Result<Workload, WorkloadError> {
283
+ ensure_safe(&input.input)?;
284
+ if input.idempotency_key.trim().is_empty() {
285
+ return Err(WorkloadError::new(
286
+ "WORKLOAD_INVALID",
287
+ "idempotency key is required",
288
+ ));
289
+ }
290
+ let idempotency = format!(
291
+ "{}:{}:{}",
292
+ input.tenant_id, input.application_id, input.idempotency_key
293
+ );
294
+ if let Some(id) = self.idempotency.get(&idempotency) {
295
+ return Ok(self.workloads[id].clone());
296
+ }
297
+ if input.authorization_snapshot.grant.subject != input.requested_by
298
+ || input.authorization_snapshot.grant.tenant_id != input.tenant_id
299
+ || input.authorization_snapshot.grant.application_id != input.application_id
300
+ {
301
+ return Err(WorkloadError::new(
302
+ "WORKLOAD_UNAUTHORIZED",
303
+ "authorization snapshot does not bind the requester",
304
+ ));
305
+ }
306
+ let seed = format!(
307
+ "{idempotency}:{}:{}",
308
+ input.definition_revision,
309
+ hash(&input.input)
310
+ );
311
+ let id = format!("workload_{}", &hex(&Sha256::digest(seed))[..24]);
312
+ let input_hash = hash(&input.input);
313
+ let mut workload = Workload {
314
+ contract_version: 1,
315
+ workload_id: id.clone(),
316
+ tenant_id: input.tenant_id,
317
+ application_id: input.application_id,
318
+ environment: input.environment,
319
+ definition_id: input.definition_id,
320
+ definition_revision: input.definition_revision,
321
+ trigger: input.trigger,
322
+ input: input.input,
323
+ input_hash,
324
+ requested_by: input.requested_by,
325
+ authorization_snapshot: input.authorization_snapshot,
326
+ capability_snapshot: input.capability_snapshot,
327
+ connection_scopes: input.connection_scopes,
328
+ priority: input.priority,
329
+ idempotency_key: input.idempotency_key,
330
+ created_at: input.created_at,
331
+ updated_at: input.created_at,
332
+ state: WorkloadState::Created,
333
+ execution_history: vec![],
334
+ attempts: vec![],
335
+ checkpoints: vec![],
336
+ result: None,
337
+ retry_policy: input.retry_policy,
338
+ timeouts: input.timeouts,
339
+ attempt_count: 0,
340
+ retry_count: 0,
341
+ next_attempt_at: None,
342
+ last_failure: None,
343
+ current_claim: None,
344
+ fencing_token: 0,
345
+ dependencies: input.dependencies,
346
+ };
347
+ self.transition(
348
+ &mut workload,
349
+ WorkloadState::Ready,
350
+ "rust",
351
+ input.created_at,
352
+ BTreeMap::new(),
353
+ )?;
354
+ self.idempotency.insert(idempotency, id.clone());
355
+ self.workloads.insert(id, workload.clone());
356
+ self.persist()?;
357
+ Ok(workload)
358
+ }
359
+ pub fn get(&self, id: &str) -> Result<&Workload, WorkloadError> {
360
+ self.workloads
361
+ .get(id)
362
+ .ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))
363
+ }
364
+ pub fn list(&self, tenant: &str, application: &str) -> Vec<Workload> {
365
+ let mut values = self
366
+ .workloads
367
+ .values()
368
+ .filter(|v| v.tenant_id == tenant && v.application_id == application)
369
+ .cloned()
370
+ .collect::<Vec<_>>();
371
+ values.sort_by_key(|v| (std::cmp::Reverse(v.priority), v.created_at));
372
+ values
373
+ }
374
+ fn edit<R>(
375
+ &mut self,
376
+ id: &str,
377
+ action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
378
+ ) -> Result<R, WorkloadError> {
379
+ let mut workload = self
380
+ .workloads
381
+ .remove(id)
382
+ .ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
383
+ let result = action(self, &mut workload);
384
+ self.workloads.insert(id.into(), workload);
385
+ if result.is_ok() {
386
+ self.persist()?;
387
+ }
388
+ result
389
+ }
390
+ fn transition(
391
+ &mut self,
392
+ w: &mut Workload,
393
+ to: WorkloadState,
394
+ actor: &str,
395
+ at: i64,
396
+ evidence: BTreeMap<String, String>,
397
+ ) -> Result<(), WorkloadError> {
398
+ if !legal(w.state, to) {
399
+ return Err(WorkloadError::new(
400
+ "WORKLOAD_TRANSITION_DENIED",
401
+ format!("{:?} cannot transition to {:?}", w.state, to),
402
+ ));
403
+ }
404
+ let from = w.state;
405
+ w.state = to;
406
+ w.updated_at = at;
407
+ self.event_sequence += 1;
408
+ w.execution_history.push(WorkloadEvent {
409
+ sequence: self.event_sequence,
410
+ event_type: event_name(to).into(),
411
+ from: Some(from),
412
+ to,
413
+ actor: actor.into(),
414
+ at,
415
+ evidence,
416
+ });
417
+ Ok(())
418
+ }
419
+ pub fn claim(
420
+ &mut self,
421
+ id: &str,
422
+ worker: &str,
423
+ capabilities: &BTreeSet<String>,
424
+ now: i64,
425
+ lease_ms: u64,
426
+ ) -> Result<WorkloadClaim, WorkloadError> {
427
+ self.edit(id, |store, w| {
428
+ if w.state != WorkloadState::Ready || w.next_attempt_at.is_some_and(|v| v > now) {
429
+ return Err(WorkloadError::new(
430
+ "WORKLOAD_NOT_READY",
431
+ "workload is not eligible",
432
+ ));
433
+ }
434
+ if !w.capability_snapshot.is_subset(capabilities) {
435
+ return Err(WorkloadError::new(
436
+ "WORKLOAD_CAPABILITY_DENIED",
437
+ "worker lacks required capability snapshot",
438
+ ));
439
+ }
440
+ w.fencing_token += 1;
441
+ w.attempt_count += 1;
442
+ let seed = format!("{}:{}:{}", w.workload_id, w.fencing_token, worker);
443
+ let claim = WorkloadClaim {
444
+ claim_id: format!("claim_{}", &hex(&Sha256::digest(seed.as_bytes()))[..20]),
445
+ workload_id: w.workload_id.clone(),
446
+ worker_id: worker.into(),
447
+ lease_id: format!("lease_{}", w.fencing_token),
448
+ fencing_token: w.fencing_token,
449
+ capability_snapshot: w.capability_snapshot.clone(),
450
+ claimed_at: now,
451
+ expires_at: now + (lease_ms / 1000) as i64,
452
+ last_heartbeat: now,
453
+ };
454
+ w.attempts.push(WorkloadAttempt {
455
+ attempt_id: format!("attempt_{}_{}", w.workload_id, w.attempt_count),
456
+ workload_id: w.workload_id.clone(),
457
+ worker_id: worker.into(),
458
+ claim_id: claim.claim_id.clone(),
459
+ fencing_token: claim.fencing_token,
460
+ started_at: None,
461
+ ended_at: None,
462
+ input_hash: w.input_hash.clone(),
463
+ result_hash: None,
464
+ error: None,
465
+ resource_usage: BTreeMap::new(),
466
+ status: AttemptStatus::Claimed,
467
+ });
468
+ w.current_claim = Some(claim.clone());
469
+ store.transition(
470
+ w,
471
+ WorkloadState::Claimed,
472
+ worker,
473
+ now,
474
+ [("fencing_token".into(), claim.fencing_token.to_string())].into(),
475
+ )?;
476
+ Ok(claim)
477
+ })
478
+ }
479
+ fn fence(
480
+ w: &Workload,
481
+ claim: &str,
482
+ worker: &str,
483
+ token: u64,
484
+ now: i64,
485
+ ) -> Result<(), WorkloadError> {
486
+ let current = w
487
+ .current_claim
488
+ .as_ref()
489
+ .ok_or_else(|| WorkloadError::new("WORKLOAD_CLAIM_INVALID", "no active claim"))?;
490
+ if current.claim_id != claim
491
+ || current.worker_id != worker
492
+ || current.fencing_token != token
493
+ {
494
+ return Err(WorkloadError::new(
495
+ "WORKLOAD_STALE_FENCE",
496
+ "claim or fencing token is stale",
497
+ ));
498
+ }
499
+ if current.expires_at <= now {
500
+ return Err(WorkloadError::new(
501
+ "WORKLOAD_LEASE_EXPIRED",
502
+ "claim lease expired",
503
+ ));
504
+ }
505
+ Ok(())
506
+ }
507
+ pub fn start(
508
+ &mut self,
509
+ id: &str,
510
+ claim: &str,
511
+ worker: &str,
512
+ token: u64,
513
+ now: i64,
514
+ ) -> Result<(), WorkloadError> {
515
+ self.edit(id, |store, w| {
516
+ Self::fence(w, claim, worker, token, now)?;
517
+ let attempt = w.attempts.last_mut().unwrap();
518
+ attempt.status = AttemptStatus::Running;
519
+ attempt.started_at = Some(now);
520
+ store.transition(w, WorkloadState::Running, worker, now, BTreeMap::new())
521
+ })
522
+ }
523
+ pub fn heartbeat(
524
+ &mut self,
525
+ id: &str,
526
+ claim: &str,
527
+ worker: &str,
528
+ token: u64,
529
+ now: i64,
530
+ extend_ms: u64,
531
+ ) -> Result<WorkloadClaim, WorkloadError> {
532
+ self.edit(id, |_, w| {
533
+ Self::fence(w, claim, worker, token, now)?;
534
+ let current = w.current_claim.as_mut().unwrap();
535
+ current.last_heartbeat = now;
536
+ current.expires_at = now + (extend_ms / 1000) as i64;
537
+ Ok(current.clone())
538
+ })
539
+ }
540
+ pub fn execution_context(
541
+ &self,
542
+ id: &str,
543
+ claim: &str,
544
+ worker: &str,
545
+ token: u64,
546
+ now: i64,
547
+ secret_references: Vec<String>,
548
+ ) -> Result<WorkloadExecutionContext, WorkloadError> {
549
+ let workload = self.get(id)?;
550
+ Self::fence(workload, claim, worker, token, now)?;
551
+ if secret_references
552
+ .iter()
553
+ .any(|v| !v.starts_with("secret://"))
554
+ {
555
+ return Err(WorkloadError::new(
556
+ "WORKLOAD_SECRET_REJECTED",
557
+ "execution context accepts secret references only",
558
+ ));
559
+ }
560
+ let current = workload.current_claim.as_ref().unwrap();
561
+ Ok(WorkloadExecutionContext {
562
+ workload_id: id.into(),
563
+ attempt_id: workload.attempts.last().unwrap().attempt_id.clone(),
564
+ worker_id: worker.into(),
565
+ capabilities: current.capability_snapshot.clone(),
566
+ connection_scopes: workload.connection_scopes.clone(),
567
+ secret_references,
568
+ expires_at: current.expires_at,
569
+ fencing_token: token,
570
+ })
571
+ }
572
+ pub fn checkpoint(
573
+ &mut self,
574
+ id: &str,
575
+ claim: &str,
576
+ worker: &str,
577
+ token: u64,
578
+ state: Value,
579
+ now: i64,
580
+ ) -> Result<WorkloadCheckpoint, WorkloadError> {
581
+ ensure_safe(&state)?;
582
+ self.edit(id, |store, w| {
583
+ Self::fence(w, claim, worker, token, now)?;
584
+ if !matches!(
585
+ w.state,
586
+ WorkloadState::Running | WorkloadState::Checkpointed
587
+ ) {
588
+ return Err(WorkloadError::new(
589
+ "WORKLOAD_TRANSITION_DENIED",
590
+ "checkpoint requires a running workload",
591
+ ));
592
+ }
593
+ let attempt = w.attempts.last_mut().unwrap();
594
+ attempt.status = AttemptStatus::Checkpointed;
595
+ let sequence = w.checkpoints.len() as u64 + 1;
596
+ let checkpoint = WorkloadCheckpoint {
597
+ checkpoint_id: format!("checkpoint_{}_{}", w.workload_id, sequence),
598
+ workload_id: w.workload_id.clone(),
599
+ attempt_id: attempt.attempt_id.clone(),
600
+ sequence,
601
+ state_hash: hash(&state),
602
+ state,
603
+ created_at: now,
604
+ produced_by: worker.into(),
605
+ };
606
+ w.checkpoints.push(checkpoint.clone());
607
+ if w.state == WorkloadState::Running {
608
+ store.transition(w, WorkloadState::Checkpointed, worker, now, BTreeMap::new())?;
609
+ }
610
+ Ok(checkpoint)
611
+ })
612
+ }
613
+ pub fn complete(
614
+ &mut self,
615
+ id: &str,
616
+ claim: &str,
617
+ worker: &str,
618
+ token: u64,
619
+ result_id: &str,
620
+ output: Value,
621
+ artifacts: Vec<String>,
622
+ schema_version: u64,
623
+ now: i64,
624
+ ) -> Result<WorkloadResult, WorkloadError> {
625
+ ensure_safe(&output)?;
626
+ if let Some(workload_id) = self.result_ids.get(result_id) {
627
+ if workload_id == id {
628
+ return Ok(self.get(id)?.result.clone().unwrap());
629
+ }
630
+ return Err(WorkloadError::new(
631
+ "WORKLOAD_RESULT_DUPLICATE",
632
+ "result id belongs to another workload",
633
+ ));
634
+ }
635
+ let result = self.edit(id, |store, w| {
636
+ Self::fence(w, claim, worker, token, now)?;
637
+ if !matches!(
638
+ w.state,
639
+ WorkloadState::Running | WorkloadState::Checkpointed
640
+ ) {
641
+ return Err(WorkloadError::new(
642
+ "WORKLOAD_TRANSITION_DENIED",
643
+ "completion requires running state",
644
+ ));
645
+ }
646
+ let attempt = w.attempts.last_mut().unwrap();
647
+ let output_hash = hash(&output);
648
+ attempt.status = AttemptStatus::Succeeded;
649
+ attempt.ended_at = Some(now);
650
+ attempt.result_hash = Some(output_hash.clone());
651
+ let result = WorkloadResult {
652
+ result_id: result_id.into(),
653
+ workload_id: w.workload_id.clone(),
654
+ attempt_id: attempt.attempt_id.clone(),
655
+ status: "succeeded".into(),
656
+ output,
657
+ output_hash,
658
+ artifacts,
659
+ provenance: [
660
+ ("claim_id".into(), claim.into()),
661
+ ("fencing_token".into(), token.to_string()),
662
+ ]
663
+ .into(),
664
+ produced_by: worker.into(),
665
+ created_at: now,
666
+ schema_version,
667
+ };
668
+ w.result = Some(result.clone());
669
+ w.current_claim = None;
670
+ store.transition(w, WorkloadState::Succeeded, worker, now, BTreeMap::new())?;
671
+ Ok(result)
672
+ })?;
673
+ self.result_ids.insert(result_id.into(), id.into());
674
+ self.persist()?;
675
+ Ok(result)
676
+ }
677
+ pub fn fail(
678
+ &mut self,
679
+ id: &str,
680
+ claim: &str,
681
+ worker: &str,
682
+ token: u64,
683
+ kind: &str,
684
+ error: &str,
685
+ now: i64,
686
+ ) -> Result<WorkloadState, WorkloadError> {
687
+ if sensitive_text(error) {
688
+ return Err(WorkloadError::new(
689
+ "WORKLOAD_SECRET_REJECTED",
690
+ "errors may not contain secret material",
691
+ ));
692
+ }
693
+ self.edit(id, |store, w| {
694
+ Self::fence(w, claim, worker, token, now)?;
695
+ let attempt = w.attempts.last_mut().unwrap();
696
+ attempt.status = AttemptStatus::Failed;
697
+ attempt.ended_at = Some(now);
698
+ attempt.error = Some(error.into());
699
+ w.last_failure = Some(format!("{kind}: {error}"));
700
+ w.current_claim = None;
701
+ store.transition(
702
+ w,
703
+ WorkloadState::Failed,
704
+ worker,
705
+ now,
706
+ [("failure_kind".into(), kind.into())].into(),
707
+ )?;
708
+ let retryable = w.retry_policy.retry_on.contains(kind)
709
+ && w.attempt_count < w.retry_policy.max_attempts;
710
+ if retryable {
711
+ w.retry_count += 1;
712
+ let multiplier = if w.retry_policy.backoff == BackoffPolicy::Exponential {
713
+ 2u64.saturating_pow(w.retry_count.saturating_sub(1))
714
+ } else {
715
+ 1
716
+ };
717
+ let delay = w
718
+ .retry_policy
719
+ .initial_delay_ms
720
+ .saturating_mul(multiplier)
721
+ .min(w.retry_policy.max_delay_ms);
722
+ w.next_attempt_at = Some(now + (delay / 1000) as i64);
723
+ store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
724
+ } else {
725
+ store.transition(
726
+ w,
727
+ WorkloadState::DeadLetter,
728
+ "rust.retry",
729
+ now,
730
+ BTreeMap::new(),
731
+ )?;
732
+ }
733
+ Ok(w.state)
734
+ })
735
+ }
736
+ pub fn cancel(
737
+ &mut self,
738
+ id: &str,
739
+ actor: &str,
740
+ now: i64,
741
+ ) -> Result<WorkloadState, WorkloadError> {
742
+ self.edit(id, |store, w| {
743
+ match w.state {
744
+ WorkloadState::Ready
745
+ | WorkloadState::Created
746
+ | WorkloadState::Failed
747
+ | WorkloadState::DeadLetter => {
748
+ w.current_claim = None;
749
+ store.transition(w, WorkloadState::Cancelled, actor, now, BTreeMap::new())?
750
+ }
751
+ WorkloadState::Claimed | WorkloadState::Running | WorkloadState::Checkpointed => {
752
+ store.transition(
753
+ w,
754
+ WorkloadState::CancellationRequested,
755
+ actor,
756
+ now,
757
+ BTreeMap::new(),
758
+ )?
759
+ }
760
+ WorkloadState::CancellationRequested => {}
761
+ _ => {
762
+ return Err(WorkloadError::new(
763
+ "WORKLOAD_TRANSITION_DENIED",
764
+ "terminal workload cannot be cancelled",
765
+ ))
766
+ }
767
+ }
768
+ Ok(w.state)
769
+ })
770
+ }
771
+ pub fn confirm_cancel(
772
+ &mut self,
773
+ id: &str,
774
+ claim: &str,
775
+ worker: &str,
776
+ token: u64,
777
+ now: i64,
778
+ ) -> Result<(), WorkloadError> {
779
+ self.edit(id, |store, w| {
780
+ Self::fence(w, claim, worker, token, now)?;
781
+ if w.state != WorkloadState::CancellationRequested {
782
+ return Err(WorkloadError::new(
783
+ "WORKLOAD_TRANSITION_DENIED",
784
+ "cancellation was not requested",
785
+ ));
786
+ }
787
+ if let Some(attempt) = w.attempts.last_mut() {
788
+ attempt.status = AttemptStatus::Cancelled;
789
+ attempt.ended_at = Some(now);
790
+ }
791
+ w.current_claim = None;
792
+ store.transition(w, WorkloadState::Cancelled, worker, now, BTreeMap::new())
793
+ })
794
+ }
795
+ pub fn timeout(
796
+ &mut self,
797
+ id: &str,
798
+ actor: &str,
799
+ now: i64,
800
+ ) -> Result<WorkloadState, WorkloadError> {
801
+ self.edit(id, |store, w| {
802
+ if !matches!(
803
+ w.state,
804
+ WorkloadState::Ready
805
+ | WorkloadState::Claimed
806
+ | WorkloadState::Running
807
+ | WorkloadState::Checkpointed
808
+ ) {
809
+ return Err(WorkloadError::new(
810
+ "WORKLOAD_TRANSITION_DENIED",
811
+ "workload cannot time out in its current state",
812
+ ));
813
+ }
814
+ if let Some(attempt) = w.attempts.last_mut() {
815
+ attempt.status = AttemptStatus::TimedOut;
816
+ attempt.ended_at = Some(now);
817
+ }
818
+ w.current_claim = None;
819
+ store.transition(w, WorkloadState::TimedOut, actor, now, BTreeMap::new())?;
820
+ if w.attempt_count < w.retry_policy.max_attempts
821
+ && w.retry_policy.retry_on.contains("timeout")
822
+ {
823
+ w.retry_count += 1;
824
+ w.next_attempt_at = Some(now + (w.retry_policy.initial_delay_ms / 1000) as i64);
825
+ store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
826
+ } else {
827
+ store.transition(
828
+ w,
829
+ WorkloadState::DeadLetter,
830
+ "rust.timeout",
831
+ now,
832
+ BTreeMap::new(),
833
+ )?;
834
+ }
835
+ Ok(w.state)
836
+ })
837
+ }
838
+ pub fn retry(&mut self, id: &str, actor: &str, now: i64) -> Result<(), WorkloadError> {
839
+ self.edit(id, |store, w| {
840
+ if !matches!(
841
+ w.state,
842
+ WorkloadState::Failed | WorkloadState::DeadLetter | WorkloadState::TimedOut
843
+ ) {
844
+ return Err(WorkloadError::new(
845
+ "WORKLOAD_TRANSITION_DENIED",
846
+ "workload is not retryable",
847
+ ));
848
+ }
849
+ w.next_attempt_at = Some(now);
850
+ w.current_claim = None;
851
+ store.transition(w, WorkloadState::Ready, actor, now, BTreeMap::new())
852
+ })
853
+ }
854
+ pub fn recover_expired(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
855
+ let ids = self
856
+ .workloads
857
+ .values()
858
+ .filter(|w| {
859
+ w.current_claim
860
+ .as_ref()
861
+ .is_some_and(|c| c.expires_at <= now)
862
+ && matches!(
863
+ w.state,
864
+ WorkloadState::Claimed
865
+ | WorkloadState::Running
866
+ | WorkloadState::Checkpointed
867
+ | WorkloadState::CancellationRequested
868
+ )
869
+ })
870
+ .map(|w| w.workload_id.clone())
871
+ .collect::<Vec<_>>();
872
+ for id in &ids {
873
+ self.edit(id, |store, w| {
874
+ if let Some(attempt) = w.attempts.last_mut() {
875
+ attempt.status = AttemptStatus::WorkerLost;
876
+ attempt.ended_at = Some(now);
877
+ }
878
+ w.current_claim = None;
879
+ if w.state == WorkloadState::CancellationRequested {
880
+ store.transition(
881
+ w,
882
+ WorkloadState::Cancelled,
883
+ "rust.recovery",
884
+ now,
885
+ BTreeMap::new(),
886
+ )?;
887
+ } else {
888
+ store.transition(
889
+ w,
890
+ WorkloadState::Recovering,
891
+ "rust.recovery",
892
+ now,
893
+ BTreeMap::new(),
894
+ )?;
895
+ store.transition(
896
+ w,
897
+ WorkloadState::Ready,
898
+ "rust.recovery",
899
+ now,
900
+ BTreeMap::new(),
901
+ )?;
902
+ }
903
+ Ok(())
904
+ })?;
905
+ }
906
+ Ok(ids)
907
+ }
908
+ }
909
+
910
+ pub trait WorkScheduler {
911
+ fn enqueue(&mut self, workload_id: &str);
912
+ fn claim(
913
+ &mut self,
914
+ store: &mut WorkloadStore,
915
+ tenant: &str,
916
+ application: &str,
917
+ worker: &str,
918
+ capabilities: &BTreeSet<String>,
919
+ now: i64,
920
+ lease_ms: u64,
921
+ ) -> Result<Option<WorkloadClaim>, WorkloadError>;
922
+ fn release(&mut self, claim_id: &str);
923
+ }
924
+ #[derive(Default)]
925
+ pub struct LocalDurableScheduler {
926
+ queue: BTreeSet<String>,
927
+ }
928
+ impl WorkScheduler for LocalDurableScheduler {
929
+ fn enqueue(&mut self, id: &str) {
930
+ self.queue.insert(id.into());
931
+ }
932
+ fn claim(
933
+ &mut self,
934
+ store: &mut WorkloadStore,
935
+ tenant: &str,
936
+ application: &str,
937
+ worker: &str,
938
+ capabilities: &BTreeSet<String>,
939
+ now: i64,
940
+ lease_ms: u64,
941
+ ) -> Result<Option<WorkloadClaim>, WorkloadError> {
942
+ let selected = store
943
+ .list(tenant, application)
944
+ .into_iter()
945
+ .filter(|w| {
946
+ w.state == WorkloadState::Ready && w.capability_snapshot.is_subset(capabilities)
947
+ })
948
+ .find(|w| {
949
+ w.dependencies.iter().all(|d| {
950
+ store
951
+ .workloads
952
+ .get(&d.workload_id)
953
+ .is_some_and(|dependency| dependency.state == d.required_state)
954
+ })
955
+ })
956
+ .map(|w| w.workload_id);
957
+ match selected {
958
+ Some(id) => store
959
+ .claim(&id, worker, capabilities, now, lease_ms)
960
+ .map(Some),
961
+ None => Ok(None),
962
+ }
963
+ }
964
+ fn release(&mut self, _: &str) {}
965
+ }
966
+ fn legal(from: WorkloadState, to: WorkloadState) -> bool {
967
+ use WorkloadState::*;
968
+ matches!(
969
+ (from, to),
970
+ (Created, Ready)
971
+ | (Ready, Claimed)
972
+ | (Claimed, Running)
973
+ | (Running, Checkpointed)
974
+ | (Checkpointed, Checkpointed)
975
+ | (Running, Succeeded)
976
+ | (Checkpointed, Succeeded)
977
+ | (Claimed, CancellationRequested)
978
+ | (Running, CancellationRequested)
979
+ | (Checkpointed, CancellationRequested)
980
+ | (CancellationRequested, Cancelled)
981
+ | (Ready, Cancelled)
982
+ | (Created, Cancelled)
983
+ | (Failed, Cancelled)
984
+ | (DeadLetter, Cancelled)
985
+ | (Running, Failed)
986
+ | (Checkpointed, Failed)
987
+ | (Claimed, Recovering)
988
+ | (Running, Recovering)
989
+ | (Checkpointed, Recovering)
990
+ | (Recovering, Ready)
991
+ | (Failed, Ready)
992
+ | (TimedOut, Ready)
993
+ | (DeadLetter, Ready)
994
+ | (Failed, DeadLetter)
995
+ | (Running, TimedOut)
996
+ | (Claimed, TimedOut)
997
+ | (Checkpointed, TimedOut)
998
+ | (Ready, TimedOut)
999
+ | (TimedOut, DeadLetter)
1000
+ )
1001
+ }
1002
+ pub fn can_transition(from: WorkloadState, to: WorkloadState) -> bool {
1003
+ legal(from, to)
1004
+ }
1005
+ fn event_name(state: WorkloadState) -> &'static str {
1006
+ use WorkloadState::*;
1007
+ match state {
1008
+ Created => "workload.created",
1009
+ Ready => "workload.ready",
1010
+ Claimed => "workload.claimed",
1011
+ Running => "workload.started",
1012
+ Checkpointed => "workload.checkpointed",
1013
+ Recovering => "workload.recovered",
1014
+ CancellationRequested => "workload.cancellation_requested",
1015
+ Succeeded => "workload.completed",
1016
+ Failed => "workload.failed",
1017
+ Cancelled => "workload.cancelled",
1018
+ TimedOut => "workload.timed_out",
1019
+ DeadLetter => "workload.dead_lettered",
1020
+ }
1021
+ }
1022
+ fn ensure_safe(value: &Value) -> Result<(), WorkloadError> {
1023
+ fn walk(value: &Value) -> bool {
1024
+ match value {
1025
+ Value::Object(map) => map.iter().any(|(k, v)| {
1026
+ matches!(
1027
+ k.to_ascii_lowercase().as_str(),
1028
+ "password" | "secret" | "token" | "api_key" | "private_key"
1029
+ ) || walk(v)
1030
+ }),
1031
+ Value::Array(values) => values.iter().any(walk),
1032
+ Value::String(v) => sensitive_text(v),
1033
+ _ => false,
1034
+ }
1035
+ }
1036
+ if walk(value) {
1037
+ Err(WorkloadError::new(
1038
+ "WORKLOAD_SECRET_REJECTED",
1039
+ "plaintext secrets are forbidden in workload state",
1040
+ ))
1041
+ } else {
1042
+ Ok(())
1043
+ }
1044
+ }
1045
+ fn sensitive_text(v: &str) -> bool {
1046
+ v.contains("BEGIN PRIVATE KEY") || v.starts_with("sk-") || v.starts_with("fdb_live_")
1047
+ }
1048
+ fn io(e: impl std::fmt::Display) -> WorkloadError {
1049
+ WorkloadError::new("WORKLOAD_STORAGE_FAILURE", e.to_string())
1050
+ }
1051
+ fn hash(value: &Value) -> String {
1052
+ hex(&Sha256::digest(
1053
+ serde_json::to_vec(value).unwrap_or_default(),
1054
+ ))
1055
+ }
1056
+ fn hex(bytes: &[u8]) -> String {
1057
+ bytes.iter().map(|v| format!("{v:02x}")).collect()
1058
+ }
1059
+
1060
+ #[cfg(test)]
1061
+ mod tests {
1062
+ use super::*;
1063
+ use crate::authorization::{
1064
+ Grant, GrantConstraints, GrantSigner, GrantStatus, ResourceScope, ResourceScopeKind,
1065
+ };
1066
+ fn create() -> CreateWorkload {
1067
+ let signer = GrantSigner::new("k", vec![1; 32]).unwrap();
1068
+ let subject = Subject::Agent("a1".into());
1069
+ let grant = Grant {
1070
+ contract_version: 1,
1071
+ id: "g".into(),
1072
+ issuer: Subject::Human("h".into()),
1073
+ subject: subject.clone(),
1074
+ tenant_id: "t".into(),
1075
+ application_id: "app".into(),
1076
+ capabilities: ["workload:execute".into()].into(),
1077
+ resources: vec![ResourceScope {
1078
+ kind: ResourceScopeKind::Workload,
1079
+ uri: "flow://app/workloads/*".into(),
1080
+ }],
1081
+ constraints: GrantConstraints::default(),
1082
+ issued_at: 1,
1083
+ expires_at: 9999,
1084
+ parent_grant: None,
1085
+ revision: 1,
1086
+ status: GrantStatus::Active,
1087
+ };
1088
+ CreateWorkload {
1089
+ tenant_id: "t".into(),
1090
+ application_id: "app".into(),
1091
+ environment: "production".into(),
1092
+ definition_id: "send".into(),
1093
+ definition_revision: "r1".into(),
1094
+ trigger: "manual".into(),
1095
+ input: serde_json::json!({"id":1}),
1096
+ requested_by: subject,
1097
+ authorization_snapshot: signer.sign(grant).unwrap(),
1098
+ capability_snapshot: ["email:send".into()].into(),
1099
+ connection_scopes: BTreeSet::new(),
1100
+ priority: 0,
1101
+ idempotency_key: "one".into(),
1102
+ retry_policy: Default::default(),
1103
+ timeouts: Default::default(),
1104
+ dependencies: vec![],
1105
+ created_at: 10,
1106
+ }
1107
+ }
1108
+ fn store(name: &str) -> (PathBuf, WorkloadStore) {
1109
+ let path =
1110
+ std::env::temp_dir().join(format!("workload-{name}-{}.json", std::process::id()));
1111
+ let store = WorkloadStore::load(&path).unwrap();
1112
+ (path, store)
1113
+ }
1114
+ #[test]
1115
+ fn idempotent_create_and_legal_lifecycle() {
1116
+ let (path, mut store) = store("lifecycle");
1117
+ let one = store.create(create()).unwrap();
1118
+ let two = store.create(create()).unwrap();
1119
+ assert_eq!(one.workload_id, two.workload_id);
1120
+ let caps = ["email:send".into()].into();
1121
+ let claim = store
1122
+ .claim(&one.workload_id, "worker", &caps, 11, 10_000)
1123
+ .unwrap();
1124
+ store
1125
+ .start(
1126
+ &one.workload_id,
1127
+ &claim.claim_id,
1128
+ "worker",
1129
+ claim.fencing_token,
1130
+ 12,
1131
+ )
1132
+ .unwrap();
1133
+ let result = store
1134
+ .complete(
1135
+ &one.workload_id,
1136
+ &claim.claim_id,
1137
+ "worker",
1138
+ claim.fencing_token,
1139
+ "result-1",
1140
+ serde_json::json!({"ok":true}),
1141
+ vec![],
1142
+ 1,
1143
+ 13,
1144
+ )
1145
+ .unwrap();
1146
+ assert_eq!(result.result_id, "result-1");
1147
+ assert!(store
1148
+ .start(
1149
+ &one.workload_id,
1150
+ &claim.claim_id,
1151
+ "worker",
1152
+ claim.fencing_token,
1153
+ 14
1154
+ )
1155
+ .is_err());
1156
+ let recovered = WorkloadStore::load(&path).unwrap();
1157
+ assert_eq!(
1158
+ recovered.get(&one.workload_id).unwrap().state,
1159
+ WorkloadState::Succeeded
1160
+ );
1161
+ let _ = fs::remove_file(path);
1162
+ }
1163
+ #[test]
1164
+ fn stale_fence_and_lease_recovery() {
1165
+ let (path, mut store) = store("fence");
1166
+ let w = store.create(create()).unwrap();
1167
+ let caps = ["email:send".into()].into();
1168
+ let old = store.claim(&w.workload_id, "a", &caps, 11, 1000).unwrap();
1169
+ store
1170
+ .start(&w.workload_id, &old.claim_id, "a", old.fencing_token, 11)
1171
+ .unwrap();
1172
+ store.recover_expired(13).unwrap();
1173
+ let new = store.claim(&w.workload_id, "b", &caps, 13, 10_000).unwrap();
1174
+ assert!(new.fencing_token > old.fencing_token);
1175
+ assert_eq!(
1176
+ store
1177
+ .complete(
1178
+ &w.workload_id,
1179
+ &old.claim_id,
1180
+ "a",
1181
+ old.fencing_token,
1182
+ "stale",
1183
+ Value::Null,
1184
+ vec![],
1185
+ 1,
1186
+ 14
1187
+ )
1188
+ .unwrap_err()
1189
+ .code,
1190
+ "WORKLOAD_STALE_FENCE"
1191
+ );
1192
+ let _ = fs::remove_file(path);
1193
+ }
1194
+ #[test]
1195
+ fn retry_dead_letter_checkpoint_and_secret_safety() {
1196
+ let (path, mut store) = store("retry");
1197
+ let mut input = create();
1198
+ input.retry_policy.max_attempts = 1;
1199
+ let w = store.create(input).unwrap();
1200
+ let caps = ["email:send".into()].into();
1201
+ let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
1202
+ store
1203
+ .start(
1204
+ &w.workload_id,
1205
+ &claim.claim_id,
1206
+ "a",
1207
+ claim.fencing_token,
1208
+ 12,
1209
+ )
1210
+ .unwrap();
1211
+ assert!(store
1212
+ .checkpoint(
1213
+ &w.workload_id,
1214
+ &claim.claim_id,
1215
+ "a",
1216
+ claim.fencing_token,
1217
+ serde_json::json!({"password":"bad"}),
1218
+ 13
1219
+ )
1220
+ .is_err());
1221
+ assert_eq!(
1222
+ store
1223
+ .fail(
1224
+ &w.workload_id,
1225
+ &claim.claim_id,
1226
+ "a",
1227
+ claim.fencing_token,
1228
+ "worker_failure",
1229
+ "crashed",
1230
+ 13
1231
+ )
1232
+ .unwrap(),
1233
+ WorkloadState::DeadLetter
1234
+ );
1235
+ let _ = fs::remove_file(path);
1236
+ }
1237
+ #[test]
1238
+ fn cancellation_race_and_duplicate_completion_are_deterministic() {
1239
+ let (path, mut store) = store("cancel");
1240
+ let w = store.create(create()).unwrap();
1241
+ let caps = ["email:send".into()].into();
1242
+ let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
1243
+ store
1244
+ .start(
1245
+ &w.workload_id,
1246
+ &claim.claim_id,
1247
+ "a",
1248
+ claim.fencing_token,
1249
+ 12,
1250
+ )
1251
+ .unwrap();
1252
+ assert_eq!(
1253
+ store.cancel(&w.workload_id, "user", 13).unwrap(),
1254
+ WorkloadState::CancellationRequested
1255
+ );
1256
+ assert_eq!(
1257
+ store
1258
+ .complete(
1259
+ &w.workload_id,
1260
+ &claim.claim_id,
1261
+ "a",
1262
+ claim.fencing_token,
1263
+ "r",
1264
+ Value::Null,
1265
+ vec![],
1266
+ 1,
1267
+ 14
1268
+ )
1269
+ .unwrap_err()
1270
+ .code,
1271
+ "WORKLOAD_TRANSITION_DENIED"
1272
+ );
1273
+ store
1274
+ .confirm_cancel(
1275
+ &w.workload_id,
1276
+ &claim.claim_id,
1277
+ "a",
1278
+ claim.fencing_token,
1279
+ 14,
1280
+ )
1281
+ .unwrap();
1282
+ assert_eq!(
1283
+ store.get(&w.workload_id).unwrap().state,
1284
+ WorkloadState::Cancelled
1285
+ );
1286
+ let _ = fs::remove_file(path);
1287
+ }
1288
+ #[test]
1289
+ fn scheduler_respects_dependencies_and_tenant_application_isolation() {
1290
+ let (path, mut store) = store("dependency");
1291
+ let parent = store.create(create()).unwrap();
1292
+ let mut child = create();
1293
+ child.idempotency_key = "child".into();
1294
+ child.dependencies = vec![WorkloadDependency {
1295
+ workload_id: parent.workload_id.clone(),
1296
+ required_state: WorkloadState::Succeeded,
1297
+ }];
1298
+ let child = store.create(child).unwrap();
1299
+ assert_eq!(store.list("other", "app").len(), 0);
1300
+ let caps = ["email:send".into()].into();
1301
+ let mut scheduler = LocalDurableScheduler::default();
1302
+ let first = scheduler
1303
+ .claim(&mut store, "t", "app", "worker", &caps, 11, 10_000)
1304
+ .unwrap()
1305
+ .unwrap();
1306
+ assert_eq!(first.workload_id, parent.workload_id);
1307
+ assert_ne!(first.workload_id, child.workload_id);
1308
+ let _ = fs::remove_file(path);
1309
+ }
1310
+ }