1use std::env::{self, VarError};
2use std::fs;
3use std::panic::{AssertUnwindSafe, catch_unwind};
4use std::path::{Component, Path, PathBuf};
5use std::sync::mpsc::RecvTimeoutError;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use atelier_sdk_remote::RemoteFolder;
9use sha2::{Digest, Sha256};
10
11use atelier_sdk_diff::{
12 Address, Delta, DeltaKind, Diff, Fidelity, FormatPackage, PackageId, as_text, detect_package,
13 diff_lines,
14};
15use atelier_sdk_docx::DocxPackage;
16use notify::{Event, RecursiveMode, Watcher};
17
18use crate::config::{
19 Actor, InstructionFidelity, ROOT_MOUNT, Source, SourceKind, SyncPolicy, WorkspaceConfig,
20 read_workspace_config, resolve_actor, write_workspace_config,
21};
22use crate::coordination::{Coordination, LeaseClaim, RequestRow, SessionRow};
23use crate::engine::{
24 DiffSides, Engine, FileBlob, LADDER_FILE_SIZE_MAX, LandOutcome, Side, StepBack,
25};
26use crate::error::{Error, config_err, engine_err};
27use crate::journal::{Act, Journal, JournalEntry};
28use crate::landing::{
29 Approval, GateOutcome, Landing, LandingRequest, RequestId, RequestState, Restore,
30};
31use crate::projection::ProjectionCache;
32use crate::read::{ReadResult, window_size, window_text};
33use crate::session::{Instruction, Session, SessionId, SessionState, SourceChange};
34use crate::watch::{
35 STOP_TICK, WatchEvent, WatchStop, event_is_content, settle, watcher_failed, watcher_gone,
36};
37
38pub use crate::engine::Snapshot;
39
40const CONTROL_DIR: &str = ".atelier";
41const JOURNAL_FILE: &str = "journal.sqlite3";
42const SESSIONS_DIR: &str = "sessions";
43pub(crate) const SKIP_NAMES: [&str; 3] = [".atelier", ".jj", ".git"];
44
45const LANDING_LEASE_POINT: &str = "landing";
47const LANDED_BOOKMARK: &str = "atelier";
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum SyncOutcome {
53 Synced {
55 snapshot: String,
57 },
58 Parked {
61 snapshot: String,
63 },
64}
65
66fn unreachable_remote<T>() -> Result<T, Error> {
70 Err(Error::Engine(
71 "sync_source lost its remote handle; this is a bug".to_owned(),
72 ))
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum PullOutcome {
78 Pulled {
80 snapshot: String,
82 },
83 Current,
85}
86
87enum SyncTarget {
90 Folder(PathBuf),
91 Remote(String),
92}
93
94const LANDING_LEASE_TTL_MS: i64 = 30_000;
97
98pub struct Workspace {
102 root: PathBuf,
103 actor: Actor,
104 engine: Engine,
105 mounts: Vec<MountedSource>,
108 journal: Journal,
109 coordination: Coordination,
110 packages: Vec<Box<dyn FormatPackage>>,
111 projections: ProjectionCache,
112}
113
114struct MountedSource {
116 name: String,
117 engine: Engine,
118 branch: Option<String>,
121}
122
123impl Workspace {
124 pub fn init(path: impl AsRef<Path>) -> Result<Self, Error> {
126 let root = path.as_ref().to_path_buf();
127 let actor = resolve_actor()?;
128
129 let control = root.join(CONTROL_DIR);
130 if control.exists() {
131 return Err(Error::WorkspaceExists(root));
132 }
133 if let Some(ancestor) = enclosing_workspace(&root) {
134 return Err(Error::NestedWorkspace(ancestor));
135 }
136
137 fs::create_dir_all(&control)?;
138 let engine = Engine::init(&root, &actor, &[])?;
139
140 let config = WorkspaceConfig::new(workspace_name(&root));
141 write_workspace_config(&control, &config)?;
142
143 let journal = Journal::open(&control.join(JOURNAL_FILE))?;
144 let coordination = Coordination::open(&control.join(JOURNAL_FILE))?;
145 let mounts = Vec::new();
146 let workspace = Self {
147 root,
148 actor,
149 engine,
150 mounts,
151 journal,
152 coordination,
153 packages: builtin_packages(),
154 projections: ProjectionCache::new(&control),
155 };
156 let entry = workspace.entry(Act::WorkspaceInit, None)?;
157 workspace.journal.append(&entry)?;
158 Ok(workspace)
159 }
160
161 pub fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
163 let root = path.as_ref().to_path_buf();
164 let actor = resolve_actor()?;
165
166 let control = root.join(CONTROL_DIR);
167 if !control.exists() {
168 return Err(Error::NotAWorkspace(root));
169 }
170
171 let config = read_workspace_config(&control)?;
172 let mount_names = mount_names(&config);
173 let engine = Engine::open(&root, &actor, &mount_names)?;
174 let mut mounts = Vec::new();
175 for name in &mount_names {
176 let branch = config
177 .sources
178 .iter()
179 .find(|source| source.mount == *name)
180 .and_then(|source| source.branch.clone());
181 mounts.push(MountedSource {
182 name: name.clone(),
183 engine: Engine::open(&root.join(name), &actor, &[])?,
184 branch,
185 });
186 }
187 let journal = Journal::open(&control.join(JOURNAL_FILE))?;
188 let coordination = Coordination::open(&control.join(JOURNAL_FILE))?;
189 Ok(Self {
190 root,
191 actor,
192 engine,
193 mounts,
194 journal,
195 coordination,
196 packages: builtin_packages(),
197 projections: ProjectionCache::new(&control),
198 })
199 }
200
201 #[must_use]
203 pub fn actor(&self) -> &Actor {
204 &self.actor
205 }
206
207 pub fn attach(&mut self, folder: impl AsRef<Path>) -> Result<Source, Error> {
211 let folder = folder.as_ref();
212 if !folder.is_dir() {
213 return Err(Error::Io(std::io::Error::new(
214 std::io::ErrorKind::NotFound,
215 format!("source folder not found: {}", folder.display()),
216 )));
217 }
218
219 let control = self.root.join(CONTROL_DIR);
220 let mut config = read_workspace_config(&control)?;
221 if config.sources.iter().any(|s| s.mount == ROOT_MOUNT) {
222 return Err(Error::AlreadyAttached);
223 }
224 if folder_uses_lfs(folder)? {
225 return Err(Error::LfsSourceUnsupported);
226 }
227
228 self.auto_snapshot()?;
229
230 copy_tree(folder, &self.root, &SKIP_NAMES)?;
231 let source = Source {
232 kind: SourceKind::LocalFolder,
233 path: folder.to_path_buf(),
234 sync: SyncPolicy::TwoWay,
235 mount: ROOT_MOUNT.to_owned(),
236 branch: None,
237 };
238 config.sources.push(source.clone());
239 write_workspace_config(&control, &config)?;
240
241 let snapshot = self.engine.snapshot()?;
242 let fingerprint = folder_fingerprint(folder)?;
245 self.coordination
246 .record_sync_state(ROOT_MOUNT, &fingerprint, &self.engine.head()?)?;
247 let entry = self.entry(Act::SourceAttach, snapshot)?;
248 self.journal.append(&entry)?;
249 Ok(source)
250 }
251
252 pub fn attach_mount(&mut self, folder: impl AsRef<Path>, name: &str) -> Result<Source, Error> {
255 let folder = folder.as_ref();
256 if !folder.is_dir() {
257 return Err(Error::Io(std::io::Error::new(
258 std::io::ErrorKind::NotFound,
259 format!("source folder not found: {}", folder.display()),
260 )));
261 }
262 let control = self.root.join(CONTROL_DIR);
263 let mut config = read_workspace_config(&control)?;
264 let mount_dir = self.root.join(name);
265 mount_refusals(name, &config, &mount_dir)?;
266 if folder_uses_lfs(folder)? {
267 return Err(Error::LfsSourceUnsupported);
268 }
269
270 self.auto_snapshot()?;
272
273 let (kind, engine, snapshot) = match self.import_folder(folder, &mount_dir) {
274 Ok(imported) => imported,
275 Err(error) => {
276 let _ = fs::remove_dir_all(&mount_dir);
280 return Err(error);
281 }
282 };
283 let branch = match kind {
287 SourceKind::LocalGit => adopted_branch(folder)?,
288 SourceKind::LocalFolder | SourceKind::Remote => None,
291 };
292
293 let source = Source {
294 kind,
295 path: folder.to_path_buf(),
296 sync: SyncPolicy::TwoWay,
297 mount: name.to_owned(),
298 branch: branch.clone(),
299 };
300 config.sources.push(source.clone());
301 write_workspace_config(&control, &config)?;
302
303 let mount_names = mount_names(&config);
306 self.engine = Engine::open(&self.root, &self.actor, &mount_names)?;
307 let position = self
308 .mounts
309 .binary_search_by(|mount| mount.name.as_str().cmp(name))
310 .unwrap_or_else(|position| position);
311 self.mounts.insert(
312 position,
313 MountedSource {
314 name: name.to_owned(),
315 engine,
316 branch,
317 },
318 );
319
320 match kind {
321 SourceKind::LocalFolder => {
324 let fingerprint = folder_fingerprint(folder)?;
325 let head = self.mounts[position].engine.head()?;
326 self.coordination
327 .record_sync_state(name, &fingerprint, &head)?;
328 }
329 SourceKind::LocalGit | SourceKind::Remote => {}
332 }
333
334 let reference = snapshot.map(|id| format!("{name} {id}"));
335 let entry = self.entry(Act::SourceAttach, reference)?;
336 self.journal.append(&entry)?;
337 Ok(source)
338 }
339
340 fn import_folder(
345 &self,
346 folder: &Path,
347 mount_dir: &Path,
348 ) -> Result<(SourceKind, Engine, Option<String>), Error> {
349 fs::create_dir_all(mount_dir)?;
350 let adopts_git = folder.join(".git").is_dir();
351 let (kind, mut engine) = if adopts_git {
352 copy_tree(folder, mount_dir, &[".atelier", ".jj"])?;
353 (
354 SourceKind::LocalGit,
355 Engine::adopt_git(mount_dir, &self.actor, &[])?,
356 )
357 } else {
358 let engine = Engine::init(mount_dir, &self.actor, &[])?;
359 copy_tree(folder, mount_dir, &SKIP_NAMES)?;
360 (SourceKind::LocalFolder, engine)
361 };
362 let snapshot = engine.snapshot()?;
363 Ok((kind, engine, snapshot))
364 }
365
366 fn import_remote(
368 &self,
369 remote: &RemoteFolder,
370 mount_dir: &Path,
371 ) -> Result<(Engine, Option<String>), Error> {
372 fs::create_dir_all(mount_dir)?;
373 let mut engine = Engine::init(mount_dir, &self.actor, &[])?;
374 remote.download_all(mount_dir).map_err(engine_err)?;
375 let snapshot = engine.snapshot()?;
376 Ok((engine, snapshot))
377 }
378
379 pub fn attach_remote(&mut self, url: &str, name: &str) -> Result<Source, Error> {
383 let control = self.root.join(CONTROL_DIR);
384 let mut config = read_workspace_config(&control)?;
385 let mount_dir = self.root.join(name);
386 mount_refusals(name, &config, &mount_dir)?;
387
388 self.auto_snapshot()?;
390
391 let remote = RemoteFolder::open(url).map_err(engine_err)?;
392 let (engine, snapshot) = match self.import_remote(&remote, &mount_dir) {
393 Ok(imported) => imported,
394 Err(error) => {
395 let _ = fs::remove_dir_all(&mount_dir);
399 return Err(error);
400 }
401 };
402 let fingerprint = remote.fingerprint().map_err(engine_err)?;
405 let head = engine.head()?;
406 self.coordination
407 .record_sync_state(name, &fingerprint, &head)?;
408
409 let source = Source {
410 kind: SourceKind::Remote,
411 path: PathBuf::from(url),
412 sync: SyncPolicy::TwoWay,
413 mount: name.to_owned(),
414 branch: None,
415 };
416 config.sources.push(source.clone());
417 write_workspace_config(&control, &config)?;
418
419 let mount_names = mount_names(&config);
422 self.engine = Engine::open(&self.root, &self.actor, &mount_names)?;
423 let position = self
424 .mounts
425 .binary_search_by(|mount| mount.name.as_str().cmp(name))
426 .unwrap_or_else(|position| position);
427 self.mounts.insert(
428 position,
429 MountedSource {
430 name: name.to_owned(),
431 engine,
432 branch: None,
433 },
434 );
435
436 let reference = snapshot.map(|id| format!("{name} {id}"));
437 let entry = self.entry(Act::SourceAttach, reference)?;
438 self.journal.append(&entry)?;
439 Ok(source)
440 }
441
442 pub fn log(&mut self, limit: usize) -> Result<Vec<SourceSnapshot>, Error> {
445 self.refresh_engines()?;
446 self.auto_snapshot()?;
447 let mut entries = Vec::new();
448 for snapshot in self.engine.log(limit)? {
449 entries.push(SourceSnapshot {
450 source: None,
451 snapshot,
452 });
453 }
454 for mount in &self.mounts {
455 for snapshot in mount.engine.log(limit)? {
456 entries.push(SourceSnapshot {
457 source: Some(mount.name.clone()),
458 snapshot,
459 });
460 }
461 }
462 Ok(entries)
463 }
464
465 pub fn diff_latest(&mut self) -> Result<Diff, Error> {
469 self.refresh_engines()?;
470 self.auto_snapshot()?;
471 let (diff, sides) = self.engine.diff_latest()?;
472 let mut deltas = self.raised(&self.engine, diff, &sides, None)?.deltas;
473 for mount in &self.mounts {
474 let (mount_diff, sides) = mount.engine.diff_latest()?;
475 let raised = self.raised(&mount.engine, mount_diff, &sides, Some(&mount.name))?;
476 deltas.extend(raised.deltas);
477 }
478 Ok(Diff { deltas })
479 }
480
481 pub fn manifest(&mut self) -> Result<String, Error> {
485 self.refresh_engines()?;
486 self.auto_snapshot()?;
487 let config = read_workspace_config(&self.root.join(CONTROL_DIR))?;
488 let mut lines = vec![
489 format!("workspace: {}", config.workspace.name),
490 format!("schema: {}", config.schema),
491 String::new(),
492 "sources:".to_owned(),
493 ];
494 if config.sources.is_empty() {
495 lines.push(" (none)".to_owned());
496 }
497 for source in &config.sources {
498 lines.push(format!(
499 " {} {} {} {}",
500 source.mount,
501 source.kind,
502 source.path.display(),
503 source.sync
504 ));
505 }
506 lines.push(String::new());
507 lines.push("discipline:".to_owned());
508 let landing = config.landing;
509 let self_approve = if landing.allow_self_approve {
510 "allowed"
511 } else {
512 "forbidden"
513 };
514 let dismiss = if landing.dismiss_approvals_on_new_snapshots {
515 "yes"
516 } else {
517 "no"
518 };
519 lines.push(format!(
520 " approvals: {} self-approval: {self_approve} snapshots dismiss approvals: {dismiss}",
521 landing.approvals
522 ));
523 let fidelity = match config.journal.instruction_fidelity {
524 InstructionFidelity::Summary => "summary",
525 InstructionFidelity::Verbatim => "verbatim",
526 };
527 lines.push(format!(" instructions: {fidelity}"));
528 lines.push(String::new());
529 lines.push("state:".to_owned());
530 for line in self.state_lines()? {
531 lines.push(format!(" {line}"));
532 }
533 lines.push(String::new());
534 lines.push("the loop:".to_owned());
535 lines
536 .push(" open_session -> write -> diff -> land (or request_land + approve)".to_owned());
537 lines.push(
538 " mount-scoped paths address sources; editing never takes the landing lease"
539 .to_owned(),
540 );
541 Ok(lines.join("\n"))
542 }
543
544 pub fn status(&mut self) -> Result<String, Error> {
547 self.refresh_engines()?;
548 self.auto_snapshot()?;
549 Ok(self.state_lines()?.join("\n"))
550 }
551
552 fn state_lines(&mut self) -> Result<Vec<String>, Error> {
555 let mut lines = vec![format!("head: {}", self.engine.head()?)];
556 for mount in &self.mounts {
557 lines.push(format!("head {}: {}", mount.name, mount.engine.head()?));
558 }
559 let mut open_sessions: Vec<String> = self
560 .sessions()?
561 .into_iter()
562 .filter(|session| match session.state {
563 SessionState::Open => true,
564 SessionState::Landed | SessionState::Abandoned => false,
565 })
566 .map(|session| session.id.to_string())
567 .collect();
568 open_sessions.reverse();
569 lines.push(if open_sessions.is_empty() {
570 "open sessions: none".to_owned()
571 } else {
572 format!("open sessions: {}", open_sessions.join(", "))
573 });
574 let mut live_requests: Vec<String> = self
575 .landing_requests()?
576 .into_iter()
577 .filter(|request| match request.state {
578 RequestState::Open | RequestState::Approved | RequestState::Parked => true,
579 RequestState::Landed | RequestState::Rejected | RequestState::Abandoned => false,
580 })
581 .map(|request| format!("{} ({})", request.id, request.state))
582 .collect();
583 live_requests.reverse();
584 lines.push(if live_requests.is_empty() {
585 "live requests: none".to_owned()
586 } else {
587 format!("live requests: {}", live_requests.join(", "))
588 });
589 Ok(lines)
590 }
591
592 pub fn diff_between(&mut self, before: &str, after: &str) -> Result<Diff, Error> {
596 self.refresh_engines()?;
597 self.auto_snapshot()?;
598 let (diff, sides) = self.engine.diff_between(before, after)?;
599 self.raised(&self.engine, diff, &sides, None)
600 }
601
602 pub fn journal(&mut self, limit: usize) -> Result<Vec<JournalEntry>, Error> {
604 self.refresh_engines()?;
605 self.auto_snapshot()?;
606 self.journal.entries(limit)
607 }
608
609 pub fn open_session(
613 &mut self,
614 actor: &Actor,
615 instruction: &Instruction,
616 ) -> Result<Session, Error> {
617 self.refresh_engines()?;
618 self.auto_snapshot()?;
619 let verbatim = match self.config()?.journal.instruction_fidelity {
620 InstructionFidelity::Summary => None,
621 InstructionFidelity::Verbatim => instruction.verbatim.clone(),
622 };
623 let row = self.coordination.create_session(
624 actor,
625 &instruction.summary,
626 instruction.run_ref.as_deref(),
627 verbatim.as_deref(),
628 now_ms()?,
629 )?;
630 let id = SessionId(row);
631 let change_id = match self.engine.create_session_workspace(
632 &self.session_root(id),
633 &format!("session-{id}"),
634 actor,
635 ) {
636 Ok(change_id) => change_id,
637 Err(error) => {
638 self.coordination.delete_session(row)?;
639 return Err(error);
640 }
641 };
642 self.coordination.set_session_change(row, &change_id)?;
643 let session_root = self.session_root(id);
646 for index in 0..self.mounts.len() {
647 let name = self.mounts[index].name.clone();
648 let mount_change = match self.mounts[index].engine.create_session_workspace(
649 &session_root.join(&name),
650 &format!("session-{id}"),
651 actor,
652 ) {
653 Ok(change_id) => change_id,
654 Err(error) => {
655 self.coordination.delete_session(row)?;
656 return Err(error);
657 }
658 };
659 self.coordination
660 .set_session_source_change(row, &name, &mount_change)?;
661 }
662 self.journal.append(&JournalEntry {
663 at_ms: now_ms()?,
664 actor_name: actor.name.clone(),
665 actor_kind: actor.kind,
666 act: Act::SessionOpen,
667 session: Some(id.to_string()),
668 instruction_summary: Some(instruction.summary.clone()),
669 instruction_run_ref: instruction.run_ref.clone(),
670 instruction_verbatim: verbatim,
671 reference: None,
672 })?;
673 self.session(id)
674 }
675
676 pub fn sessions(&mut self) -> Result<Vec<Session>, Error> {
679 let rows = self.coordination.sessions()?;
680 rows.into_iter().map(|row| self.session_from(row)).collect()
681 }
682
683 pub fn session(&mut self, id: SessionId) -> Result<Session, Error> {
685 match self.coordination.session(id.0)? {
686 Some(row) => self.session_from(row),
687 None => Err(Error::SessionNotFound(id.to_string())),
688 }
689 }
690
691 pub fn session_write(
695 &mut self,
696 id: SessionId,
697 path: &str,
698 content: &str,
699 ) -> Result<String, Error> {
700 self.engine.refresh()?;
701 let session = self.open_session_only(id)?;
702 let (source, directory, inner) = self.session_target(&session, path);
703 let file = session_file(&directory, &inner)?;
704 if let Some(parent) = file.parent() {
705 fs::create_dir_all(parent)?;
706 }
707 fs::write(&file, content)?;
708 let tips = self.snapshot_session(&session)?;
709 Ok(tips.tip_of(source.as_deref()))
710 }
711
712 pub fn session_read(
717 &mut self,
718 id: SessionId,
719 path: &str,
720 start: usize,
721 max_bytes: Option<usize>,
722 ) -> Result<ReadResult, Error> {
723 let session = self.open_session_only(id)?;
724 let size = window_size(max_bytes)?;
725 let (_, directory, inner) = self.session_target(&session, path);
726 let file = session_file(&directory, &inner)?;
727 let bytes = fs::read(&file)?;
728 if let Some(package) = self.detected(path, &bytes)? {
729 let text = self.project_for_read(package, &bytes)?;
730 return Ok(window_text(&text, start, size, Some(package.id())));
731 }
732 match as_text(&bytes) {
733 Some(text) => Ok(window_text(text, start, size, None)),
734 None => Err(Error::NotText(path.to_owned())),
735 }
736 }
737
738 pub fn session_diff(&mut self, id: SessionId) -> Result<Diff, Error> {
742 self.refresh_engines()?;
743 let session = self.open_session_only(id)?;
744 let tips = self.snapshot_session(&session)?;
745 let base = self.engine.parent_of(&tips.root)?;
746 let (diff, sides) = self.engine.diff_between(&base, &tips.root)?;
747 let mut deltas = self.raised(&self.engine, diff, &sides, None)?.deltas;
748 for (name, tip) in &tips.mounts {
749 let mount = self.mount(name)?;
750 let base = mount.engine.parent_of(tip)?;
751 let (diff, sides) = mount.engine.diff_between(&base, tip)?;
752 let raised = self.raised(&mount.engine, diff, &sides, Some(name))?;
753 deltas.extend(raised.deltas);
754 }
755 Ok(Diff { deltas })
756 }
757
758 pub fn request_land(&mut self, id: SessionId) -> Result<LandingRequest, Error> {
762 self.refresh_engines()?;
763 let session = self.open_session_only(id)?;
764 self.snapshot_session(&session)?;
765 if let Some(row) = self.coordination.gated_request_for_session(id.0)? {
766 return self.request_from(row);
767 }
768 let row = self
769 .coordination
770 .create_request(id.0, &session.actor, now_ms()?)?;
771 let request_id = RequestId(row);
772 self.append_session_entry(
773 &session.actor,
774 Act::LandRequest,
775 id,
776 Some(request_id.to_string()),
777 )?;
778 self.request(request_id)
779 }
780
781 pub fn landing_requests(&mut self) -> Result<Vec<LandingRequest>, Error> {
783 let rows = self.coordination.requests()?;
784 rows.into_iter().map(|row| self.request_from(row)).collect()
785 }
786
787 pub fn request(&mut self, id: RequestId) -> Result<LandingRequest, Error> {
789 match self.coordination.request(id.0)? {
790 Some(row) => self.request_from(row),
791 None => Err(Error::RequestNotFound(id.to_string())),
792 }
793 }
794
795 pub fn approve(&mut self, id: RequestId, approver: &Actor) -> Result<GateOutcome, Error> {
799 self.refresh_engines()?;
800 let row = self.gated_request(id)?;
801 let session = self.open_session_only(SessionId(row.session_id))?;
802 let policy = self.config()?.landing;
803 let requester = Actor {
804 name: row.requester_name.clone(),
805 kind: row.requester_kind,
806 };
807 if !policy.allow_self_approve && *approver == requester {
808 return Err(Error::SelfApprovalForbidden);
809 }
810 let tips = self.snapshot_session(&session)?;
811 let tip = tips.root.clone();
815 let row = self.gated_request(id)?;
818 if let RequestState::Open = row.state {
819 self.coordination
820 .add_approval(row.id, approver, &tip, now_ms()?)?;
821 self.append_session_entry(
822 approver,
823 Act::Approve,
824 session.id,
825 Some(format!("{id} {tip}")),
826 )?;
827 }
828 let approvals = self.coordination.live_approvals(row.id)?;
829 let approvers: std::collections::BTreeSet<(&str, &str)> = approvals
830 .iter()
831 .map(|approval| (approval.actor_name.as_str(), approval.actor_kind.as_str()))
832 .collect();
833 if (approvers.len() as u64) < u64::from(policy.approvals) {
834 return Ok(GateOutcome::Pending {
835 request: self.request(id)?,
836 required: policy.approvals,
837 });
838 }
839 if !self.coordination.move_request_state(
844 row.id,
845 &[RequestState::Open],
846 RequestState::Approved,
847 )? {
848 let row = self.gated_request(id)?;
849 if let RequestState::Open = row.state {
850 return Ok(GateOutcome::Pending {
853 request: self.request(id)?,
854 required: policy.approvals,
855 });
856 }
857 }
858 self.apply(&session, id, &tips, approver)
859 }
860
861 pub fn reject(
863 &mut self,
864 id: RequestId,
865 actor: &Actor,
866 reason: Option<&str>,
867 ) -> Result<LandingRequest, Error> {
868 let row = self.gated_request(id)?;
871 while !self.coordination.move_request_state(
872 row.id,
873 &[RequestState::Open, RequestState::Approved],
874 RequestState::Rejected,
875 )? {
876 self.gated_request(id)?;
877 }
878 let reference = match reason {
879 Some(reason) => format!("{id} {reason}"),
880 None => id.to_string(),
881 };
882 self.append_session_entry(
883 actor,
884 Act::Reject,
885 SessionId(row.session_id),
886 Some(reference),
887 )?;
888 self.request(id)
889 }
890
891 pub fn land(&mut self, id: SessionId) -> Result<GateOutcome, Error> {
895 let request = self.request_land(id)?;
896 let session = self.session(id)?;
897 let policy = self.config()?.landing;
898 if !policy.allow_self_approve {
899 return Ok(GateOutcome::Pending {
900 request,
901 required: policy.approvals,
902 });
903 }
904 self.approve(request.id, &session.actor)
905 }
906
907 pub fn abandon(&mut self, id: SessionId) -> Result<Session, Error> {
910 self.engine.refresh()?;
911 let session = self.open_session_only(id)?;
912 self.snapshot_session(&session)?;
913 let mut reference = None;
914 if let Some(request) = self.coordination.gated_request_for_session(id.0)? {
915 let _ = self.coordination.move_request_state(
919 request.id,
920 &[
921 RequestState::Open,
922 RequestState::Approved,
923 RequestState::Parked,
924 ],
925 RequestState::Abandoned,
926 )?;
927 reference = Some(RequestId(request.id).to_string());
928 }
929 if !self.coordination.move_session_state(
930 id.0,
931 SessionState::Open,
932 SessionState::Abandoned,
933 )? {
934 let session = self.session(id)?;
937 return Err(Error::SessionClosed {
938 id: id.to_string(),
939 state: session.state.to_string(),
940 });
941 }
942 self.append_session_entry(&session.actor, Act::SessionAbandon, id, reference)?;
943 self.session(id)
944 }
945
946 fn auto_snapshot(&mut self) -> Result<Vec<(Option<String>, String)>, Error> {
950 let mut recorded = Vec::new();
951 if let Some(id) = self.engine.snapshot()? {
952 let entry = self.entry(Act::Snapshot, Some(id.clone()))?;
953 self.journal.append(&entry)?;
954 recorded.push((None, id));
955 }
956 for mount in &mut self.mounts {
957 if let Some(id) = mount.engine.snapshot()? {
958 recorded.push((Some(mount.name.clone()), id));
959 }
960 }
961 for (mount, id) in &recorded {
962 if let Some(mount) = mount {
963 let entry = self.entry(Act::Snapshot, Some(format!("{mount} {id}")))?;
964 self.journal.append(&entry)?;
965 }
966 }
967 Ok(recorded)
968 }
969
970 fn refresh_engines(&mut self) -> Result<(), Error> {
973 self.engine.refresh()?;
974 for mount in &mut self.mounts {
975 mount.engine.refresh()?;
976 }
977 Ok(())
978 }
979
980 pub fn watch(
986 &mut self,
987 debounce: Duration,
988 mut on_event: impl FnMut(&WatchEvent),
989 stop: &WatchStop,
990 ) -> Result<(), Error> {
991 let root = fs::canonicalize(&self.root)?;
994 let (pulses, storm) = std::sync::mpsc::channel();
995 let filter_root = root.clone();
996 let mut watcher =
997 notify::recommended_watcher(move |event: Result<Event, notify::Error>| {
998 let pulse = match event {
999 Ok(event) => {
1000 if !event_is_content(&filter_root, &event) {
1001 return;
1002 }
1003 Ok(())
1004 }
1005 Err(error) => Err(error),
1006 };
1007 let _ = pulses.send(pulse);
1010 })
1011 .map_err(|error| watcher_failed(&error))?;
1012 watcher
1013 .watch(&root, RecursiveMode::Recursive)
1014 .map_err(|error| watcher_failed(&error))?;
1015 on_event(&WatchEvent::Started);
1016 self.snapshot_watched(&mut on_event)?;
1017 while !stop.stopped() {
1018 match storm.recv_timeout(STOP_TICK) {
1019 Ok(Ok(())) => {
1020 settle(&storm, debounce, stop)?;
1021 self.snapshot_watched(&mut on_event)?;
1022 }
1023 Ok(Err(error)) => return Err(watcher_failed(&error)),
1024 Err(RecvTimeoutError::Timeout) => {}
1025 Err(RecvTimeoutError::Disconnected) => return Err(watcher_gone()),
1026 }
1027 }
1028 Ok(())
1029 }
1030
1031 fn snapshot_watched(&mut self, on_event: &mut impl FnMut(&WatchEvent)) -> Result<(), Error> {
1034 self.refresh_engines()?;
1035 for (_, snapshot) in self.auto_snapshot()? {
1036 on_event(&WatchEvent::Snapshotted { snapshot });
1037 }
1038 Ok(())
1039 }
1040
1041 fn apply(
1048 &mut self,
1049 session: &Session,
1050 id: RequestId,
1051 tips: &SessionTips,
1052 approver: &Actor,
1053 ) -> Result<GateOutcome, Error> {
1054 let already = self.coordination.landings(id.0)?;
1055 let mut parked = Vec::new();
1056 let mut plan: Vec<(Option<String>, String)> = vec![(None, tips.root.clone())];
1060 for (name, tip) in &tips.mounts {
1061 if self.mount(name)?.engine.tree_changed(tip)? {
1062 plan.push((Some(name.clone()), tip.clone()));
1063 }
1064 }
1065 for (source, tip) in plan {
1066 if already.iter().any(|(landed, _)| *landed == source) {
1067 continue;
1068 }
1069 match self.apply_source(session, id, source.as_deref(), &tip, approver)? {
1070 LandOutcome::Landed { .. } => {}
1071 LandOutcome::Conflicted => parked.push(source),
1072 }
1073 }
1074 let landings: Vec<Landing> = self
1075 .coordination
1076 .landings(id.0)?
1077 .into_iter()
1078 .map(|(source, snapshot)| Landing { source, snapshot })
1079 .collect();
1080 if parked.is_empty() {
1081 if self.coordination.move_request_state(
1086 id.0,
1087 &[RequestState::Approved],
1088 RequestState::Landed,
1089 )? {
1090 let _ = self.coordination.move_session_state(
1091 session.id.0,
1092 SessionState::Open,
1093 SessionState::Landed,
1094 )?;
1095 }
1096 return Ok(GateOutcome::Landed { landings });
1097 }
1098 let _ = self.coordination.move_request_state(
1101 id.0,
1102 &[RequestState::Approved],
1103 RequestState::Parked,
1104 )?;
1105 Ok(GateOutcome::Parked {
1106 request: self.request(id)?,
1107 landings,
1108 parked,
1109 })
1110 }
1111
1112 fn apply_source(
1116 &mut self,
1117 session: &Session,
1118 id: RequestId,
1119 source: Option<&str>,
1120 tip: &str,
1121 approver: &Actor,
1122 ) -> Result<LandOutcome, Error> {
1123 let point = match source {
1124 Some(name) => format!("{LANDING_LEASE_POINT}/{name}"),
1125 None => LANDING_LEASE_POINT.to_owned(),
1126 };
1127 let holder = format!("{}:{}", self.actor.name, std::process::id());
1128 let now = now_ms()?;
1129 match self
1130 .coordination
1131 .claim_lease(&point, &holder, now, LANDING_LEASE_TTL_MS)?
1132 {
1133 LeaseClaim::HeldByOther {
1134 holder,
1135 expires_at_ms,
1136 } => {
1137 return Err(Error::LeaseHeld {
1138 holder,
1139 expires_at_ms,
1140 });
1141 }
1142 LeaseClaim::Held => {}
1143 }
1144 let outcome = self.apply_source_holding_lease(session, id, source, tip, approver);
1145 let released = self.coordination.release_lease(&point, &holder);
1146 let outcome = outcome?;
1147 released?;
1148 Ok(outcome)
1149 }
1150
1151 fn apply_source_holding_lease(
1152 &mut self,
1153 session: &Session,
1154 id: RequestId,
1155 source: Option<&str>,
1156 tip: &str,
1157 approver: &Actor,
1158 ) -> Result<LandOutcome, Error> {
1159 if let Some(hold) = land_hold_ms()? {
1162 std::thread::sleep(Duration::from_millis(hold));
1163 }
1164 self.refresh_engines()?;
1168 self.auto_snapshot()?;
1169 let outcome = match source {
1170 None => self.engine.land(tip, LANDED_BOOKMARK)?,
1171 Some(name) => {
1172 let index = self
1173 .mounts
1174 .iter()
1175 .position(|mount| mount.name == name)
1176 .ok_or_else(|| Error::Engine(format!("no source is mounted at {name:?}")))?;
1177 let bookmark = self.mounts[index]
1178 .branch
1179 .clone()
1180 .unwrap_or_else(|| LANDED_BOOKMARK.to_owned());
1181 self.mounts[index].engine.land(tip, &bookmark)?
1182 }
1183 };
1184 let scoped = |text: &str| match source {
1185 Some(name) => format!("{name} {text}"),
1186 None => text.to_owned(),
1187 };
1188 match &outcome {
1189 LandOutcome::Conflicted => {
1190 self.append_session_entry(
1191 approver,
1192 Act::LandParked,
1193 session.id,
1194 Some(scoped(&id.to_string())),
1195 )?;
1196 }
1197 LandOutcome::Landed { snapshot } => {
1198 self.coordination.record_landing(id.0, source, snapshot)?;
1199 self.append_session_entry(
1200 approver,
1201 Act::Land,
1202 session.id,
1203 Some(scoped(&format!("{id} {snapshot}"))),
1204 )?;
1205 self.sync_after_line_move(session, source, approver)?;
1206 }
1207 }
1208 Ok(outcome)
1209 }
1210
1211 pub fn undo(&mut self, id: RequestId) -> Result<Vec<Restore>, Error> {
1217 self.refresh_engines()?;
1218 self.auto_snapshot()?;
1219 let Some(row) = self.coordination.request(id.0)? else {
1220 return Err(Error::RequestNotFound(id.to_string()));
1221 };
1222 match row.state {
1223 RequestState::Landed => {}
1224 RequestState::Open
1225 | RequestState::Approved
1226 | RequestState::Parked
1227 | RequestState::Rejected
1228 | RequestState::Abandoned => {
1229 return Err(Error::Config(format!(
1230 "{id} is {}; only a landed request undoes - snapshots amend forward, gate acts move forward, syncs reconcile with atelier sync",
1231 row.state
1232 )));
1233 }
1234 }
1235 let session = self.session(SessionId(row.session_id))?;
1236 let mut landings = self.coordination.landings(id.0)?;
1237 landings.reverse();
1238 let mut restores = Vec::new();
1239 for (source, landed) in &landings {
1240 if let Some(head) = self.undo_source(&session, id, source.as_deref(), landed)? {
1241 restores.push(Restore {
1242 source: source.clone(),
1243 head,
1244 });
1245 }
1246 }
1247 if self.coordination.move_request_state(
1248 id.0,
1249 &[RequestState::Landed],
1250 RequestState::Open,
1251 )? {
1252 let actor = self.actor.clone();
1253 if self.coordination.dismiss_approvals(id.0)? > 0 {
1254 self.append_session_entry(
1255 &actor,
1256 Act::ApprovalsDismissed,
1257 session.id,
1258 Some(id.to_string()),
1259 )?;
1260 }
1261 let _ = self.coordination.move_session_state(
1262 session.id.0,
1263 SessionState::Landed,
1264 SessionState::Open,
1265 )?;
1266 }
1267 Ok(restores)
1268 }
1269
1270 fn undo_source(
1273 &mut self,
1274 session: &Session,
1275 id: RequestId,
1276 source: Option<&str>,
1277 landed: &str,
1278 ) -> Result<Option<String>, Error> {
1279 let point = match source {
1280 Some(name) => format!("{LANDING_LEASE_POINT}/{name}"),
1281 None => LANDING_LEASE_POINT.to_owned(),
1282 };
1283 let holder = format!("{}:{}", self.actor.name, std::process::id());
1284 let now = now_ms()?;
1285 match self
1286 .coordination
1287 .claim_lease(&point, &holder, now, LANDING_LEASE_TTL_MS)?
1288 {
1289 LeaseClaim::HeldByOther {
1290 holder,
1291 expires_at_ms,
1292 } => {
1293 return Err(Error::LeaseHeld {
1294 holder,
1295 expires_at_ms,
1296 });
1297 }
1298 LeaseClaim::Held => {}
1299 }
1300 let outcome = self.undo_source_holding_lease(session, id, source, landed);
1301 let released = self.coordination.release_lease(&point, &holder);
1302 let outcome = outcome?;
1303 released?;
1304 Ok(outcome)
1305 }
1306
1307 fn undo_source_holding_lease(
1310 &mut self,
1311 session: &Session,
1312 id: RequestId,
1313 source: Option<&str>,
1314 landed: &str,
1315 ) -> Result<Option<String>, Error> {
1316 let step = match source {
1317 None => self.engine.step_back(landed, LANDED_BOOKMARK)?,
1318 Some(name) => {
1319 let index = self
1320 .mounts
1321 .iter()
1322 .position(|mount| mount.name == name)
1323 .ok_or_else(|| Error::Engine(format!("no source is mounted at {name:?}")))?;
1324 let bookmark = self.mounts[index]
1325 .branch
1326 .clone()
1327 .unwrap_or_else(|| LANDED_BOOKMARK.to_owned());
1328 self.mounts[index].engine.step_back(landed, &bookmark)?
1329 }
1330 };
1331 match step {
1332 StepBack::Stepped { restored } => {
1333 self.coordination.delete_landing(id.0, source)?;
1336 let reference = match source {
1337 Some(name) => format!("{name} {id} {restored}"),
1338 None => format!("{id} {restored}"),
1339 };
1340 let actor = self.actor.clone();
1341 self.append_session_entry(&actor, Act::Undo, session.id, Some(reference))?;
1342 self.sync_after_line_move(session, source, &actor)?;
1343 Ok(Some(restored))
1344 }
1345 StepBack::AlreadyStepped => {
1346 self.coordination.delete_landing(id.0, source)?;
1349 Ok(None)
1350 }
1351 StepBack::LineMoved { head } => {
1352 let line = source.unwrap_or("the root");
1353 Err(Error::Config(format!(
1354 "{line} moved past {id}: {head} sits on the line now; undo that landing first"
1355 )))
1356 }
1357 }
1358 }
1359
1360 pub fn pull(&mut self, source: Option<&str>) -> Result<PullOutcome, Error> {
1366 self.refresh_engines()?;
1367 self.auto_snapshot()?;
1368 let mount = source.unwrap_or(ROOT_MOUNT);
1369 let config = read_workspace_config(&self.root.join(CONTROL_DIR))?;
1370 let Some(entry) = config.sources.iter().find(|s| s.mount == mount) else {
1371 return Err(Error::Config(format!("no source is attached at {mount:?}")));
1372 };
1373 match entry.kind {
1374 SourceKind::Remote => {}
1375 SourceKind::LocalFolder | SourceKind::LocalGit => {
1376 return Err(Error::Config(format!(
1377 "{mount:?} is not a remote source; folders reconcile with atelier sync and git sources pull with plain git"
1378 )));
1379 }
1380 }
1381 let index = self
1382 .mounts
1383 .iter()
1384 .position(|m| m.name == mount)
1385 .ok_or_else(|| Error::Engine(format!("no source is mounted at {mount:?}")))?;
1386 let remote = RemoteFolder::open(&entry.path.display().to_string()).map_err(engine_err)?;
1387 let Some((recorded, last_synced)) = self.coordination.sync_state(mount)? else {
1388 return Err(Error::Config(format!(
1389 "{mount:?} has no sync record; atelier sync --force seeds one"
1390 )));
1391 };
1392 if remote.fingerprint().map_err(engine_err)? == recorded {
1393 return Ok(PullOutcome::Current);
1394 }
1395 let head = self.mounts[index].engine.head()?;
1396 if head != last_synced {
1397 return Err(Error::Config(format!(
1398 "{mount:?} moved locally since its last sync ({head}); land or sync it first, then pull"
1399 )));
1400 }
1401 let mount_dir = self.root.join(mount);
1402 remote.download_mirror(&mount_dir).map_err(engine_err)?;
1403 let Some(snapshot) = self.mounts[index].engine.snapshot()? else {
1404 let fingerprint = remote.fingerprint().map_err(engine_err)?;
1407 self.coordination
1408 .record_sync_state(mount, &fingerprint, &head)?;
1409 return Ok(PullOutcome::Current);
1410 };
1411 let fingerprint = remote.fingerprint().map_err(engine_err)?;
1414 self.coordination
1415 .record_sync_state(mount, &fingerprint, &snapshot)?;
1416 let entry = self.entry(Act::Pull, Some(format!("{mount} {snapshot}")))?;
1417 self.journal.append(&entry)?;
1418 Ok(PullOutcome::Pulled { snapshot })
1419 }
1420
1421 pub fn sync(&mut self, source: Option<&str>, force: bool) -> Result<SyncOutcome, Error> {
1426 self.refresh_engines()?;
1427 self.auto_snapshot()?;
1428 let mount = source.unwrap_or(ROOT_MOUNT);
1429 let config = read_workspace_config(&self.root.join(CONTROL_DIR))?;
1430 let Some(entry) = config.sources.iter().find(|s| s.mount == mount) else {
1431 return Err(Error::Config(format!("no source is attached at {mount:?}")));
1432 };
1433 let target = match entry.kind {
1434 SourceKind::LocalGit => {
1435 return Err(Error::Config(format!(
1436 "{mount:?} is a git source; landed work publishes with plain git push"
1437 )));
1438 }
1439 SourceKind::LocalFolder => SyncTarget::Folder(self.origin_path(&entry.path)),
1440 SourceKind::Remote => SyncTarget::Remote(entry.path.display().to_string()),
1441 };
1442 let outcome = self.sync_source(source, &target, force)?;
1443 let (act, detail) = match &outcome {
1444 SyncOutcome::Synced { snapshot } => (Act::Sync, snapshot.clone()),
1445 SyncOutcome::Parked { snapshot } => {
1446 (Act::SyncParked, format!("{snapshot} origin changed"))
1447 }
1448 };
1449 let reference = match source {
1450 Some(name) => format!("{name} {detail}"),
1451 None => detail,
1452 };
1453 let entry = self.entry(act, Some(reference))?;
1454 self.journal.append(&entry)?;
1455 Ok(outcome)
1456 }
1457
1458 fn origin_path(&self, configured: &Path) -> PathBuf {
1461 if configured.is_absolute() {
1462 configured.to_path_buf()
1463 } else {
1464 self.root.join(configured)
1465 }
1466 }
1467
1468 fn sync_source(
1471 &mut self,
1472 source: Option<&str>,
1473 target: &SyncTarget,
1474 force: bool,
1475 ) -> Result<SyncOutcome, Error> {
1476 let mount = source.unwrap_or(ROOT_MOUNT);
1477 let index = match source {
1478 None => None,
1479 Some(name) => Some(
1480 self.mounts
1481 .iter()
1482 .position(|m| m.name == name)
1483 .ok_or_else(|| Error::Engine(format!("no source is mounted at {name:?}")))?,
1484 ),
1485 };
1486 let snapshot = match index {
1487 None => self.engine.head()?,
1488 Some(index) => self.mounts[index].engine.head()?,
1489 };
1490 let remote = match target {
1491 SyncTarget::Folder(_) => None,
1492 SyncTarget::Remote(url) => Some(RemoteFolder::open(url).map_err(engine_err)?),
1493 };
1494 if !force {
1495 let recorded = self.coordination.sync_state(mount)?;
1496 let current = match (target, &remote) {
1497 (SyncTarget::Folder(origin), _) => folder_fingerprint(origin)?,
1498 (SyncTarget::Remote(_), Some(remote)) => {
1499 remote.fingerprint().map_err(engine_err)?
1500 }
1501 (SyncTarget::Remote(_), None) => unreachable_remote()?,
1502 };
1503 if recorded.map(|(fingerprint, _)| fingerprint) != Some(current) {
1504 return Ok(SyncOutcome::Parked { snapshot });
1505 }
1506 }
1507 let engine = match index {
1508 None => &self.engine,
1509 Some(index) => &self.mounts[index].engine,
1510 };
1511 let fingerprint = match (target, &remote) {
1512 (SyncTarget::Folder(origin), _) => {
1513 engine.export_tree(&snapshot, origin)?;
1514 folder_fingerprint(origin)?
1515 }
1516 (SyncTarget::Remote(_), Some(remote)) => {
1517 let scratch = tempfile::tempdir()?;
1520 engine.export_tree(&snapshot, scratch.path())?;
1521 remote.mirror(scratch.path()).map_err(engine_err)?;
1522 remote.fingerprint().map_err(engine_err)?
1523 }
1524 (SyncTarget::Remote(_), None) => unreachable_remote()?,
1525 };
1526 self.coordination
1527 .record_sync_state(mount, &fingerprint, &snapshot)?;
1528 Ok(SyncOutcome::Synced { snapshot })
1529 }
1530
1531 fn sync_after_line_move(
1536 &mut self,
1537 session: &Session,
1538 source: Option<&str>,
1539 approver: &Actor,
1540 ) -> Result<(), Error> {
1541 let mount = source.unwrap_or(ROOT_MOUNT);
1542 let config = read_workspace_config(&self.root.join(CONTROL_DIR))?;
1543 let Some(entry) = config.sources.iter().find(|s| s.mount == mount) else {
1544 return Ok(());
1545 };
1546 let target = match entry.kind {
1547 SourceKind::LocalGit => return Ok(()),
1548 SourceKind::LocalFolder => SyncTarget::Folder(self.origin_path(&entry.path)),
1549 SourceKind::Remote => SyncTarget::Remote(entry.path.display().to_string()),
1550 };
1551 let (act, detail) = match self.sync_source(source, &target, false) {
1552 Ok(SyncOutcome::Synced { snapshot }) => (Act::Sync, snapshot),
1553 Ok(SyncOutcome::Parked { snapshot }) => {
1554 (Act::SyncParked, format!("{snapshot} origin changed"))
1555 }
1556 Err(error) => (Act::SyncParked, error.to_string()),
1557 };
1558 let reference = match source {
1559 Some(name) => format!("{name} {detail}"),
1560 None => detail,
1561 };
1562 self.append_session_entry(approver, act, session.id, Some(reference))?;
1563 Ok(())
1564 }
1565
1566 fn snapshot_session(&mut self, session: &Session) -> Result<SessionTips, Error> {
1571 let boundary = self.mount_boundary();
1574 let mut engine = Engine::open(&session.working_copy, &session.actor, &boundary)?;
1575 let new_snapshot = engine.snapshot_amend()?;
1576 let root_tip = engine.head()?;
1577 let mut recorded: Vec<(Option<String>, String)> = Vec::new();
1578 if let Some(new_snapshot) = new_snapshot {
1579 recorded.push((None, new_snapshot));
1580 }
1581 let mut mounts = Vec::new();
1582 for name in boundary {
1583 let mut engine = Engine::open(&session.working_copy.join(&name), &session.actor, &[])?;
1584 if let Some(new_snapshot) = engine.snapshot_amend()? {
1585 recorded.push((Some(name.clone()), new_snapshot));
1586 }
1587 mounts.push((name, engine.head()?));
1588 }
1589 for (source, new_snapshot) in &recorded {
1590 let reference = match source {
1591 Some(source) => format!("{source} {new_snapshot}"),
1592 None => new_snapshot.clone(),
1593 };
1594 self.append_session_entry(&session.actor, Act::Snapshot, session.id, Some(reference))?;
1595 self.gate_reacts_to_snapshot(session, new_snapshot)?;
1596 }
1597 if !recorded.is_empty() {
1598 self.refresh_engines()?;
1601 }
1602 Ok(SessionTips {
1603 root: root_tip,
1604 mounts,
1605 })
1606 }
1607
1608 fn mount(&self, name: &str) -> Result<&MountedSource, Error> {
1610 self.mounts
1611 .iter()
1612 .find(|mount| mount.name == name)
1613 .ok_or_else(|| Error::Engine(format!("no source is mounted at {name:?}")))
1614 }
1615
1616 fn session_target(&self, session: &Session, path: &str) -> (Option<String>, PathBuf, String) {
1619 if let Some((first, rest)) = path.split_once('/')
1620 && !rest.is_empty()
1621 && self.mounts.iter().any(|mount| mount.name == first)
1622 {
1623 return (
1624 Some(first.to_owned()),
1625 session.working_copy.join(first),
1626 rest.to_owned(),
1627 );
1628 }
1629 (None, session.working_copy.clone(), path.to_owned())
1630 }
1631
1632 fn gate_reacts_to_snapshot(
1633 &mut self,
1634 session: &Session,
1635 new_snapshot: &str,
1636 ) -> Result<(), Error> {
1637 let Some(request) = self.coordination.gated_request_for_session(session.id.0)? else {
1638 return Ok(());
1639 };
1640 let id = RequestId(request.id);
1641 match request.state {
1642 RequestState::Open | RequestState::Approved | RequestState::Parked => {
1643 if self.config()?.landing.dismiss_approvals_on_new_snapshots {
1644 let dismissed = self.coordination.dismiss_approvals(request.id)?;
1645 if dismissed > 0 {
1646 self.append_session_entry(
1647 &session.actor,
1648 Act::ApprovalsDismissed,
1649 session.id,
1650 Some(format!("{id} {new_snapshot}")),
1651 )?;
1652 }
1653 }
1654 match request.state {
1655 RequestState::Approved | RequestState::Parked => {
1660 let _ = self.coordination.move_request_state(
1661 request.id,
1662 &[RequestState::Approved, RequestState::Parked],
1663 RequestState::Open,
1664 )?;
1665 }
1666 RequestState::Open
1667 | RequestState::Landed
1668 | RequestState::Rejected
1669 | RequestState::Abandoned => {}
1670 }
1671 }
1672 RequestState::Landed | RequestState::Rejected | RequestState::Abandoned => {}
1673 }
1674 Ok(())
1675 }
1676
1677 fn gated_request(&mut self, id: RequestId) -> Result<RequestRow, Error> {
1681 let Some(row) = self.coordination.request(id.0)? else {
1682 return Err(Error::RequestNotFound(id.to_string()));
1683 };
1684 match row.state {
1685 RequestState::Open | RequestState::Approved => Ok(row),
1686 RequestState::Parked => Err(Error::RequestParked(id.to_string())),
1687 RequestState::Landed | RequestState::Rejected | RequestState::Abandoned => {
1688 Err(Error::RequestClosed {
1689 id: id.to_string(),
1690 state: row.state.to_string(),
1691 })
1692 }
1693 }
1694 }
1695
1696 fn open_session_only(&mut self, id: SessionId) -> Result<Session, Error> {
1698 let session = self.session(id)?;
1699 match session.state {
1700 SessionState::Open => Ok(session),
1701 SessionState::Landed | SessionState::Abandoned => Err(Error::SessionClosed {
1702 id: id.to_string(),
1703 state: session.state.to_string(),
1704 }),
1705 }
1706 }
1707
1708 fn session_from(&self, row: SessionRow) -> Result<Session, Error> {
1709 let id = SessionId(row.id);
1710 let change_id = row.change_id.ok_or_else(|| {
1711 Error::Engine(format!("session {id} has no change; its bootstrap failed"))
1712 })?;
1713 let mut changes = vec![SourceChange {
1714 source: None,
1715 change_id: change_id.clone(),
1716 }];
1717 for (source, mount_change) in self.coordination.session_source_changes(row.id)? {
1718 changes.push(SourceChange {
1719 source: Some(source),
1720 change_id: mount_change,
1721 });
1722 }
1723 Ok(Session {
1724 id,
1725 actor: Actor {
1726 name: row.actor_name,
1727 kind: row.actor_kind,
1728 },
1729 state: row.state,
1730 change_id,
1731 changes,
1732 working_copy: self.session_root(id),
1733 instruction_summary: row.instruction_summary,
1734 instruction_run_ref: row.instruction_run_ref,
1735 opened_at_ms: row.opened_at_ms,
1736 })
1737 }
1738
1739 fn request_from(&self, row: RequestRow) -> Result<LandingRequest, Error> {
1740 let approvals = self
1741 .coordination
1742 .live_approvals(row.id)?
1743 .into_iter()
1744 .map(|approval| Approval {
1745 actor: Actor {
1746 name: approval.actor_name,
1747 kind: approval.actor_kind,
1748 },
1749 snapshot: approval.snapshot_id,
1750 at_ms: approval.at_ms,
1751 })
1752 .collect();
1753 Ok(LandingRequest {
1754 id: RequestId(row.id),
1755 session_id: SessionId(row.session_id),
1756 requester: Actor {
1757 name: row.requester_name,
1758 kind: row.requester_kind,
1759 },
1760 state: row.state,
1761 approvals,
1762 created_at_ms: row.created_at_ms,
1763 })
1764 }
1765
1766 fn session_root(&self, id: SessionId) -> PathBuf {
1767 self.root
1768 .join(CONTROL_DIR)
1769 .join(SESSIONS_DIR)
1770 .join(id.to_string())
1771 }
1772
1773 fn config(&self) -> Result<WorkspaceConfig, Error> {
1774 read_workspace_config(&self.root.join(CONTROL_DIR))
1775 }
1776
1777 fn append_session_entry(
1778 &self,
1779 actor: &Actor,
1780 act: Act,
1781 session: SessionId,
1782 reference: Option<String>,
1783 ) -> Result<(), Error> {
1784 self.journal.append(&JournalEntry {
1785 at_ms: now_ms()?,
1786 actor_name: actor.name.clone(),
1787 actor_kind: actor.kind,
1788 act,
1789 session: Some(session.to_string()),
1790 instruction_summary: None,
1791 instruction_run_ref: None,
1792 instruction_verbatim: None,
1793 reference,
1794 })
1795 }
1796
1797 fn project_for_read(&self, package: &dyn FormatPackage, bytes: &[u8]) -> Result<String, Error> {
1801 let blob = FileBlob {
1802 id: crate::projection::content_id(bytes),
1803 bytes: bytes.to_vec(),
1804 };
1805 if let Some(text) = self.projections.read(package.id(), &blob) {
1806 return Ok(text);
1807 }
1808 match catch_unwind(AssertUnwindSafe(|| package.project(&blob.bytes))) {
1809 Ok(Ok(projection)) => {
1810 let _ = self
1813 .projections
1814 .store(package.id(), &blob, &projection.text);
1815 Ok(projection.text)
1816 }
1817 Ok(Err(error)) => Err(Error::PackageFailed {
1818 package: package.id().to_string(),
1819 reason: error.to_string(),
1820 }),
1821 Err(_) => Err(Error::PackageFailed {
1822 package: package.id().to_string(),
1823 reason: "the package panicked during projection".to_owned(),
1824 }),
1825 }
1826 }
1827
1828 fn raised(
1834 &self,
1835 engine: &Engine,
1836 diff: Diff,
1837 sides: &DiffSides,
1838 mount: Option<&str>,
1839 ) -> Result<Diff, Error> {
1840 let mut deltas = Vec::new();
1841 for delta in diff.deltas {
1842 deltas.extend(self.raise(engine, delta, sides, mount)?);
1843 }
1844 Ok(Diff { deltas })
1845 }
1846
1847 fn raise(
1850 &self,
1851 engine: &Engine,
1852 delta: Delta,
1853 sides: &DiffSides,
1854 mount: Option<&str>,
1855 ) -> Result<Vec<Delta>, Error> {
1856 let raw = delta.address.as_str().to_owned();
1860 let mut delta = delta;
1861 if let Some(mount) = mount {
1862 delta.address = Address::new(format!("{mount}/{raw}"));
1863 }
1864 if delta.kind != DeltaKind::Changed {
1865 return Ok(vec![delta]);
1866 }
1867 let (before, after) = match engine.read_file_sides(sides, &raw)? {
1868 (Side::Blob(before), Side::Blob(after)) => (before, after),
1869 (Side::TooLarge, _) | (_, Side::TooLarge) => {
1870 self.file_too_large(delta.address.as_str())?;
1871 return Ok(vec![delta]);
1872 }
1873 (Side::Absent, _) | (_, Side::Absent) => return Ok(vec![delta]),
1874 };
1875 if let Some(package) = self.detected(delta.address.as_str(), &after.bytes)? {
1876 let projections = (
1877 self.projection(package, delta.address.as_str(), &before)?,
1878 self.projection(package, delta.address.as_str(), &after)?,
1879 );
1880 let (Some(projected_before), Some(projected_after)) = projections else {
1881 return Ok(vec![delta]);
1882 };
1883 let raised = delta.at_text_rung(
1884 diff_lines(&projected_before, &projected_after),
1885 Some(package.id()),
1886 );
1887 return self.enriched(raised, package, &before, &after);
1888 }
1889 match (as_text(&before.bytes), as_text(&after.bytes)) {
1895 (Some(before), Some(after)) => {
1896 Ok(vec![delta.at_text_rung(diff_lines(before, after), None)])
1897 }
1898 _ => Ok(vec![delta]),
1899 }
1900 }
1901
1902 fn enriched(
1909 &self,
1910 raised: Delta,
1911 package: &dyn FormatPackage,
1912 before: &FileBlob,
1913 after: &FileBlob,
1914 ) -> Result<Vec<Delta>, Error> {
1915 let rich = match catch_unwind(AssertUnwindSafe(|| {
1916 package.diff(&before.bytes, &after.bytes)
1917 })) {
1918 Ok(None) => return Ok(vec![raised]),
1919 Ok(Some(Ok(rich))) => rich,
1920 Ok(Some(Err(error))) => {
1921 self.differ_failed(raised.address.as_str(), package.id(), &error.to_string())?;
1922 return Ok(vec![raised]);
1923 }
1924 Err(_) => {
1925 self.differ_failed(
1926 raised.address.as_str(),
1927 package.id(),
1928 "the package panicked during diffing",
1929 )?;
1930 return Ok(vec![raised]);
1931 }
1932 };
1933 if rich.is_empty() {
1934 return Ok(vec![raised]);
1935 }
1936 let path = raised.address.as_str().to_owned();
1937 let mut deltas = vec![Delta {
1938 fidelity: Fidelity::Rich,
1939 ..raised
1940 }];
1941 deltas.extend(rich.into_iter().map(|delta| Delta {
1942 address: Address::new(format!("{path} > {}", delta.address.as_str())),
1943 ..delta
1944 }));
1945 Ok(deltas)
1946 }
1947
1948 fn detected(&self, address: &str, bytes: &[u8]) -> Result<Option<&dyn FormatPackage>, Error> {
1952 if let Ok(package) = catch_unwind(AssertUnwindSafe(|| {
1953 detect_package(&self.packages, address, bytes)
1954 })) {
1955 Ok(package)
1956 } else {
1957 self.package_failed(address, None, "a package panicked during detection")?;
1958 Ok(None)
1959 }
1960 }
1961
1962 fn projection(
1967 &self,
1968 package: &dyn FormatPackage,
1969 address: &str,
1970 blob: &FileBlob,
1971 ) -> Result<Option<String>, Error> {
1972 if let Some(text) = self.projections.read(package.id(), blob) {
1973 return Ok(Some(text));
1974 }
1975 match catch_unwind(AssertUnwindSafe(|| package.project(&blob.bytes))) {
1976 Ok(Ok(projection)) => {
1977 let _ = self.projections.store(package.id(), blob, &projection.text);
1982 Ok(Some(projection.text))
1983 }
1984 Ok(Err(error)) => {
1985 self.package_failed(address, Some(package.id()), &error.to_string())?;
1986 Ok(None)
1987 }
1988 Err(_) => {
1989 self.package_failed(
1990 address,
1991 Some(package.id()),
1992 "the package panicked during projection",
1993 )?;
1994 Ok(None)
1995 }
1996 }
1997 }
1998
1999 fn package_failed(
2000 &self,
2001 address: &str,
2002 package: Option<PackageId>,
2003 reason: &str,
2004 ) -> Result<(), Error> {
2005 let reference = match package {
2006 Some(id) => format!("{address} {id} fell_back_to=binary: {reason}"),
2007 None => format!("{address} fell_back_to=binary: {reason}"),
2008 };
2009 let entry = self.entry(Act::PackageFailed, Some(reference))?;
2010 self.journal.append(&entry)
2011 }
2012
2013 fn differ_failed(&self, address: &str, package: PackageId, reason: &str) -> Result<(), Error> {
2017 let reference = format!("{address} {package} fell_back_to=text: {reason}");
2018 let entry = self.entry(Act::PackageFailed, Some(reference))?;
2019 self.journal.append(&entry)
2020 }
2021
2022 fn file_too_large(&self, address: &str) -> Result<(), Error> {
2025 let reference = format!(
2026 "{address} exceeds the {LADDER_FILE_SIZE_MAX}-byte ladder cap; kept at the binary rung"
2027 );
2028 let entry = self.entry(Act::FileTooLarge, Some(reference))?;
2029 self.journal.append(&entry)
2030 }
2031
2032 fn entry(&self, act: Act, reference: Option<String>) -> Result<JournalEntry, Error> {
2033 Ok(JournalEntry {
2034 at_ms: now_ms()?,
2035 actor_name: self.actor.name.clone(),
2036 actor_kind: self.actor.kind,
2037 act,
2038 session: None,
2039 instruction_summary: None,
2040 instruction_run_ref: None,
2041 instruction_verbatim: None,
2042 reference,
2043 })
2044 }
2045
2046 fn mount_boundary(&self) -> Vec<String> {
2048 self.mounts.iter().map(|mount| mount.name.clone()).collect()
2049 }
2050}
2051
2052fn builtin_packages() -> Vec<Box<dyn FormatPackage>> {
2054 vec![Box::new(DocxPackage)]
2055}
2056
2057struct SessionTips {
2059 root: String,
2060 mounts: Vec<(String, String)>,
2061}
2062
2063impl SessionTips {
2064 fn tip_of(&self, source: Option<&str>) -> String {
2067 match source {
2068 None => self.root.clone(),
2069 Some(name) => self
2070 .mounts
2071 .iter()
2072 .find(|(mount, _)| mount == name)
2073 .map_or_else(|| self.root.clone(), |(_, tip)| tip.clone()),
2074 }
2075 }
2076}
2077
2078#[derive(Debug, Clone, PartialEq, Eq)]
2081pub struct SourceSnapshot {
2082 pub source: Option<String>,
2084 pub snapshot: Snapshot,
2086}
2087
2088fn mount_names(config: &WorkspaceConfig) -> Vec<String> {
2091 let mut names: Vec<String> = config
2092 .sources
2093 .iter()
2094 .filter(|source| source.mount != ROOT_MOUNT)
2095 .map(|source| source.mount.clone())
2096 .collect();
2097 names.sort();
2098 names
2099}
2100
2101fn valid_mount_name(name: &str) -> Result<(), Error> {
2104 let flat = !name.is_empty()
2105 && name != "."
2106 && name != ".."
2107 && !name.contains('/')
2108 && !name.contains('\\');
2109 if !flat || SKIP_NAMES.contains(&name) {
2110 return Err(Error::Config(format!(
2111 "mount name {name:?} must be one path component outside the engine's internals"
2112 )));
2113 }
2114 Ok(())
2115}
2116
2117fn workspace_name(root: &Path) -> String {
2118 match root.file_name().and_then(|name| name.to_str()) {
2119 Some(name) => name.to_owned(),
2120 None => "workspace".to_owned(),
2121 }
2122}
2123
2124fn enclosing_workspace(root: &Path) -> Option<PathBuf> {
2125 let mut current = root.parent();
2126 while let Some(dir) = current {
2127 if dir.join(CONTROL_DIR).exists() {
2128 return Some(dir.to_path_buf());
2129 }
2130 current = dir.parent();
2131 }
2132 None
2133}
2134
2135fn folder_fingerprint(folder: &Path) -> Result<String, Error> {
2140 let mut hasher = Sha256::new();
2141 hash_folder(&mut hasher, folder)?;
2142 Ok(format!("{:x}", hasher.finalize()))
2143}
2144
2145fn hash_folder(hasher: &mut Sha256, root: &Path) -> Result<(), Error> {
2146 let mut files: Vec<(String, PathBuf, bool)> = Vec::new();
2149 let mut pending = vec![root.to_path_buf()];
2150 while let Some(dir) = pending.pop() {
2151 for entry in fs::read_dir(&dir)? {
2152 let entry = entry?;
2153 let name = entry.file_name();
2154 let Some(name) = name.to_str() else {
2155 return Err(Error::Engine(format!(
2156 "cannot fingerprint a non-utf8 name at {}",
2157 entry.path().display()
2158 )));
2159 };
2160 if SKIP_NAMES.contains(&name) {
2161 continue;
2162 }
2163 let path = entry.path();
2164 let file_type = entry.file_type()?;
2165 if file_type.is_dir() {
2166 pending.push(path);
2167 } else {
2168 let rel = path
2169 .strip_prefix(root)
2170 .map_err(engine_err)?
2171 .to_string_lossy()
2172 .into_owned();
2173 files.push((rel, path, file_type.is_symlink()));
2174 }
2175 }
2176 }
2177 files.sort();
2178 for (rel, path, is_symlink) in &files {
2179 hasher.update(rel.as_bytes());
2180 if *is_symlink {
2181 hasher.update([1]);
2182 hasher.update(fs::read_link(path)?.to_string_lossy().as_bytes());
2183 } else {
2184 hasher.update([2]);
2185 hasher.update(fs::read(path)?);
2186 }
2187 hasher.update([0]);
2188 }
2189 Ok(())
2190}
2191
2192fn adopted_branch(source: &Path) -> Result<Option<String>, Error> {
2196 let head = fs::read_to_string(source.join(".git").join("HEAD"))?;
2197 Ok(head
2198 .trim()
2199 .strip_prefix("ref: refs/heads/")
2200 .map(str::to_owned))
2201}
2202fn mount_refusals(name: &str, config: &WorkspaceConfig, mount_dir: &Path) -> Result<(), Error> {
2206 valid_mount_name(name)?;
2207 if config.sources.iter().any(|s| s.mount == name) {
2208 return Err(Error::AlreadyAttached);
2209 }
2210 if mount_dir.exists() {
2211 return Err(Error::Config(format!(
2212 "mount {name:?} collides with existing workspace content"
2213 )));
2214 }
2215 Ok(())
2216}
2217
2218fn folder_uses_lfs(folder: &Path) -> Result<bool, Error> {
2219 let gitattributes = folder.join(".gitattributes");
2220 if !gitattributes.is_file() {
2221 return Ok(false);
2222 }
2223 let text = fs::read_to_string(&gitattributes)?;
2224 Ok(text.contains("filter=lfs"))
2225}
2226
2227fn copy_tree(source: &Path, target: &Path, skips: &[&str]) -> Result<(), Error> {
2232 let mut pending = vec![(source.to_path_buf(), target.to_path_buf())];
2233 while let Some((from_dir, to_dir)) = pending.pop() {
2234 for entry in fs::read_dir(&from_dir)? {
2235 let entry = entry?;
2236 let name = entry.file_name();
2237 if skips.iter().any(|skip| name == **skip) {
2238 continue;
2239 }
2240 let from = entry.path();
2241 let to = to_dir.join(&name);
2242 if from.is_dir() {
2243 fs::create_dir_all(&to)?;
2244 pending.push((from, to));
2245 } else {
2246 fs::copy(&from, &to)?;
2247 }
2248 }
2249 }
2250 Ok(())
2251}
2252
2253fn session_file(working_copy: &Path, path: &str) -> Result<PathBuf, Error> {
2256 let relative = Path::new(path);
2257 let stays_inside = relative.components().all(|component| match component {
2258 Component::Normal(_) | Component::CurDir => true,
2259 Component::ParentDir | Component::RootDir | Component::Prefix(_) => false,
2260 });
2261 if path.is_empty() || !stays_inside {
2262 return Err(Error::PathOutsideWorkingCopy(path.to_owned()));
2263 }
2264 Ok(working_copy.join(relative))
2265}
2266
2267fn land_hold_ms() -> Result<Option<u64>, Error> {
2270 match env::var("ATELIER_LAND_HOLD_MS") {
2271 Ok(value) => value.parse().map(Some).map_err(config_err),
2272 Err(VarError::NotPresent) => Ok(None),
2273 Err(error @ VarError::NotUnicode(_)) => Err(config_err(error)),
2274 }
2275}
2276
2277fn now_ms() -> Result<i64, Error> {
2278 let elapsed = SystemTime::now()
2279 .duration_since(UNIX_EPOCH)
2280 .map_err(config_err)?;
2281 i64::try_from(elapsed.as_millis()).map_err(config_err)
2282}