dsh-ops 0.0.0-stage → 0.2.2

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 (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,1300 @@
1
+ // Modified by dsh-ops (https://github.com/T-Auto/dsh-ops): removed the incremental tail reader (LogDelta/read_log_delta/read_legacy_spool_delta) that only the deleted TUI job viewer used.
2
+ //! Directory-backed job registry, direct/legacy log reads, atomic records, and terminal reaping.
3
+
4
+ use super::JobRegistryError;
5
+ use super::identity::{identity_is_alive, process_identity};
6
+ use super::model::{
7
+ CAPTURE_ERROR_FILE, CaptureErrorRecord, EXIT_FILE, ExitRecord, JOB_SCHEMA_VERSION, JobMeta,
8
+ JobRecord, JobStatus, KILL_REQUEST_FILE, META_FILE, OUTPUT_TRUNCATION_FILE, OriginSnapshot,
9
+ OutputTruncationRecord, SpoolLine, StoredLine,
10
+ };
11
+ use super::output_log::{BoundedLines, OutputLogReader};
12
+ use crate::control::paths::ControlPaths;
13
+ use crate::control::settings::{DEFAULT_JOB_STORAGE_LIMIT_MIB, DEFAULT_MAX_RUNNING_JOBS};
14
+ use crate::edit::private_storage::ensure_private_directory;
15
+ use crate::paths::display_path;
16
+ use serde::Serialize;
17
+ use serde::de::DeserializeOwned;
18
+ use std::collections::hash_map::RandomState;
19
+ use std::fs::{self, File, OpenOptions};
20
+ use std::hash::{BuildHasher, Hash, Hasher};
21
+ use std::io::{BufRead, BufReader, Write};
22
+ use std::path::{Path, PathBuf};
23
+ use std::sync::atomic::{AtomicU64, Ordering};
24
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
+ use time::OffsetDateTime;
26
+ use time::format_description::well_known::Rfc3339;
27
+
28
+ const PENDING_STALE_AFTER: Duration = Duration::from_secs(60);
29
+ const JOB_ID_SPACE: u64 = 36_u64.pow(6);
30
+ static JOB_ID_SEQUENCE: AtomicU64 = AtomicU64::new(1);
31
+
32
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
33
+ pub(crate) struct JobLimits {
34
+ pub(crate) storage_limit_mib: u64,
35
+ pub(crate) max_running_jobs: u64,
36
+ }
37
+
38
+ impl Default for JobLimits {
39
+ fn default() -> Self {
40
+ Self {
41
+ storage_limit_mib: DEFAULT_JOB_STORAGE_LIMIT_MIB,
42
+ max_running_jobs: DEFAULT_MAX_RUNNING_JOBS,
43
+ }
44
+ }
45
+ }
46
+
47
+ #[derive(Clone, Debug, Default)]
48
+ pub(crate) struct RegistrySnapshot {
49
+ pub(crate) records: Vec<JobRecord>,
50
+ pub(crate) pending_reservations: u64,
51
+ }
52
+
53
+ #[derive(Debug)]
54
+ enum LogSource {
55
+ Direct(OutputLogReader),
56
+ Legacy {
57
+ lines: Vec<StoredLine>,
58
+ oldest_seq: u64,
59
+ total_lines: u64,
60
+ had_loss: bool,
61
+ },
62
+ }
63
+
64
+ /// Unified view returned above the one schema-based format dispatch point.
65
+ /// `direct_path` is the sole capability bit needed by delivery and TUI layers.
66
+ #[derive(Debug)]
67
+ pub(crate) struct LogView {
68
+ source: LogSource,
69
+ pub(crate) capture_error: Option<CaptureErrorRecord>,
70
+ pub(crate) output_truncation: Option<OutputTruncationRecord>,
71
+ }
72
+
73
+ #[derive(Debug)]
74
+ pub(crate) struct LogLines {
75
+ pub(crate) lines: Vec<StoredLine>,
76
+ pub(crate) complete: bool,
77
+ }
78
+
79
+ impl LogView {
80
+ pub(crate) fn direct_path(&self) -> Option<&Path> {
81
+ match &self.source {
82
+ LogSource::Direct(reader) => Some(reader.path()),
83
+ LogSource::Legacy { .. } => None,
84
+ }
85
+ }
86
+
87
+ pub(crate) fn total_lines(&self) -> u64 {
88
+ match &self.source {
89
+ LogSource::Direct(reader) => reader.total_lines(),
90
+ LogSource::Legacy { total_lines, .. } => *total_lines,
91
+ }
92
+ }
93
+
94
+ pub(crate) fn oldest_seq(&self) -> u64 {
95
+ match &self.source {
96
+ LogSource::Direct(_) => 1,
97
+ LogSource::Legacy { oldest_seq, .. } => *oldest_seq,
98
+ }
99
+ }
100
+
101
+ pub(crate) fn had_irretrievable_loss(&self) -> bool {
102
+ match &self.source {
103
+ LogSource::Direct(_) => false,
104
+ LogSource::Legacy { had_loss, .. } => *had_loss,
105
+ }
106
+ }
107
+
108
+ pub(crate) fn read_prefix_bounded(
109
+ &mut self,
110
+ first: u64,
111
+ last: u64,
112
+ max_lines: usize,
113
+ max_bytes: usize,
114
+ ) -> Result<LogLines, String> {
115
+ match &mut self.source {
116
+ LogSource::Direct(reader) => {
117
+ let BoundedLines { lines, complete } =
118
+ reader.read_prefix_bounded(first, last, max_lines, max_bytes)?;
119
+ Ok(LogLines { lines, complete })
120
+ }
121
+ LogSource::Legacy { lines, .. } => {
122
+ let mut selected = Vec::new();
123
+ let mut stored_bytes = 0_usize;
124
+ let mut exhausted = true;
125
+ for line in lines
126
+ .iter()
127
+ .filter(|line| (first..=last).contains(&line.seq))
128
+ {
129
+ let would_exceed = !selected.is_empty()
130
+ && stored_bytes.saturating_add(line.bytes.len()) > max_bytes;
131
+ if selected.len() >= max_lines || would_exceed {
132
+ exhausted = false;
133
+ break;
134
+ }
135
+ stored_bytes = stored_bytes.saturating_add(line.bytes.len());
136
+ selected.push(line.clone());
137
+ }
138
+ Ok(LogLines {
139
+ lines: selected,
140
+ complete: exhausted,
141
+ })
142
+ }
143
+ }
144
+ }
145
+
146
+ pub(crate) fn read_suffix_bounded(
147
+ &mut self,
148
+ first: u64,
149
+ last: u64,
150
+ max_lines: usize,
151
+ max_bytes: usize,
152
+ ) -> Result<LogLines, String> {
153
+ match &mut self.source {
154
+ LogSource::Direct(reader) => {
155
+ let BoundedLines { lines, complete } =
156
+ reader.read_suffix_bounded(first, last, max_lines, max_bytes)?;
157
+ Ok(LogLines { lines, complete })
158
+ }
159
+ LogSource::Legacy { lines, .. } => {
160
+ let mut selected = Vec::new();
161
+ let mut stored_bytes = 0_usize;
162
+ let mut exhausted = true;
163
+ for line in lines
164
+ .iter()
165
+ .rev()
166
+ .filter(|line| (first..=last).contains(&line.seq))
167
+ {
168
+ let would_exceed = !selected.is_empty()
169
+ && stored_bytes.saturating_add(line.bytes.len()) > max_bytes;
170
+ if selected.len() >= max_lines || would_exceed {
171
+ exhausted = false;
172
+ break;
173
+ }
174
+ stored_bytes = stored_bytes.saturating_add(line.bytes.len());
175
+ selected.push(line.clone());
176
+ }
177
+ selected.reverse();
178
+ Ok(LogLines {
179
+ lines: selected,
180
+ complete: exhausted,
181
+ })
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ pub(crate) fn effective_limits(paths: &ControlPaths) -> Result<JobLimits, String> {
188
+ let settings = crate::control::settings::load(paths)?;
189
+ Ok(JobLimits {
190
+ storage_limit_mib: positive_or_default(
191
+ settings.fastshell.job_storage_limit_mib,
192
+ DEFAULT_JOB_STORAGE_LIMIT_MIB,
193
+ ),
194
+ max_running_jobs: positive_or_default(
195
+ settings.fastshell.max_running_jobs,
196
+ DEFAULT_MAX_RUNNING_JOBS,
197
+ ),
198
+ })
199
+ }
200
+
201
+ fn positive_or_default(value: u64, fallback: u64) -> u64 {
202
+ if value == 0 { fallback } else { value }
203
+ }
204
+
205
+ pub(crate) fn utc_now() -> String {
206
+ OffsetDateTime::now_utc()
207
+ .replace_nanosecond(0)
208
+ .unwrap_or_else(|_| OffsetDateTime::now_utc())
209
+ .format(&Rfc3339)
210
+ .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
211
+ }
212
+
213
+ pub(crate) fn unix_nanos_now() -> u64 {
214
+ SystemTime::now()
215
+ .duration_since(UNIX_EPOCH)
216
+ .ok()
217
+ .and_then(|duration| u64::try_from(duration.as_nanos()).ok())
218
+ .unwrap_or(u64::MAX)
219
+ }
220
+
221
+ pub(crate) fn origin_snapshot(server_cwd: &Path) -> OriginSnapshot {
222
+ let server_pid = std::process::id();
223
+ let (parent_pid, parent_executable) = parent_process();
224
+ OriginSnapshot {
225
+ server_pid,
226
+ server_started: process_identity(server_pid).map(|identity| identity.started),
227
+ parent_pid,
228
+ parent_executable,
229
+ server_cwd: display_path(server_cwd),
230
+ }
231
+ }
232
+
233
+ #[cfg(target_os = "linux")]
234
+ fn parent_process() -> (Option<u32>, Option<String>) {
235
+ let parent = unsafe { libc::getppid() };
236
+ let parent_pid = u32::try_from(parent).ok();
237
+ let executable = std::fs::read_link(format!("/proc/{parent}/exe"))
238
+ .ok()
239
+ .map(|path| display_path(&path));
240
+ (parent_pid, executable)
241
+ }
242
+
243
+ #[cfg(all(unix, not(target_os = "linux")))]
244
+ fn parent_process() -> (Option<u32>, Option<String>) {
245
+ let parent = unsafe { libc::getppid() };
246
+ let output = std::process::Command::new("ps")
247
+ .args(["-p", &parent.to_string(), "-o", "comm="])
248
+ .output()
249
+ .ok();
250
+ let executable = output
251
+ .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
252
+ .filter(|executable| !executable.is_empty());
253
+ (u32::try_from(parent).ok(), executable)
254
+ }
255
+
256
+ #[cfg(windows)]
257
+ fn parent_process() -> (Option<u32>, Option<String>) {
258
+ use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
259
+ use windows_sys::Win32::System::Diagnostics::ToolHelp::{
260
+ CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
261
+ TH32CS_SNAPPROCESS,
262
+ };
263
+
264
+ // SAFETY: the returned snapshot handle is closed on every path below.
265
+ let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
266
+ if snapshot == INVALID_HANDLE_VALUE {
267
+ return (None, None);
268
+ }
269
+ let mut entry = PROCESSENTRY32W {
270
+ dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
271
+ ..Default::default()
272
+ };
273
+ let mut rows = Vec::new();
274
+ // SAFETY: entry has the documented size and remains writable for the enumeration.
275
+ let mut has_entry = unsafe { Process32FirstW(snapshot, &mut entry) };
276
+ while has_entry != 0 {
277
+ let end = entry
278
+ .szExeFile
279
+ .iter()
280
+ .position(|character| *character == 0)
281
+ .unwrap_or(entry.szExeFile.len());
282
+ rows.push((
283
+ entry.th32ProcessID,
284
+ entry.th32ParentProcessID,
285
+ String::from_utf16_lossy(&entry.szExeFile[..end]),
286
+ ));
287
+ // SAFETY: snapshot and entry remain valid for the next enumeration step.
288
+ has_entry = unsafe { Process32NextW(snapshot, &mut entry) };
289
+ }
290
+ // SAFETY: this function owns the snapshot handle.
291
+ unsafe {
292
+ CloseHandle(snapshot);
293
+ }
294
+ let parent_pid = rows
295
+ .iter()
296
+ .find(|(pid, _, _)| *pid == std::process::id())
297
+ .map(|(_, parent, _)| *parent);
298
+ let executable = parent_pid.and_then(|parent| {
299
+ rows.into_iter()
300
+ .find(|(pid, _, _)| *pid == parent)
301
+ .map(|(_, _, executable)| executable)
302
+ .filter(|executable| !executable.is_empty())
303
+ });
304
+ (parent_pid, executable)
305
+ }
306
+
307
+ #[cfg(not(any(unix, windows)))]
308
+ fn parent_process() -> (Option<u32>, Option<String>) {
309
+ (None, None)
310
+ }
311
+
312
+ pub(crate) fn ensure_root(root: &Path) -> Result<(), String> {
313
+ ensure_private_directory(root, "background job registry")
314
+ }
315
+
316
+ pub(crate) fn reserve_job(root: &Path) -> Result<(String, PathBuf), String> {
317
+ ensure_root(root)?;
318
+ for _ in 0..256 {
319
+ let id = generate_job_id();
320
+ let directory = root.join(&id);
321
+ match fs::create_dir(&directory) {
322
+ Ok(()) => {
323
+ ensure_private_directory(&directory, "background job")?;
324
+ return Ok((id, directory));
325
+ }
326
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
327
+ Err(error) => {
328
+ return Err(format!(
329
+ "Cannot reserve a background job directory {}: {error}",
330
+ display_path(&directory)
331
+ ));
332
+ }
333
+ }
334
+ }
335
+ Err(
336
+ "Cannot allocate a unique background job id after 256 attempts. Retry the command."
337
+ .to_string(),
338
+ )
339
+ }
340
+
341
+ pub(crate) fn remove_reserved_job(directory: &Path) {
342
+ match fs::remove_dir_all(directory) {
343
+ Ok(()) => {}
344
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
345
+ Err(_) => {}
346
+ }
347
+ }
348
+
349
+ pub(crate) fn write_atomic_json(path: &Path, value: &impl Serialize) -> Result<(), String> {
350
+ let bytes = serde_json::to_vec(value)
351
+ .map_err(|error| format!("Cannot encode {}: {error}", display_path(path)))?;
352
+ let parent = path.parent().ok_or_else(|| {
353
+ format!(
354
+ "Cannot write {} because it has no parent directory.",
355
+ display_path(path)
356
+ )
357
+ })?;
358
+ let mut temp = None;
359
+ for attempt in 0..64_u64 {
360
+ let candidate = parent.join(format!(
361
+ ".{}.{}.{}.tmp",
362
+ path.file_name().unwrap_or_default().to_string_lossy(),
363
+ std::process::id(),
364
+ attempt
365
+ ));
366
+ let mut options = OpenOptions::new();
367
+ options.create_new(true).write(true);
368
+ #[cfg(unix)]
369
+ {
370
+ use std::os::unix::fs::OpenOptionsExt;
371
+ options.mode(0o600);
372
+ }
373
+ match options.open(&candidate) {
374
+ Ok(mut file) => {
375
+ if let Err(error) = file.write_all(&bytes).and_then(|()| file.write_all(b"\n")) {
376
+ drop(file);
377
+ let _ = fs::remove_file(&candidate);
378
+ return Err(format!(
379
+ "Cannot write temporary job record {}: {error}",
380
+ display_path(&candidate)
381
+ ));
382
+ }
383
+ drop(file);
384
+ temp = Some(candidate);
385
+ break;
386
+ }
387
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
388
+ Err(error) => {
389
+ return Err(format!(
390
+ "Cannot create temporary job record {}: {error}",
391
+ display_path(&candidate)
392
+ ));
393
+ }
394
+ }
395
+ }
396
+ let temp = temp.ok_or_else(|| {
397
+ format!(
398
+ "Cannot allocate a temporary file for job record {}.",
399
+ display_path(path)
400
+ )
401
+ })?;
402
+ publish_new(&temp, path).map_err(|error| {
403
+ let _ = fs::remove_file(&temp);
404
+ if error.kind() == std::io::ErrorKind::AlreadyExists {
405
+ format!(
406
+ "Cannot replace immutable job record {} because it already exists.",
407
+ display_path(path)
408
+ )
409
+ } else {
410
+ format!(
411
+ "Cannot publish immutable job record {}: {error}",
412
+ display_path(path)
413
+ )
414
+ }
415
+ })?;
416
+ Ok(())
417
+ }
418
+
419
+ #[cfg(unix)]
420
+ fn publish_new(temporary: &Path, target: &Path) -> std::io::Result<()> {
421
+ fs::hard_link(temporary, target)?;
422
+ let _ = fs::remove_file(temporary);
423
+ Ok(())
424
+ }
425
+
426
+ #[cfg(windows)]
427
+ fn publish_new(temporary: &Path, target: &Path) -> std::io::Result<()> {
428
+ use std::os::windows::ffi::OsStrExt;
429
+ use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW};
430
+
431
+ fn wide(path: &Path) -> Vec<u16> {
432
+ path.as_os_str().encode_wide().chain(Some(0)).collect()
433
+ }
434
+
435
+ let moved = unsafe {
436
+ MoveFileExW(
437
+ wide(temporary).as_ptr(),
438
+ wide(target).as_ptr(),
439
+ MOVEFILE_WRITE_THROUGH,
440
+ )
441
+ };
442
+ if moved == 0 {
443
+ let error = std::io::Error::last_os_error();
444
+ if matches!(error.raw_os_error(), Some(80 | 183)) {
445
+ Err(std::io::Error::new(
446
+ std::io::ErrorKind::AlreadyExists,
447
+ error,
448
+ ))
449
+ } else {
450
+ Err(error)
451
+ }
452
+ } else {
453
+ Ok(())
454
+ }
455
+ }
456
+
457
+ #[cfg(not(any(unix, windows)))]
458
+ fn publish_new(temporary: &Path, target: &Path) -> std::io::Result<()> {
459
+ fs::hard_link(temporary, target)?;
460
+ let _ = fs::remove_file(temporary);
461
+ Ok(())
462
+ }
463
+
464
+ pub(crate) fn read_json<T: DeserializeOwned>(
465
+ path: &Path,
466
+ label: &str,
467
+ ) -> Result<Option<T>, JobRegistryError> {
468
+ let bytes = match fs::read(path) {
469
+ Ok(bytes) => bytes,
470
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
471
+ Err(error) => {
472
+ return Err(JobRegistryError::from_io(
473
+ format!("Cannot read {label} {}", display_path(path)),
474
+ error,
475
+ ));
476
+ }
477
+ };
478
+ serde_json::from_slice(&bytes).map(Some).map_err(|error| {
479
+ JobRegistryError::data(format!(
480
+ "Cannot parse {label} {}: {error}. Remove the damaged finished record or restart its running job.",
481
+ display_path(path)
482
+ ))
483
+ })
484
+ }
485
+
486
+ pub(crate) fn scan_registry(root: &Path) -> Result<RegistrySnapshot, JobRegistryError> {
487
+ let entries = match fs::read_dir(root) {
488
+ Ok(entries) => entries,
489
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
490
+ return Ok(RegistrySnapshot::default());
491
+ }
492
+ Err(error) => {
493
+ return Err(JobRegistryError::from_io(
494
+ format!(
495
+ "Cannot list the background job registry {}",
496
+ display_path(root)
497
+ ),
498
+ error,
499
+ ));
500
+ }
501
+ };
502
+ let now = SystemTime::now();
503
+ let mut snapshot = RegistrySnapshot::default();
504
+ for entry in entries {
505
+ let entry = entry.map_err(|error| {
506
+ JobRegistryError::from_io(
507
+ format!(
508
+ "Cannot inspect an entry in the background job registry {}",
509
+ display_path(root)
510
+ ),
511
+ error,
512
+ )
513
+ })?;
514
+ let id = entry.file_name().to_string_lossy().to_string();
515
+ if !valid_job_id(&id) {
516
+ continue;
517
+ }
518
+ let metadata = match fs::symlink_metadata(entry.path()) {
519
+ Ok(metadata) => metadata,
520
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
521
+ Err(error) => {
522
+ return Err(JobRegistryError::from_io(
523
+ format!(
524
+ "Cannot inspect background job {id} at {}",
525
+ display_path(&entry.path())
526
+ ),
527
+ error,
528
+ ));
529
+ }
530
+ };
531
+ if !metadata.is_dir() || metadata.file_type().is_symlink() {
532
+ return Err(JobRegistryError::data(format!(
533
+ "Cannot use background job path {} because it is not a private directory. Remove it and retry.",
534
+ display_path(&entry.path())
535
+ )));
536
+ }
537
+ let directory = entry.path();
538
+ let Some(meta) = read_json::<JobMeta>(&directory.join(META_FILE), "job metadata")? else {
539
+ let age = metadata
540
+ .modified()
541
+ .ok()
542
+ .and_then(|modified| now.duration_since(modified).ok())
543
+ .unwrap_or_default();
544
+ if age >= PENDING_STALE_AFTER {
545
+ remove_reserved_job(&directory);
546
+ } else {
547
+ snapshot.pending_reservations = snapshot.pending_reservations.saturating_add(1);
548
+ }
549
+ continue;
550
+ };
551
+ validate_schema(&id, meta.schema_version)?;
552
+ let exit = read_json::<ExitRecord>(&directory.join(EXIT_FILE), "job exit record")?;
553
+ let status = if let Some(exit) = exit {
554
+ JobStatus::Exited(exit)
555
+ } else if identity_is_alive(&meta.supervisor) {
556
+ JobStatus::Running
557
+ } else {
558
+ JobStatus::Interrupted
559
+ };
560
+ let ended_sort_key = terminal_sort_key(&directory, &status);
561
+ if !directory.exists() {
562
+ continue;
563
+ }
564
+ snapshot.records.push(JobRecord {
565
+ id,
566
+ directory,
567
+ meta,
568
+ status,
569
+ ended_sort_key,
570
+ });
571
+ }
572
+ Ok(snapshot)
573
+ }
574
+
575
+ fn terminal_sort_key(directory: &Path, status: &JobStatus) -> SystemTime {
576
+ if let JobStatus::Exited(exit) = status
577
+ && exit.ended_at_unix_nanos > 0
578
+ {
579
+ return UNIX_EPOCH + Duration::from_nanos(exit.ended_at_unix_nanos);
580
+ }
581
+ let preferred = match status {
582
+ JobStatus::Exited(_) => directory.join(EXIT_FILE),
583
+ JobStatus::Interrupted => directory.to_path_buf(),
584
+ JobStatus::Running => directory.join(META_FILE),
585
+ };
586
+ fs::metadata(preferred)
587
+ .and_then(|metadata| metadata.modified())
588
+ .unwrap_or(UNIX_EPOCH)
589
+ }
590
+
591
+ pub(super) fn started_sort_key(record: &JobRecord) -> (u64, &str) {
592
+ (record.meta.started_at_unix_nanos, record.id.as_str())
593
+ }
594
+
595
+ pub(crate) fn find_record(root: &Path, job_id: &str) -> Result<Option<JobRecord>, String> {
596
+ if !valid_job_id(job_id) {
597
+ return Ok(None);
598
+ }
599
+ let directory = root.join(job_id);
600
+ let metadata = match fs::symlink_metadata(&directory) {
601
+ Ok(metadata) => metadata,
602
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
603
+ Err(error) => {
604
+ return Err(JobRegistryError::from_io(
605
+ format!(
606
+ "Cannot inspect background job {job_id} at {}",
607
+ display_path(&directory)
608
+ ),
609
+ error,
610
+ )
611
+ .into());
612
+ }
613
+ };
614
+ if !metadata.is_dir() || metadata.file_type().is_symlink() {
615
+ return Err(format!(
616
+ "Cannot use background job path {} because it is not a private directory. Remove it and retry.",
617
+ display_path(&directory)
618
+ ));
619
+ }
620
+ let Some(meta) = read_json::<JobMeta>(&directory.join(META_FILE), "job metadata")? else {
621
+ return Ok(None);
622
+ };
623
+ validate_schema(job_id, meta.schema_version)?;
624
+ let exit = read_json::<ExitRecord>(&directory.join(EXIT_FILE), "job exit record")?;
625
+ let status = if let Some(exit) = exit {
626
+ JobStatus::Exited(exit)
627
+ } else if identity_is_alive(&meta.supervisor) {
628
+ JobStatus::Running
629
+ } else {
630
+ JobStatus::Interrupted
631
+ };
632
+ let ended_sort_key = terminal_sort_key(&directory, &status);
633
+ if !directory.exists() {
634
+ return Ok(None);
635
+ }
636
+ Ok(Some(JobRecord {
637
+ id: job_id.to_string(),
638
+ directory,
639
+ meta,
640
+ status,
641
+ ended_sort_key,
642
+ }))
643
+ }
644
+
645
+ fn validate_schema(job_id: &str, schema_version: u32) -> Result<(), JobRegistryError> {
646
+ if (1..=JOB_SCHEMA_VERSION).contains(&schema_version) {
647
+ Ok(())
648
+ } else {
649
+ Err(JobRegistryError::data(format!(
650
+ "Cannot read job {job_id}: metadata schema {schema_version} is unsupported by this FastCtx (expected 1 through {JOB_SCHEMA_VERSION}). Upgrade FastCtx or remove the finished record."
651
+ )))
652
+ }
653
+ }
654
+
655
+ #[derive(Clone, Debug, Default)]
656
+ struct LegacySnapshot {
657
+ lines: Vec<StoredLine>,
658
+ oldest_seq: u64,
659
+ total_lines: u64,
660
+ had_loss: bool,
661
+ }
662
+
663
+ /// Opens one job's output through the only schema-version dispatch in the
664
+ /// read path. Callers receive capabilities, never format versions.
665
+ pub(crate) fn open_log(record: &JobRecord) -> Result<LogView, String> {
666
+ let capture_error = capture_error(record)?;
667
+ let output_truncation = output_truncation(record)?;
668
+ if record.meta.schema_version >= 3 {
669
+ let include_unindexed_tail =
670
+ !record.status.is_running() || capture_error.is_some() || output_truncation.is_some();
671
+ return Ok(LogView {
672
+ source: LogSource::Direct(OutputLogReader::open(
673
+ &record.directory,
674
+ include_unindexed_tail,
675
+ )?),
676
+ capture_error,
677
+ output_truncation,
678
+ });
679
+ }
680
+ let legacy = read_legacy_spool(record)?;
681
+ Ok(LogView {
682
+ source: LogSource::Legacy {
683
+ lines: legacy.lines,
684
+ oldest_seq: legacy.oldest_seq,
685
+ total_lines: legacy.total_lines,
686
+ had_loss: legacy.had_loss,
687
+ },
688
+ capture_error,
689
+ output_truncation,
690
+ })
691
+ }
692
+
693
+ fn read_legacy_spool(record: &JobRecord) -> Result<LegacySnapshot, String> {
694
+ let mut segments = segment_paths(&record.directory)?;
695
+ segments.sort();
696
+ let mut lines = Vec::new();
697
+ let mut total_lines = 0_u64;
698
+ let mut had_loss = false;
699
+ for path in segments {
700
+ let file = match File::open(&path) {
701
+ Ok(file) => file,
702
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
703
+ Err(error) => {
704
+ return Err(format!(
705
+ "Cannot read job {} output segment {}: {error}",
706
+ record.id,
707
+ display_path(&path)
708
+ ));
709
+ }
710
+ };
711
+ let mut reader = BufReader::new(file);
712
+ let mut source = Vec::new();
713
+ loop {
714
+ source.clear();
715
+ let bytes = reader.read_until(b'\n', &mut source).map_err(|error| {
716
+ format!(
717
+ "Cannot read job {} output segment {}: {error}",
718
+ record.id,
719
+ display_path(&path)
720
+ )
721
+ })?;
722
+ if bytes == 0 {
723
+ break;
724
+ }
725
+ if !source.ends_with(b"\n") {
726
+ if !record.status.is_running() {
727
+ had_loss = true;
728
+ }
729
+ break;
730
+ }
731
+ let line = match serde_json::from_slice::<SpoolLine>(trim_record_ending(&source)) {
732
+ Ok(line) => line,
733
+ Err(error) => {
734
+ return Err(format!(
735
+ "Cannot parse job {} output segment {}: {error}. The record is damaged.",
736
+ record.id,
737
+ display_path(&path)
738
+ ));
739
+ }
740
+ };
741
+ total_lines = total_lines.max(line.seq);
742
+ had_loss |= line.had_loss || line.truncated;
743
+ lines.push(StoredLine::from(line));
744
+ }
745
+ }
746
+ lines.sort_by_key(|line| line.seq);
747
+ lines.dedup_by_key(|line| line.seq);
748
+ let oldest_seq = lines
749
+ .first()
750
+ .map(|line| line.seq)
751
+ .unwrap_or_else(|| match &record.status {
752
+ JobStatus::Exited(exit) => exit.total_lines.saturating_add(1),
753
+ _ => total_lines.saturating_add(1),
754
+ });
755
+ if oldest_seq > 1 {
756
+ had_loss = true;
757
+ }
758
+ if let JobStatus::Exited(exit) = &record.status {
759
+ total_lines = total_lines.max(exit.total_lines);
760
+ had_loss |= exit.had_loss;
761
+ }
762
+ Ok(LegacySnapshot {
763
+ lines,
764
+ oldest_seq,
765
+ total_lines,
766
+ had_loss,
767
+ })
768
+ }
769
+
770
+ pub(crate) fn capture_error(record: &JobRecord) -> Result<Option<CaptureErrorRecord>, String> {
771
+ let mut capture_error = read_json(
772
+ &record.directory.join(CAPTURE_ERROR_FILE),
773
+ "job capture-error record",
774
+ )?;
775
+ if capture_error.is_none()
776
+ && let JobStatus::Exited(exit) = &record.status
777
+ {
778
+ capture_error.clone_from(&exit.capture_error);
779
+ }
780
+ Ok(capture_error)
781
+ }
782
+
783
+ pub(crate) fn output_truncation(
784
+ record: &JobRecord,
785
+ ) -> Result<Option<OutputTruncationRecord>, String> {
786
+ let mut truncation = read_json(
787
+ &record.directory.join(OUTPUT_TRUNCATION_FILE),
788
+ "job output-truncation record",
789
+ )?;
790
+ if truncation.is_none()
791
+ && let JobStatus::Exited(exit) = &record.status
792
+ {
793
+ truncation.clone_from(&exit.output_truncation);
794
+ }
795
+ Ok(truncation)
796
+ }
797
+
798
+ fn trim_record_ending(record: &[u8]) -> &[u8] {
799
+ let record = record.strip_suffix(b"\n").unwrap_or(record);
800
+ record.strip_suffix(b"\r").unwrap_or(record)
801
+ }
802
+
803
+ pub(crate) fn segment_paths(directory: &Path) -> Result<Vec<PathBuf>, String> {
804
+ let entries = fs::read_dir(directory).map_err(|error| {
805
+ format!(
806
+ "Cannot list job output segments in {}: {error}",
807
+ display_path(directory)
808
+ )
809
+ })?;
810
+ let mut paths = Vec::new();
811
+ for entry in entries {
812
+ let entry = entry.map_err(|error| {
813
+ format!(
814
+ "Cannot inspect a job output segment in {}: {error}",
815
+ display_path(directory)
816
+ )
817
+ })?;
818
+ let name = entry.file_name();
819
+ let name = name.to_string_lossy();
820
+ if name.starts_with("segment-") && name.ends_with(".jsonl") {
821
+ paths.push(entry.path());
822
+ }
823
+ }
824
+ Ok(paths)
825
+ }
826
+
827
+ pub(crate) fn request_kill(record: &JobRecord) -> Result<(), String> {
828
+ let path = record.directory.join(KILL_REQUEST_FILE);
829
+ match OpenOptions::new().write(true).create_new(true).open(&path) {
830
+ Ok(_) => Ok(()),
831
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
832
+ Err(error) => Err(format!(
833
+ "Cannot request termination of job {}: {error}. Retry job_kill.",
834
+ record.id
835
+ )),
836
+ }
837
+ }
838
+
839
+ pub(crate) fn kill_requested(directory: &Path) -> bool {
840
+ directory.join(KILL_REQUEST_FILE).is_file()
841
+ }
842
+
843
+ pub(crate) fn clear_kill_request(directory: &Path) {
844
+ match fs::remove_file(directory.join(KILL_REQUEST_FILE)) {
845
+ Ok(()) => {}
846
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
847
+ Err(_) => {}
848
+ }
849
+ }
850
+
851
+ pub(crate) fn reap(paths: &ControlPaths, limit_mib: u64) -> Result<u64, String> {
852
+ let root = &paths.jobs_dir;
853
+ let entries = match fs::read_dir(root) {
854
+ Ok(entries) => entries,
855
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
856
+ Err(error) => {
857
+ return Err(format!(
858
+ "Cannot inspect job storage {}: {error}",
859
+ display_path(root)
860
+ ));
861
+ }
862
+ };
863
+ let mut total = 0_u64;
864
+ for entry in entries {
865
+ let entry = entry.map_err(|error| {
866
+ format!("Cannot inspect job storage {}: {error}", display_path(root))
867
+ })?;
868
+ total = total.saturating_add(path_size(&entry.path())?);
869
+ }
870
+ let limit = limit_mib.saturating_mul(1024 * 1024);
871
+ if total <= limit {
872
+ return Ok(0);
873
+ }
874
+ let target = limit.saturating_mul(9) / 10;
875
+ let mut terminal = scan_registry(root)?
876
+ .records
877
+ .into_iter()
878
+ .filter(|record| !record.status.is_running())
879
+ .collect::<Vec<_>>();
880
+ terminal.sort_by_key(|record| record.ended_sort_key);
881
+ let mut removed = 0_u64;
882
+ for record in terminal {
883
+ if total <= target {
884
+ break;
885
+ }
886
+ let bytes = path_size(&record.directory).unwrap_or(0);
887
+ match fs::remove_dir_all(&record.directory) {
888
+ Ok(()) => {
889
+ total = total.saturating_sub(bytes);
890
+ removed = removed.saturating_add(1);
891
+ }
892
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
893
+ total = total.saturating_sub(bytes);
894
+ }
895
+ Err(error) => {
896
+ return Err(format!(
897
+ "Cannot evict finished job {} from {}: {error}",
898
+ record.id,
899
+ display_path(&record.directory)
900
+ ));
901
+ }
902
+ }
903
+ }
904
+ Ok(removed)
905
+ }
906
+
907
+ fn path_size(path: &Path) -> Result<u64, String> {
908
+ let metadata = fs::symlink_metadata(path)
909
+ .map_err(|error| format!("Cannot inspect job storage {}: {error}", display_path(path)))?;
910
+ if metadata.file_type().is_symlink() {
911
+ return Ok(0);
912
+ }
913
+ if metadata.is_file() {
914
+ return Ok(metadata.len());
915
+ }
916
+ if !metadata.is_dir() {
917
+ return Ok(0);
918
+ }
919
+ let mut total = 0_u64;
920
+ for entry in fs::read_dir(path)
921
+ .map_err(|error| format!("Cannot inspect job storage {}: {error}", display_path(path)))?
922
+ {
923
+ let entry = entry.map_err(|error| {
924
+ format!("Cannot inspect job storage {}: {error}", display_path(path))
925
+ })?;
926
+ total = total.saturating_add(path_size(&entry.path())?);
927
+ }
928
+ Ok(total)
929
+ }
930
+
931
+ pub(crate) fn valid_job_id(id: &str) -> bool {
932
+ id.len() == 8
933
+ && id.starts_with("j-")
934
+ && id[2..]
935
+ .bytes()
936
+ .all(|byte| byte.is_ascii_digit() || byte.is_ascii_lowercase())
937
+ }
938
+
939
+ fn generate_job_id() -> String {
940
+ let sequence = JOB_ID_SEQUENCE.fetch_add(1, Ordering::Relaxed);
941
+ let mut hasher = RandomState::new().build_hasher();
942
+ sequence.hash(&mut hasher);
943
+ std::process::id().hash(&mut hasher);
944
+ SystemTime::now()
945
+ .duration_since(UNIX_EPOCH)
946
+ .unwrap_or_default()
947
+ .as_nanos()
948
+ .hash(&mut hasher);
949
+ format!("j-{}", base36(hasher.finish() % JOB_ID_SPACE))
950
+ }
951
+
952
+ fn base36(mut value: u64) -> String {
953
+ let mut bytes = [b'0'; 6];
954
+ for index in (0..bytes.len()).rev() {
955
+ let digit = (value % 36) as u8;
956
+ bytes[index] = if digit < 10 {
957
+ b'0' + digit
958
+ } else {
959
+ b'a' + digit - 10
960
+ };
961
+ value /= 36;
962
+ }
963
+ String::from_utf8(bytes.to_vec()).expect("base36 output is ASCII")
964
+ }
965
+
966
+ #[cfg(test)]
967
+ mod tests {
968
+ use super::{open_log, reap, scan_registry, valid_job_id, write_atomic_json};
969
+ use crate::control::paths::ControlPaths;
970
+ use crate::control::settings::{FastCtxSettings, save};
971
+ use crate::shell::jobs::identity::process_identity;
972
+ use crate::shell::jobs::model::{
973
+ CaptureErrorRecord, EXIT_FILE, ExitRecord, JOB_SCHEMA_VERSION, JobMeta, META_FILE,
974
+ OriginSnapshot, ProcessIdentity, SpoolLine, TerminationKind,
975
+ };
976
+ use filetime::{FileTime, set_file_mtime};
977
+ use std::path::{Path, PathBuf};
978
+
979
+ fn metadata(supervisor: ProcessIdentity, started_at_unix_nanos: u64) -> JobMeta {
980
+ JobMeta {
981
+ schema_version: JOB_SCHEMA_VERSION,
982
+ command: "printf test".to_string(),
983
+ cwd: "/fixture".to_string(),
984
+ login_shell: false,
985
+ encoding: None,
986
+ supervisor,
987
+ origin: OriginSnapshot {
988
+ server_pid: 7,
989
+ server_started: Some("server-token".to_string()),
990
+ parent_pid: Some(6),
991
+ parent_executable: Some("codex".to_string()),
992
+ server_cwd: "/fixture".to_string(),
993
+ },
994
+ started_at: "2026-07-16T10:00:00Z".to_string(),
995
+ started_at_unix_nanos,
996
+ isolation_warning: None,
997
+ }
998
+ }
999
+
1000
+ fn terminal_job(
1001
+ root: &Path,
1002
+ id: &str,
1003
+ ended_at_unix_nanos: u64,
1004
+ payload_bytes: usize,
1005
+ ) -> PathBuf {
1006
+ terminal_job_with_schema(
1007
+ root,
1008
+ id,
1009
+ ended_at_unix_nanos,
1010
+ payload_bytes,
1011
+ JOB_SCHEMA_VERSION,
1012
+ )
1013
+ }
1014
+
1015
+ fn terminal_job_with_schema(
1016
+ root: &Path,
1017
+ id: &str,
1018
+ ended_at_unix_nanos: u64,
1019
+ payload_bytes: usize,
1020
+ schema_version: u32,
1021
+ ) -> PathBuf {
1022
+ let directory = root.join(id);
1023
+ std::fs::create_dir_all(&directory).unwrap();
1024
+ let supervisor = process_identity(std::process::id()).unwrap();
1025
+ let mut meta = metadata(supervisor, 1);
1026
+ meta.schema_version = schema_version;
1027
+ write_atomic_json(&directory.join(META_FILE), &meta).unwrap();
1028
+ let exit = ExitRecord {
1029
+ exit_code: 0,
1030
+ total_lines: 0,
1031
+ had_loss: false,
1032
+ ended_at: "2026-07-16T10:00:01Z".to_string(),
1033
+ ended_at_unix_nanos,
1034
+ termination: TerminationKind::Exited,
1035
+ capture_error: None,
1036
+ output_truncation: None,
1037
+ };
1038
+ write_atomic_json(&directory.join(EXIT_FILE), &exit).unwrap();
1039
+ std::fs::write(directory.join("payload.bin"), vec![b'x'; payload_bytes]).unwrap();
1040
+ directory
1041
+ }
1042
+
1043
+ #[test]
1044
+ fn immutable_records_are_published_without_a_clobber_window() {
1045
+ let temp = tempfile::tempdir().unwrap();
1046
+ let path = temp.path().join("meta.json");
1047
+ write_atomic_json(&path, &serde_json::json!({"value": 1})).unwrap();
1048
+ let error = write_atomic_json(&path, &serde_json::json!({"value": 2})).unwrap_err();
1049
+
1050
+ assert!(error.contains("immutable job record"), "{error}");
1051
+ assert_eq!(
1052
+ serde_json::from_slice::<serde_json::Value>(&std::fs::read(path).unwrap()).unwrap(),
1053
+ serde_json::json!({"value": 1})
1054
+ );
1055
+ }
1056
+
1057
+ #[test]
1058
+ fn pid_creation_token_mismatch_is_interrupted_not_running() {
1059
+ let temp = tempfile::tempdir().unwrap();
1060
+ let root = temp.path().join("jobs");
1061
+ let directory = root.join("j-000001");
1062
+ std::fs::create_dir_all(&directory).unwrap();
1063
+ write_atomic_json(
1064
+ &directory.join(META_FILE),
1065
+ &metadata(
1066
+ ProcessIdentity {
1067
+ pid: std::process::id(),
1068
+ started: "definitely-not-this-process".to_string(),
1069
+ },
1070
+ 1,
1071
+ ),
1072
+ )
1073
+ .unwrap();
1074
+
1075
+ let snapshot = scan_registry(&root).unwrap();
1076
+ assert_eq!(snapshot.records.len(), 1);
1077
+ assert!(matches!(
1078
+ snapshot.records[0].status,
1079
+ crate::shell::jobs::model::JobStatus::Interrupted
1080
+ ));
1081
+ }
1082
+
1083
+ #[test]
1084
+ fn registry_counts_fresh_reservations_and_reaps_crash_stale_ones() {
1085
+ let temp = tempfile::tempdir().unwrap();
1086
+ let root = temp.path().join("jobs");
1087
+ let fresh = root.join("j-000001");
1088
+ let stale = root.join("j-000002");
1089
+ std::fs::create_dir_all(&fresh).unwrap();
1090
+ std::fs::create_dir_all(&stale).unwrap();
1091
+ set_file_mtime(&stale, FileTime::from_unix_time(1, 0)).unwrap();
1092
+
1093
+ let snapshot = scan_registry(&root).unwrap();
1094
+
1095
+ assert_eq!(snapshot.pending_reservations, 1);
1096
+ assert!(fresh.exists());
1097
+ assert!(!stale.exists());
1098
+ }
1099
+
1100
+ #[test]
1101
+ fn reaper_uses_terminal_end_order_and_stops_at_the_ninety_percent_low_water() {
1102
+ let temp = tempfile::tempdir().unwrap();
1103
+ let paths = ControlPaths::for_home(temp.path());
1104
+ std::fs::create_dir_all(&paths.jobs_dir).unwrap();
1105
+ let oldest = terminal_job(&paths.jobs_dir, "j-000001", 1, 430 * 1024);
1106
+ let middle = terminal_job(&paths.jobs_dir, "j-000002", 2, 430 * 1024);
1107
+ let newest = terminal_job(&paths.jobs_dir, "j-000003", 3, 430 * 1024);
1108
+
1109
+ assert_eq!(reap(&paths, 1).unwrap(), 1);
1110
+ assert!(!oldest.exists());
1111
+ assert!(middle.exists());
1112
+ assert!(newest.exists());
1113
+ }
1114
+
1115
+ #[test]
1116
+ fn saving_a_smaller_current_user_limit_immediately_reaps_finished_records() {
1117
+ let temp = tempfile::tempdir().unwrap();
1118
+ let paths = ControlPaths::for_home(temp.path());
1119
+ std::fs::create_dir_all(&paths.jobs_dir).unwrap();
1120
+ let oldest = terminal_job(&paths.jobs_dir, "j-000001", 1, 430 * 1024);
1121
+ let middle = terminal_job(&paths.jobs_dir, "j-000002", 2, 430 * 1024);
1122
+ let newest = terminal_job(&paths.jobs_dir, "j-000003", 3, 430 * 1024);
1123
+ let running = paths.jobs_dir.join("j-000004");
1124
+ std::fs::create_dir_all(&running).unwrap();
1125
+ write_atomic_json(
1126
+ &running.join(META_FILE),
1127
+ &metadata(process_identity(std::process::id()).unwrap(), 4),
1128
+ )
1129
+ .unwrap();
1130
+
1131
+ let mut settings = FastCtxSettings::default();
1132
+ settings.fastshell.job_storage_limit_mib = 1;
1133
+ assert!(save(&paths, &settings).unwrap());
1134
+
1135
+ assert!(!oldest.exists());
1136
+ assert!(middle.exists());
1137
+ assert!(newest.exists());
1138
+ assert!(running.exists());
1139
+ assert_eq!(
1140
+ crate::control::settings::load(&paths)
1141
+ .unwrap()
1142
+ .fastshell
1143
+ .job_storage_limit_mib,
1144
+ 1
1145
+ );
1146
+ }
1147
+
1148
+ #[test]
1149
+ fn reaper_never_deletes_a_running_record_even_when_it_alone_exceeds_the_limit() {
1150
+ let temp = tempfile::tempdir().unwrap();
1151
+ let paths = ControlPaths::for_home(temp.path());
1152
+ let running = paths.jobs_dir.join("j-000001");
1153
+ std::fs::create_dir_all(&running).unwrap();
1154
+ write_atomic_json(
1155
+ &running.join(META_FILE),
1156
+ &metadata(process_identity(std::process::id()).unwrap(), 1),
1157
+ )
1158
+ .unwrap();
1159
+ std::fs::write(running.join("payload.bin"), vec![b'x'; 2 * 1024 * 1024]).unwrap();
1160
+ let finished = terminal_job(&paths.jobs_dir, "j-000002", 2, 512 * 1024);
1161
+
1162
+ assert_eq!(reap(&paths, 1).unwrap(), 1);
1163
+ assert!(running.exists());
1164
+ assert!(!finished.exists());
1165
+ assert!(matches!(
1166
+ scan_registry(&paths.jobs_dir).unwrap().records[0].status,
1167
+ crate::shell::jobs::model::JobStatus::Running
1168
+ ));
1169
+ }
1170
+
1171
+ #[test]
1172
+ fn partial_tail_records_are_ignored_and_exit_fallback_preserves_capture_failure() {
1173
+ let temp = tempfile::tempdir().unwrap();
1174
+ let root = temp.path().join("jobs");
1175
+ let directory = terminal_job_with_schema(&root, "j-000001", 1, 0, 2);
1176
+ std::fs::remove_file(directory.join(EXIT_FILE)).unwrap();
1177
+ let capture_error = CaptureErrorRecord {
1178
+ after_seq: 1,
1179
+ reason: "disk became unavailable".to_string(),
1180
+ };
1181
+ let exit = ExitRecord {
1182
+ exit_code: 17,
1183
+ total_lines: 2,
1184
+ had_loss: true,
1185
+ ended_at: "2026-07-16T10:00:01Z".to_string(),
1186
+ ended_at_unix_nanos: 1,
1187
+ termination: TerminationKind::Exited,
1188
+ capture_error: Some(capture_error.clone()),
1189
+ output_truncation: None,
1190
+ };
1191
+ write_atomic_json(&directory.join(EXIT_FILE), &exit).unwrap();
1192
+ let complete = serde_json::to_string(&SpoolLine {
1193
+ seq: 1,
1194
+ text: "kept".to_string(),
1195
+ raw_bytes: None,
1196
+ total_bytes: 0,
1197
+ stream_encoding: None,
1198
+ truncated: false,
1199
+ had_loss: false,
1200
+ })
1201
+ .unwrap();
1202
+ let partial = serde_json::to_string(&SpoolLine {
1203
+ seq: 2,
1204
+ text: "partial".to_string(),
1205
+ raw_bytes: None,
1206
+ total_bytes: 0,
1207
+ stream_encoding: None,
1208
+ truncated: false,
1209
+ had_loss: false,
1210
+ })
1211
+ .unwrap();
1212
+ std::fs::write(
1213
+ directory.join("segment-00000000000000000001.jsonl"),
1214
+ format!("{complete}\n{partial}"),
1215
+ )
1216
+ .unwrap();
1217
+
1218
+ let record = scan_registry(&root).unwrap().records.remove(0);
1219
+ let mut log = open_log(&record).unwrap();
1220
+ let lines = log
1221
+ .read_prefix_bounded(1, log.total_lines(), usize::MAX, usize::MAX)
1222
+ .unwrap()
1223
+ .lines;
1224
+ assert_eq!(
1225
+ lines
1226
+ .iter()
1227
+ .map(|line| line.legacy_text.as_deref().unwrap())
1228
+ .collect::<Vec<_>>(),
1229
+ ["kept"]
1230
+ );
1231
+ assert_eq!(log.total_lines(), 2);
1232
+ assert_eq!(log.capture_error, Some(capture_error));
1233
+ assert!(log.had_irretrievable_loss());
1234
+ assert!(log.direct_path().is_none());
1235
+ }
1236
+
1237
+ #[test]
1238
+ fn incomplete_terminal_spool_record_is_reported_as_output_loss() {
1239
+ let temp = tempfile::tempdir().unwrap();
1240
+ let root = temp.path().join("jobs");
1241
+ let directory = terminal_job_with_schema(&root, "j-000001", 1, 0, 2);
1242
+ std::fs::remove_file(directory.join(EXIT_FILE)).unwrap();
1243
+ write_atomic_json(
1244
+ &directory.join(EXIT_FILE),
1245
+ &ExitRecord {
1246
+ exit_code: 0,
1247
+ total_lines: 2,
1248
+ had_loss: false,
1249
+ ended_at: "2026-07-16T10:00:01Z".to_string(),
1250
+ ended_at_unix_nanos: 1,
1251
+ termination: TerminationKind::Exited,
1252
+ capture_error: None,
1253
+ output_truncation: None,
1254
+ },
1255
+ )
1256
+ .unwrap();
1257
+ let complete = serde_json::to_string(&SpoolLine {
1258
+ seq: 1,
1259
+ text: "kept".to_string(),
1260
+ raw_bytes: None,
1261
+ total_bytes: 0,
1262
+ stream_encoding: None,
1263
+ truncated: false,
1264
+ had_loss: false,
1265
+ })
1266
+ .unwrap();
1267
+ let mut segment = format!("{complete}\n{{\"seq\":2,\"text\":\"cut-off-").into_bytes();
1268
+ segment.extend_from_slice(&"界".as_bytes()[..2]);
1269
+ std::fs::write(
1270
+ directory.join("segment-00000000000000000001.jsonl"),
1271
+ segment,
1272
+ )
1273
+ .unwrap();
1274
+
1275
+ let record = scan_registry(&root).unwrap().records.remove(0);
1276
+ let mut log = open_log(&record).unwrap();
1277
+ let lines = log
1278
+ .read_prefix_bounded(1, log.total_lines(), usize::MAX, usize::MAX)
1279
+ .unwrap()
1280
+ .lines;
1281
+
1282
+ assert_eq!(
1283
+ lines
1284
+ .iter()
1285
+ .map(|line| (line.seq, line.legacy_text.as_deref().unwrap()))
1286
+ .collect::<Vec<_>>(),
1287
+ [(1, "kept")]
1288
+ );
1289
+ assert_eq!(log.total_lines(), 2);
1290
+ assert!(log.had_irretrievable_loss());
1291
+ }
1292
+
1293
+ #[test]
1294
+ fn job_ids_are_exactly_six_lowercase_base36_digits() {
1295
+ assert!(valid_job_id("j-09azzz"));
1296
+ for invalid in ["j-09AZZZ", "j-12345", "j-1234567", "x-123456", "j-12_456"] {
1297
+ assert!(!valid_job_id(invalid), "{invalid}");
1298
+ }
1299
+ }
1300
+ }