agentguard-local 0.1.0
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.
- package/Cargo.lock +727 -0
- package/Cargo.toml +22 -0
- package/README.md +140 -0
- package/crates/agentguard/Cargo.toml +23 -0
- package/crates/agentguard/src/bin/agentguard.rs +460 -0
- package/crates/agentguard/src/bin/agentguardd.rs +23 -0
- package/crates/agentguard/tests/integration.rs +384 -0
- package/crates/agentguard-core/Cargo.toml +19 -0
- package/crates/agentguard-core/src/adapter.rs +69 -0
- package/crates/agentguard-core/src/lib.rs +14 -0
- package/crates/agentguard-core/src/lifecycle.rs +41 -0
- package/crates/agentguard-core/src/policy.rs +117 -0
- package/crates/agentguard-core/src/pressure.rs +186 -0
- package/crates/agentguard-core/src/process.rs +100 -0
- package/crates/agentguard-core/src/protocol.rs +186 -0
- package/crates/agentguard-core/src/scheduler.rs +132 -0
- package/crates/agentguard-core/src/server.rs +613 -0
- package/crates/agentguard-core/src/store.rs +434 -0
- package/crates/agentguard-core/src/types.rs +248 -0
- package/docs/DECISIONS.md +37 -0
- package/docs/TESTING.md +75 -0
- package/npm/bin/agentguard +9 -0
- package/npm/bin/agentguardd +9 -0
- package/npm/bin/darwin-arm64/agentguard +0 -0
- package/npm/bin/darwin-arm64/agentguardd +0 -0
- package/package.json +44 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
use crate::lifecycle::ensure_transition;
|
|
2
|
+
use crate::types::{EventRecord, JobRecord, JobState, Policy, ProcessSample, ToolPath};
|
|
3
|
+
use chrono::{DateTime, Utc};
|
|
4
|
+
use rusqlite::{Connection, OptionalExtension, params};
|
|
5
|
+
use serde_json::{Value, json};
|
|
6
|
+
use std::path::{Path, PathBuf};
|
|
7
|
+
|
|
8
|
+
pub struct Store {
|
|
9
|
+
conn: Connection,
|
|
10
|
+
path: PathBuf,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl Store {
|
|
14
|
+
pub fn open(path: impl AsRef<Path>) -> anyhow::Result<Self> {
|
|
15
|
+
let path = path.as_ref().to_path_buf();
|
|
16
|
+
if let Some(parent) = path.parent() {
|
|
17
|
+
std::fs::create_dir_all(parent)?;
|
|
18
|
+
}
|
|
19
|
+
let conn = Connection::open(&path)?;
|
|
20
|
+
conn.busy_timeout(std::time::Duration::from_secs(2))?;
|
|
21
|
+
let store = Self { conn, path };
|
|
22
|
+
store.init_schema()?;
|
|
23
|
+
Ok(store)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn path(&self) -> &Path {
|
|
27
|
+
&self.path
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn init_schema(&self) -> anyhow::Result<()> {
|
|
31
|
+
self.conn.execute_batch(
|
|
32
|
+
r#"
|
|
33
|
+
PRAGMA journal_mode = WAL;
|
|
34
|
+
PRAGMA foreign_keys = ON;
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
adapter TEXT NOT NULL,
|
|
39
|
+
command TEXT NOT NULL,
|
|
40
|
+
args_json TEXT NOT NULL,
|
|
41
|
+
cwd TEXT NOT NULL,
|
|
42
|
+
status TEXT NOT NULL,
|
|
43
|
+
priority TEXT NOT NULL,
|
|
44
|
+
root_pid INTEGER,
|
|
45
|
+
pgid INTEGER,
|
|
46
|
+
queued_reason TEXT,
|
|
47
|
+
started_at TEXT,
|
|
48
|
+
ended_at TEXT,
|
|
49
|
+
exit_code INTEGER,
|
|
50
|
+
policy_snapshot_json TEXT NOT NULL,
|
|
51
|
+
created_at TEXT NOT NULL,
|
|
52
|
+
updated_at TEXT NOT NULL
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS process_samples (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
job_id TEXT NOT NULL,
|
|
58
|
+
sampled_at TEXT NOT NULL,
|
|
59
|
+
pid_count INTEGER NOT NULL,
|
|
60
|
+
rss_bytes INTEGER NOT NULL,
|
|
61
|
+
footprint_bytes INTEGER NOT NULL,
|
|
62
|
+
cpu_pct REAL NOT NULL,
|
|
63
|
+
pressure_state TEXT NOT NULL
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
job_id TEXT,
|
|
69
|
+
ts TEXT NOT NULL,
|
|
70
|
+
type TEXT NOT NULL,
|
|
71
|
+
payload_json TEXT NOT NULL
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS policies (
|
|
75
|
+
name TEXT PRIMARY KEY,
|
|
76
|
+
created_at TEXT NOT NULL,
|
|
77
|
+
updated_at TEXT NOT NULL,
|
|
78
|
+
profile_json TEXT NOT NULL
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS tool_paths (
|
|
82
|
+
tool TEXT PRIMARY KEY,
|
|
83
|
+
real_path TEXT NOT NULL,
|
|
84
|
+
discovered_at TEXT NOT NULL,
|
|
85
|
+
hash TEXT
|
|
86
|
+
);
|
|
87
|
+
"#,
|
|
88
|
+
)?;
|
|
89
|
+
Ok(())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pub fn load_policy(&self) -> anyhow::Result<Policy> {
|
|
93
|
+
let profile: Option<String> = self
|
|
94
|
+
.conn
|
|
95
|
+
.query_row(
|
|
96
|
+
"SELECT profile_json FROM policies WHERE name = 'default'",
|
|
97
|
+
[],
|
|
98
|
+
|row| row.get(0),
|
|
99
|
+
)
|
|
100
|
+
.optional()?;
|
|
101
|
+
if let Some(profile) = profile {
|
|
102
|
+
Ok(serde_json::from_str(&profile)?)
|
|
103
|
+
} else {
|
|
104
|
+
let policy = Policy::default();
|
|
105
|
+
self.save_policy(&policy)?;
|
|
106
|
+
Ok(policy)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pub fn save_policy(&self, policy: &Policy) -> anyhow::Result<()> {
|
|
111
|
+
policy.validate()?;
|
|
112
|
+
let now = Utc::now().to_rfc3339();
|
|
113
|
+
let json = serde_json::to_string(policy)?;
|
|
114
|
+
self.conn.execute(
|
|
115
|
+
r#"
|
|
116
|
+
INSERT INTO policies (name, created_at, updated_at, profile_json)
|
|
117
|
+
VALUES ('default', ?1, ?1, ?2)
|
|
118
|
+
ON CONFLICT(name) DO UPDATE SET updated_at = excluded.updated_at,
|
|
119
|
+
profile_json = excluded.profile_json
|
|
120
|
+
"#,
|
|
121
|
+
params![now, json],
|
|
122
|
+
)?;
|
|
123
|
+
Ok(())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pub fn insert_job(&self, job: &JobRecord) -> anyhow::Result<()> {
|
|
127
|
+
self.conn.execute(
|
|
128
|
+
r#"
|
|
129
|
+
INSERT INTO jobs (
|
|
130
|
+
id, adapter, command, args_json, cwd, status, priority, root_pid, pgid,
|
|
131
|
+
queued_reason, started_at, ended_at, exit_code, policy_snapshot_json,
|
|
132
|
+
created_at, updated_at
|
|
133
|
+
)
|
|
134
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
|
|
135
|
+
"#,
|
|
136
|
+
params![
|
|
137
|
+
job.id,
|
|
138
|
+
job.adapter,
|
|
139
|
+
job.command,
|
|
140
|
+
serde_json::to_string(&job.args)?,
|
|
141
|
+
job.cwd,
|
|
142
|
+
job.status.to_string(),
|
|
143
|
+
job.priority.to_string(),
|
|
144
|
+
job.root_pid,
|
|
145
|
+
job.pgid,
|
|
146
|
+
job.queued_reason,
|
|
147
|
+
opt_ts(job.started_at),
|
|
148
|
+
opt_ts(job.ended_at),
|
|
149
|
+
job.exit_code,
|
|
150
|
+
job.policy_snapshot_json,
|
|
151
|
+
job.created_at.to_rfc3339(),
|
|
152
|
+
job.updated_at.to_rfc3339(),
|
|
153
|
+
],
|
|
154
|
+
)?;
|
|
155
|
+
Ok(())
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pub fn get_job(&self, id: &str) -> anyhow::Result<Option<JobRecord>> {
|
|
159
|
+
self.conn
|
|
160
|
+
.query_row("SELECT * FROM jobs WHERE id = ?1", params![id], map_job)
|
|
161
|
+
.optional()
|
|
162
|
+
.map_err(Into::into)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pub fn list_jobs(&self) -> anyhow::Result<Vec<JobRecord>> {
|
|
166
|
+
let mut stmt = self
|
|
167
|
+
.conn
|
|
168
|
+
.prepare("SELECT * FROM jobs ORDER BY created_at DESC, id ASC")?;
|
|
169
|
+
let rows = stmt.query_map([], map_job)?;
|
|
170
|
+
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pub fn list_jobs_by_state(&self, states: &[JobState]) -> anyhow::Result<Vec<JobRecord>> {
|
|
174
|
+
let jobs = self.list_jobs()?;
|
|
175
|
+
Ok(jobs
|
|
176
|
+
.into_iter()
|
|
177
|
+
.filter(|job| states.contains(&job.status))
|
|
178
|
+
.collect())
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
pub fn active_count(&self) -> anyhow::Result<usize> {
|
|
182
|
+
Ok(self
|
|
183
|
+
.list_jobs()?
|
|
184
|
+
.into_iter()
|
|
185
|
+
.filter(|job| job.status.counts_as_active())
|
|
186
|
+
.count())
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pub fn update_job_state(
|
|
190
|
+
&self,
|
|
191
|
+
id: &str,
|
|
192
|
+
to: JobState,
|
|
193
|
+
queued_reason: Option<&str>,
|
|
194
|
+
) -> anyhow::Result<()> {
|
|
195
|
+
if let Some(current) = self.get_job(id)? {
|
|
196
|
+
ensure_transition(current.status, to)?;
|
|
197
|
+
let now = Utc::now();
|
|
198
|
+
let started_at = if matches!(to, JobState::Starting | JobState::Running) {
|
|
199
|
+
current.started_at.or(Some(now)).map(|ts| ts.to_rfc3339())
|
|
200
|
+
} else {
|
|
201
|
+
opt_ts(current.started_at)
|
|
202
|
+
};
|
|
203
|
+
let ended_at = if to.is_terminal() {
|
|
204
|
+
Some(now.to_rfc3339())
|
|
205
|
+
} else {
|
|
206
|
+
opt_ts(current.ended_at)
|
|
207
|
+
};
|
|
208
|
+
self.conn.execute(
|
|
209
|
+
r#"
|
|
210
|
+
UPDATE jobs
|
|
211
|
+
SET status = ?2, queued_reason = ?3, started_at = ?4, ended_at = ?5, updated_at = ?6
|
|
212
|
+
WHERE id = ?1
|
|
213
|
+
"#,
|
|
214
|
+
params![
|
|
215
|
+
id,
|
|
216
|
+
to.to_string(),
|
|
217
|
+
queued_reason,
|
|
218
|
+
started_at,
|
|
219
|
+
ended_at,
|
|
220
|
+
now.to_rfc3339()
|
|
221
|
+
],
|
|
222
|
+
)?;
|
|
223
|
+
}
|
|
224
|
+
Ok(())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
pub fn register_job(&self, id: &str, root_pid: i64, pgid: i64) -> anyhow::Result<()> {
|
|
228
|
+
if let Some(current) = self.get_job(id)? {
|
|
229
|
+
ensure_transition(current.status, JobState::Running)?;
|
|
230
|
+
}
|
|
231
|
+
let now = Utc::now().to_rfc3339();
|
|
232
|
+
self.conn.execute(
|
|
233
|
+
r#"
|
|
234
|
+
UPDATE jobs
|
|
235
|
+
SET status = 'running',
|
|
236
|
+
root_pid = ?2,
|
|
237
|
+
pgid = ?3,
|
|
238
|
+
started_at = COALESCE(started_at, ?4),
|
|
239
|
+
updated_at = ?4
|
|
240
|
+
WHERE id = ?1
|
|
241
|
+
"#,
|
|
242
|
+
params![id, root_pid, pgid, now],
|
|
243
|
+
)?;
|
|
244
|
+
Ok(())
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
pub fn complete_job(&self, id: &str, exit_code: i32) -> anyhow::Result<()> {
|
|
248
|
+
let state = if exit_code == 0 {
|
|
249
|
+
JobState::Exited
|
|
250
|
+
} else {
|
|
251
|
+
JobState::Failed
|
|
252
|
+
};
|
|
253
|
+
if let Some(current) = self.get_job(id)?
|
|
254
|
+
&& current.status.is_terminal()
|
|
255
|
+
{
|
|
256
|
+
return Ok(());
|
|
257
|
+
}
|
|
258
|
+
let now = Utc::now().to_rfc3339();
|
|
259
|
+
self.conn.execute(
|
|
260
|
+
r#"
|
|
261
|
+
UPDATE jobs
|
|
262
|
+
SET status = ?2, ended_at = ?3, exit_code = ?4, updated_at = ?3
|
|
263
|
+
WHERE id = ?1
|
|
264
|
+
"#,
|
|
265
|
+
params![id, state.to_string(), now, exit_code],
|
|
266
|
+
)?;
|
|
267
|
+
Ok(())
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
pub fn add_event(
|
|
271
|
+
&self,
|
|
272
|
+
job_id: Option<&str>,
|
|
273
|
+
event_type: &str,
|
|
274
|
+
payload: Value,
|
|
275
|
+
) -> anyhow::Result<()> {
|
|
276
|
+
self.conn.execute(
|
|
277
|
+
"INSERT INTO events (job_id, ts, type, payload_json) VALUES (?1, ?2, ?3, ?4)",
|
|
278
|
+
params![
|
|
279
|
+
job_id,
|
|
280
|
+
Utc::now().to_rfc3339(),
|
|
281
|
+
event_type,
|
|
282
|
+
serde_json::to_string(&payload)?,
|
|
283
|
+
],
|
|
284
|
+
)?;
|
|
285
|
+
Ok(())
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
pub fn recent_events(&self, limit: usize) -> anyhow::Result<Vec<EventRecord>> {
|
|
289
|
+
let mut stmt = self.conn.prepare(
|
|
290
|
+
"SELECT id, job_id, ts, type, payload_json FROM events ORDER BY id DESC LIMIT ?1",
|
|
291
|
+
)?;
|
|
292
|
+
let rows = stmt.query_map(params![limit as i64], |row| {
|
|
293
|
+
let ts: String = row.get(2)?;
|
|
294
|
+
let payload: String = row.get(4)?;
|
|
295
|
+
Ok(EventRecord {
|
|
296
|
+
id: row.get(0)?,
|
|
297
|
+
job_id: row.get(1)?,
|
|
298
|
+
ts: parse_ts_sql(&ts)?,
|
|
299
|
+
event_type: row.get(3)?,
|
|
300
|
+
payload_json: serde_json::from_str(&payload).map_err(to_sql_error)?,
|
|
301
|
+
})
|
|
302
|
+
})?;
|
|
303
|
+
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
pub fn record_sample(&self, sample: &ProcessSample) -> anyhow::Result<()> {
|
|
307
|
+
self.conn.execute(
|
|
308
|
+
r#"
|
|
309
|
+
INSERT INTO process_samples (
|
|
310
|
+
job_id, sampled_at, pid_count, rss_bytes, footprint_bytes, cpu_pct, pressure_state
|
|
311
|
+
)
|
|
312
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
|
313
|
+
"#,
|
|
314
|
+
params![
|
|
315
|
+
sample.job_id,
|
|
316
|
+
sample.sampled_at.to_rfc3339(),
|
|
317
|
+
sample.pid_count,
|
|
318
|
+
sample.rss_bytes,
|
|
319
|
+
sample.footprint_bytes,
|
|
320
|
+
sample.cpu_pct,
|
|
321
|
+
sample.pressure_state.to_string(),
|
|
322
|
+
],
|
|
323
|
+
)?;
|
|
324
|
+
Ok(())
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
pub fn set_tool_path(&self, tool: &str, real_path: &str) -> anyhow::Result<()> {
|
|
328
|
+
let now = Utc::now().to_rfc3339();
|
|
329
|
+
self.conn.execute(
|
|
330
|
+
r#"
|
|
331
|
+
INSERT INTO tool_paths (tool, real_path, discovered_at, hash)
|
|
332
|
+
VALUES (?1, ?2, ?3, NULL)
|
|
333
|
+
ON CONFLICT(tool) DO UPDATE SET real_path = excluded.real_path,
|
|
334
|
+
discovered_at = excluded.discovered_at
|
|
335
|
+
"#,
|
|
336
|
+
params![tool, real_path, now],
|
|
337
|
+
)?;
|
|
338
|
+
Ok(())
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
pub fn list_tool_paths(&self) -> anyhow::Result<Vec<ToolPath>> {
|
|
342
|
+
let mut stmt = self
|
|
343
|
+
.conn
|
|
344
|
+
.prepare("SELECT tool, real_path, discovered_at, hash FROM tool_paths ORDER BY tool")?;
|
|
345
|
+
let rows = stmt.query_map([], |row| {
|
|
346
|
+
let discovered_at: String = row.get(2)?;
|
|
347
|
+
Ok(ToolPath {
|
|
348
|
+
tool: row.get(0)?,
|
|
349
|
+
real_path: row.get(1)?,
|
|
350
|
+
discovered_at: parse_ts_sql(&discovered_at)?,
|
|
351
|
+
hash: row.get(3)?,
|
|
352
|
+
})
|
|
353
|
+
})?;
|
|
354
|
+
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
fn map_job(row: &rusqlite::Row<'_>) -> rusqlite::Result<JobRecord> {
|
|
359
|
+
let args_json: String = row.get("args_json")?;
|
|
360
|
+
let status: String = row.get("status")?;
|
|
361
|
+
let priority: String = row.get("priority")?;
|
|
362
|
+
let started_at: Option<String> = row.get("started_at")?;
|
|
363
|
+
let ended_at: Option<String> = row.get("ended_at")?;
|
|
364
|
+
let created_at: String = row.get("created_at")?;
|
|
365
|
+
let updated_at: String = row.get("updated_at")?;
|
|
366
|
+
Ok(JobRecord {
|
|
367
|
+
id: row.get("id")?,
|
|
368
|
+
adapter: row.get("adapter")?,
|
|
369
|
+
command: row.get("command")?,
|
|
370
|
+
args: serde_json::from_str(&args_json).map_err(to_sql_error)?,
|
|
371
|
+
cwd: row.get("cwd")?,
|
|
372
|
+
status: status.parse().map_err(string_sql_error)?,
|
|
373
|
+
priority: priority.parse().map_err(string_sql_error)?,
|
|
374
|
+
root_pid: row.get("root_pid")?,
|
|
375
|
+
pgid: row.get("pgid")?,
|
|
376
|
+
queued_reason: row.get("queued_reason")?,
|
|
377
|
+
started_at: parse_opt_ts(started_at)?,
|
|
378
|
+
ended_at: parse_opt_ts(ended_at)?,
|
|
379
|
+
exit_code: row.get("exit_code")?,
|
|
380
|
+
policy_snapshot_json: row.get("policy_snapshot_json")?,
|
|
381
|
+
created_at: parse_ts_sql(&created_at)?,
|
|
382
|
+
updated_at: parse_ts_sql(&updated_at)?,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
fn opt_ts(ts: Option<DateTime<Utc>>) -> Option<String> {
|
|
387
|
+
ts.map(|ts| ts.to_rfc3339())
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
fn parse_opt_ts(value: Option<String>) -> rusqlite::Result<Option<DateTime<Utc>>> {
|
|
391
|
+
value.as_deref().map(parse_ts_sql).transpose()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
fn parse_ts_sql(value: &str) -> rusqlite::Result<DateTime<Utc>> {
|
|
395
|
+
DateTime::parse_from_rfc3339(value)
|
|
396
|
+
.map(|dt| dt.with_timezone(&Utc))
|
|
397
|
+
.map_err(to_sql_error)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
fn to_sql_error<E>(err: E) -> rusqlite::Error
|
|
401
|
+
where
|
|
402
|
+
E: std::error::Error + Send + Sync + 'static,
|
|
403
|
+
{
|
|
404
|
+
rusqlite::Error::ToSqlConversionFailure(Box::new(err))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn string_sql_error(err: String) -> rusqlite::Error {
|
|
408
|
+
rusqlite::Error::FromSqlConversionFailure(
|
|
409
|
+
0,
|
|
410
|
+
rusqlite::types::Type::Text,
|
|
411
|
+
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, err)),
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
pub fn event_payload(message: impl Into<String>) -> Value {
|
|
416
|
+
json!({ "message": message.into() })
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[cfg(test)]
|
|
420
|
+
mod tests {
|
|
421
|
+
use super::*;
|
|
422
|
+
use tempfile::tempdir;
|
|
423
|
+
|
|
424
|
+
#[test]
|
|
425
|
+
fn policy_load_save_defaults_round_trip() {
|
|
426
|
+
let dir = tempdir().unwrap();
|
|
427
|
+
let store = Store::open(dir.path().join("state.db")).unwrap();
|
|
428
|
+
let mut policy = store.load_policy().unwrap();
|
|
429
|
+
assert_eq!(policy.max_active_jobs, 1);
|
|
430
|
+
policy.set_key("max_active_jobs", "3").unwrap();
|
|
431
|
+
store.save_policy(&policy).unwrap();
|
|
432
|
+
assert_eq!(store.load_policy().unwrap().max_active_jobs, 3);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
use chrono::{DateTime, Utc};
|
|
2
|
+
use serde::{Deserialize, Serialize};
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
use std::{fmt, str::FromStr};
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
|
7
|
+
#[serde(rename_all = "snake_case")]
|
|
8
|
+
pub enum Priority {
|
|
9
|
+
Low,
|
|
10
|
+
#[default]
|
|
11
|
+
Normal,
|
|
12
|
+
High,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
impl Priority {
|
|
16
|
+
pub fn scheduler_weight(self) -> i64 {
|
|
17
|
+
match self {
|
|
18
|
+
Priority::Low => 0,
|
|
19
|
+
Priority::Normal => 10,
|
|
20
|
+
Priority::High => 20,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl fmt::Display for Priority {
|
|
26
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
27
|
+
f.write_str(match self {
|
|
28
|
+
Priority::Low => "low",
|
|
29
|
+
Priority::Normal => "normal",
|
|
30
|
+
Priority::High => "high",
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl FromStr for Priority {
|
|
36
|
+
type Err = String;
|
|
37
|
+
|
|
38
|
+
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
39
|
+
match value {
|
|
40
|
+
"low" => Ok(Self::Low),
|
|
41
|
+
"normal" => Ok(Self::Normal),
|
|
42
|
+
"high" => Ok(Self::High),
|
|
43
|
+
other => Err(format!("unknown priority {other:?}")),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
49
|
+
#[serde(rename_all = "snake_case")]
|
|
50
|
+
pub enum PressureState {
|
|
51
|
+
Normal,
|
|
52
|
+
Soft,
|
|
53
|
+
Critical,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
impl fmt::Display for PressureState {
|
|
57
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
58
|
+
f.write_str(match self {
|
|
59
|
+
PressureState::Normal => "normal",
|
|
60
|
+
PressureState::Soft => "soft",
|
|
61
|
+
PressureState::Critical => "critical",
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
impl FromStr for PressureState {
|
|
67
|
+
type Err = String;
|
|
68
|
+
|
|
69
|
+
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
70
|
+
match value.trim().to_ascii_lowercase().as_str() {
|
|
71
|
+
"normal" | "green" => Ok(Self::Normal),
|
|
72
|
+
"soft" | "warning" | "yellow" => Ok(Self::Soft),
|
|
73
|
+
"critical" | "red" => Ok(Self::Critical),
|
|
74
|
+
other => Err(format!("unknown pressure state {other:?}")),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
80
|
+
#[serde(rename_all = "snake_case")]
|
|
81
|
+
pub enum JobState {
|
|
82
|
+
PendingAdmission,
|
|
83
|
+
Queued,
|
|
84
|
+
Starting,
|
|
85
|
+
Running,
|
|
86
|
+
PausedByGuard,
|
|
87
|
+
Exiting,
|
|
88
|
+
Exited,
|
|
89
|
+
Failed,
|
|
90
|
+
Lost,
|
|
91
|
+
Cancelled,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
impl JobState {
|
|
95
|
+
pub fn is_terminal(self) -> bool {
|
|
96
|
+
matches!(
|
|
97
|
+
self,
|
|
98
|
+
JobState::Exited | JobState::Failed | JobState::Lost | JobState::Cancelled
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pub fn counts_as_active(self) -> bool {
|
|
103
|
+
matches!(
|
|
104
|
+
self,
|
|
105
|
+
JobState::Starting | JobState::Running | JobState::PausedByGuard | JobState::Exiting
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
impl fmt::Display for JobState {
|
|
111
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
112
|
+
f.write_str(match self {
|
|
113
|
+
JobState::PendingAdmission => "pending_admission",
|
|
114
|
+
JobState::Queued => "queued",
|
|
115
|
+
JobState::Starting => "starting",
|
|
116
|
+
JobState::Running => "running",
|
|
117
|
+
JobState::PausedByGuard => "paused_by_guard",
|
|
118
|
+
JobState::Exiting => "exiting",
|
|
119
|
+
JobState::Exited => "exited",
|
|
120
|
+
JobState::Failed => "failed",
|
|
121
|
+
JobState::Lost => "lost",
|
|
122
|
+
JobState::Cancelled => "cancelled",
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
impl FromStr for JobState {
|
|
128
|
+
type Err = String;
|
|
129
|
+
|
|
130
|
+
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
131
|
+
match value {
|
|
132
|
+
"pending_admission" => Ok(Self::PendingAdmission),
|
|
133
|
+
"queued" => Ok(Self::Queued),
|
|
134
|
+
"starting" => Ok(Self::Starting),
|
|
135
|
+
"running" => Ok(Self::Running),
|
|
136
|
+
"paused_by_guard" | "paused" => Ok(Self::PausedByGuard),
|
|
137
|
+
"exiting" => Ok(Self::Exiting),
|
|
138
|
+
"exited" | "completed" => Ok(Self::Exited),
|
|
139
|
+
"failed" => Ok(Self::Failed),
|
|
140
|
+
"lost" => Ok(Self::Lost),
|
|
141
|
+
"cancelled" | "canceled" => Ok(Self::Cancelled),
|
|
142
|
+
other => Err(format!("unknown job state {other:?}")),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
148
|
+
pub struct Policy {
|
|
149
|
+
pub max_active_jobs: u32,
|
|
150
|
+
pub soft_memory_pressure_percent: u8,
|
|
151
|
+
pub critical_memory_pressure_percent: u8,
|
|
152
|
+
pub recovery_memory_pressure_percent: u8,
|
|
153
|
+
pub default_codex_max_threads: u32,
|
|
154
|
+
pub pause_enabled: bool,
|
|
155
|
+
pub queue_timeout_seconds: u64,
|
|
156
|
+
pub recovery_window_seconds: u64,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
160
|
+
pub struct JobRecord {
|
|
161
|
+
pub id: String,
|
|
162
|
+
pub adapter: String,
|
|
163
|
+
pub command: String,
|
|
164
|
+
pub args: Vec<String>,
|
|
165
|
+
pub cwd: String,
|
|
166
|
+
pub status: JobState,
|
|
167
|
+
pub priority: Priority,
|
|
168
|
+
pub root_pid: Option<i64>,
|
|
169
|
+
pub pgid: Option<i64>,
|
|
170
|
+
pub queued_reason: Option<String>,
|
|
171
|
+
pub started_at: Option<DateTime<Utc>>,
|
|
172
|
+
pub ended_at: Option<DateTime<Utc>>,
|
|
173
|
+
pub exit_code: Option<i32>,
|
|
174
|
+
pub policy_snapshot_json: String,
|
|
175
|
+
pub created_at: DateTime<Utc>,
|
|
176
|
+
pub updated_at: DateTime<Utc>,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
impl JobRecord {
|
|
180
|
+
pub fn new_admission(
|
|
181
|
+
id: String,
|
|
182
|
+
adapter: String,
|
|
183
|
+
command: String,
|
|
184
|
+
args: Vec<String>,
|
|
185
|
+
cwd: String,
|
|
186
|
+
priority: Priority,
|
|
187
|
+
policy: &Policy,
|
|
188
|
+
) -> anyhow::Result<Self> {
|
|
189
|
+
let now = Utc::now();
|
|
190
|
+
Ok(Self {
|
|
191
|
+
id,
|
|
192
|
+
adapter,
|
|
193
|
+
command,
|
|
194
|
+
args,
|
|
195
|
+
cwd,
|
|
196
|
+
status: JobState::PendingAdmission,
|
|
197
|
+
priority,
|
|
198
|
+
root_pid: None,
|
|
199
|
+
pgid: None,
|
|
200
|
+
queued_reason: None,
|
|
201
|
+
started_at: None,
|
|
202
|
+
ended_at: None,
|
|
203
|
+
exit_code: None,
|
|
204
|
+
policy_snapshot_json: serde_json::to_string(policy)?,
|
|
205
|
+
created_at: now,
|
|
206
|
+
updated_at: now,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
212
|
+
pub struct EventRecord {
|
|
213
|
+
pub id: i64,
|
|
214
|
+
pub job_id: Option<String>,
|
|
215
|
+
pub ts: DateTime<Utc>,
|
|
216
|
+
pub event_type: String,
|
|
217
|
+
pub payload_json: Value,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
221
|
+
pub struct ToolPath {
|
|
222
|
+
pub tool: String,
|
|
223
|
+
pub real_path: String,
|
|
224
|
+
pub discovered_at: DateTime<Utc>,
|
|
225
|
+
pub hash: Option<String>,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
229
|
+
pub struct ProcessSample {
|
|
230
|
+
pub job_id: String,
|
|
231
|
+
pub sampled_at: DateTime<Utc>,
|
|
232
|
+
pub pid_count: u32,
|
|
233
|
+
pub rss_bytes: u64,
|
|
234
|
+
pub footprint_bytes: u64,
|
|
235
|
+
pub cpu_pct: f64,
|
|
236
|
+
pub pressure_state: PressureState,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
240
|
+
pub struct StatusSnapshot {
|
|
241
|
+
pub pressure: PressureState,
|
|
242
|
+
pub policy: Policy,
|
|
243
|
+
pub jobs: Vec<JobRecord>,
|
|
244
|
+
pub events: Vec<EventRecord>,
|
|
245
|
+
pub tool_paths: Vec<ToolPath>,
|
|
246
|
+
pub socket_path: String,
|
|
247
|
+
pub db_path: String,
|
|
248
|
+
}
|