1use std::collections::HashSet;
29use std::io::Write as _;
30use std::path::{Path, PathBuf};
31use std::process::{Command, Stdio};
32use std::sync::Arc;
33
34use serde::{Deserialize, Serialize};
35use serde_json::{json, Value};
36use sha2::{Digest, Sha256};
37
38use super::contract::{CheckResult, OutcomeContract};
39use super::session::{CoderSession, CoderState, EventSink};
40use crate::handler::JsonRpcMessage;
41use crate::session::{ClientSession, ServerState};
42
43pub const RECORD_DIR: &str = ".car/multiplayer";
45pub const BRANCH_PREFIX: &str = "car/mp/";
47pub const SCHEMA_VERSION: u32 = 1;
53
54const COMMITTER: [&str; 6] = [
55 "-c",
56 "user.name=car-multiplayer",
57 "-c",
58 "user.email=multiplayer@parslee.ai",
59 "-c",
61 "commit.gpgSign=false",
62];
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum Stage {
68 Build,
69 Improve,
70 Polish,
71 Extra,
74}
75
76impl Stage {
77 pub fn as_str(self) -> &'static str {
78 match self {
79 Self::Build => "build",
80 Self::Improve => "improve",
81 Self::Polish => "polish",
82 Self::Extra => "extra",
83 }
84 }
85
86 pub fn after(completed: usize) -> Self {
88 match completed {
89 0 => Self::Build,
90 1 => Self::Improve,
91 2 => Self::Polish,
92 _ => Self::Extra,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct StageSignals {
102 pub files_changed: u64,
103 pub lines_added: u64,
104 pub lines_removed: u64,
105 pub checks_added: u64,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct StageRecord {
113 pub stage: Stage,
114 pub account_id: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub session_id: Option<String>,
120 pub engine: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub model: Option<String>,
125 pub base_commit: String,
128 pub result_commit: String,
131 pub no_change: bool,
133 pub contract_hash: String,
135 pub finished_at: u64,
136 pub signals: StageSignals,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub cost_usd: Option<f64>,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct WorkItem {
148 pub schema_version: u32,
149 pub id: String,
150 pub repo_root_commit: String,
152 pub origin_commit: String,
154 pub intent: String,
156 pub contract: OutcomeContract,
158 pub stages: Vec<StageRecord>,
159}
160
161impl WorkItem {
162 pub fn next_stage(&self) -> Stage {
163 Stage::after(self.stages.len())
164 }
165
166 pub fn owners(&self) -> impl Iterator<Item = &str> {
167 self.stages.iter().map(|s| s.account_id.as_str())
168 }
169}
170
171pub fn record_path(id: &str) -> String {
173 format!("{RECORD_DIR}/{id}.json")
174}
175
176fn valid_item_id(id: &str) -> bool {
177 id.len() == 19
178 && id.starts_with("mp-")
179 && id[3..]
180 .bytes()
181 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
182}
183
184fn require_item_id(id: &str) -> Result<(), String> {
185 if valid_item_id(id) {
186 Ok(())
187 } else {
188 Err(format!(
189 "invalid work item id {id:?} (expected `mp-` and 16 hex digits)"
190 ))
191 }
192}
193
194pub fn contract_hash(contract: &OutcomeContract) -> String {
197 let mut checks: Vec<String> = contract
198 .checks
199 .iter()
200 .map(|c| serde_json::to_string(c).unwrap_or_default())
201 .collect();
202 checks.sort();
203 let mut hasher = Sha256::new();
204 hasher.update(if contract.allow_credentials {
205 b"1"
206 } else {
207 b"0"
208 });
209 for check in checks {
210 hasher.update([0u8]);
211 hasher.update(check.as_bytes());
212 }
213 format!("{:x}", hasher.finalize())
214}
215
216pub fn contract_grows(prior: &OutcomeContract, next: &OutcomeContract) -> Result<u64, String> {
226 unique_names(prior)?;
227 unique_names(next)?;
228 if next.allow_credentials && !prior.allow_credentials {
229 return Err(
230 "the contract grants credential access the locked contract did not; credential \
231 grants do not travel between stages"
232 .into(),
233 );
234 }
235 for check in &prior.checks {
236 match next.checks.iter().find(|c| c.name == check.name) {
237 None => {
238 return Err(format!(
239 "the contract drops the locked check `{}`; a stage may add checks but never \
240 remove one",
241 check.name
242 ));
243 }
244 Some(found) if found != check => {
245 return Err(format!(
246 "the contract changes the locked check `{}`; a stage may add checks but \
247 never alter one",
248 check.name
249 ));
250 }
251 Some(_) => {}
252 }
253 }
254 Ok(next.checks.len().saturating_sub(prior.checks.len()) as u64)
255}
256
257fn recorded(contract: &OutcomeContract, intent: &str) -> OutcomeContract {
262 OutcomeContract {
263 description: intent.to_string(),
264 ..contract.clone()
265 }
266}
267
268fn unique_names(contract: &OutcomeContract) -> Result<(), String> {
269 let mut seen = HashSet::new();
270 for check in &contract.checks {
271 if !seen.insert(check.name.as_str()) {
272 return Err(format!("the contract names check `{}` twice", check.name));
273 }
274 }
275 Ok(())
276}
277
278pub fn admissible(contract: &OutcomeContract) -> Result<(), String> {
284 let issues = contract.validate();
285 if !issues.is_empty() {
286 return Err(format!("the contract is invalid: {}", issues.join("; ")));
287 }
288 unique_names(contract)?;
289 if let Some(check) = contract
290 .checks
291 .iter()
292 .find(|c| c.baseline || c.differential.is_some())
293 {
294 return Err(format!(
295 "check `{}` is a baseline/differential check; a multiplayer contract is re-run \
296 cold at every stage and at merge, where no before-value exists",
297 check.name
298 ));
299 }
300 Ok(())
301}
302
303fn git_with(
308 repo: &Path,
309 args: &[&str],
310 env: &[(&str, &Path)],
311 stdin: Option<&[u8]>,
312) -> Result<String, String> {
313 let mut cmd = Command::new("git");
314 cmd.arg("-C").arg(repo).args(args);
315 cmd.env("GIT_TERMINAL_PROMPT", "0");
318 for (key, value) in env {
319 cmd.env(key, value);
320 }
321 cmd.stdin(if stdin.is_some() {
322 Stdio::piped()
323 } else {
324 Stdio::null()
325 })
326 .stdout(Stdio::piped())
327 .stderr(Stdio::piped());
328 let mut child = cmd.spawn().map_err(|e| format!("git {args:?}: {e}"))?;
329 if let Some(bytes) = stdin {
330 child
331 .stdin
332 .take()
333 .ok_or("git stdin unavailable")?
334 .write_all(bytes)
335 .map_err(|e| format!("git {args:?}: {e}"))?;
336 }
337 let out = child
338 .wait_with_output()
339 .map_err(|e| format!("git {args:?}: {e}"))?;
340 if out.status.success() {
341 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
342 } else {
343 Err(format!(
344 "git {} failed: {}",
345 args.join(" "),
346 String::from_utf8_lossy(&out.stderr).trim()
347 ))
348 }
349}
350
351fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
352 git_with(repo, args, &[], None)
353}
354
355fn require_remote(repo: &Path, remote: &str) -> Result<(), String> {
356 if remote.is_empty() || remote.starts_with('-') {
357 return Err(format!("invalid remote name {remote:?}"));
358 }
359 git(repo, &["remote", "get-url", "--", remote])
360 .map(|_| ())
361 .map_err(|_| {
362 format!(
363 "{} has no remote named `{remote}`; a work item is shared through the team's \
364 git remote",
365 repo.display()
366 )
367 })
368}
369
370fn remote_ref(remote: &str, id: &str) -> String {
371 format!("refs/remotes/{remote}/{BRANCH_PREFIX}{id}")
372}
373
374fn fetch_item(repo: &Path, remote: &str, id: &str) -> Result<String, String> {
376 require_item_id(id)?;
377 let spec = format!("+refs/heads/{BRANCH_PREFIX}{id}:{}", remote_ref(remote, id));
378 git(repo, &["fetch", "--quiet", "--", remote, &spec]).map_err(|e| {
379 format!("could not fetch work item {id} from `{remote}` — does it exist? ({e})")
380 })?;
381 git(
382 repo,
383 &[
384 "rev-parse",
385 "--verify",
386 &format!("{}^{{commit}}", remote_ref(remote, id)),
387 ],
388 )
389}
390
391fn read_item(repo: &Path, commit: &str, id: &str) -> Result<WorkItem, String> {
393 let raw = git(repo, &["show", &format!("{commit}:{}", record_path(id))]).map_err(|e| {
394 if e.contains("does not exist") || e.contains("exists on disk, but not in") {
395 format!("{commit} carries no record for work item {id}")
396 } else {
397 e
398 }
399 })?;
400 let item: WorkItem = serde_json::from_str(&raw)
401 .map_err(|e| format!("work item {id} record at {commit} is not valid: {e}"))?;
402 if item.id != id {
403 return Err(format!(
404 "record at {commit} names work item {}, not {id}",
405 item.id
406 ));
407 }
408 if item.schema_version != SCHEMA_VERSION {
409 return Err(format!(
410 "work item {id} has schema version {}; this CAR reads {SCHEMA_VERSION}",
411 item.schema_version
412 ));
413 }
414 Ok(item)
415}
416
417fn tree_with(repo: &Path, base: &str, path: &str, blob: Option<&str>) -> Result<String, String> {
420 let index = tempfile::NamedTempFile::new().map_err(|e| format!("temp index: {e}"))?;
421 let env = [("GIT_INDEX_FILE", index.path())];
422 git_with(repo, &["read-tree", base], &env, None)?;
423 match blob {
424 Some(blob) => git_with(
425 repo,
426 &[
427 "update-index",
428 "--add",
429 "--cacheinfo",
430 &format!("100644,{blob},{path}"),
431 ],
432 &env,
433 None,
434 )?,
435 None => git_with(
436 repo,
437 &["update-index", "--force-remove", "--", path],
438 &env,
439 None,
440 )?,
441 };
442 git_with(repo, &["write-tree"], &env, None)
443}
444
445fn commit_tree(repo: &Path, tree: &str, parent: &str, message: &str) -> Result<String, String> {
446 let mut args: Vec<&str> = COMMITTER.to_vec();
447 args.extend(["commit-tree", tree, "-p", parent, "-F", "-"]);
448 git_with(repo, &args, &[], Some(message.as_bytes()))
449}
450
451fn signals(repo: &Path, base: &str, result: &str, checks_added: u64) -> StageSignals {
452 let mut out = StageSignals {
453 checks_added,
454 ..StageSignals::default()
455 };
456 if let Ok(numstat) = git(repo, &["diff", "--numstat", base, result]) {
457 for line in numstat.lines() {
458 let mut cols = line.split('\t');
459 let added = cols.next().and_then(|v| v.parse::<u64>().ok());
460 let removed = cols.next().and_then(|v| v.parse::<u64>().ok());
461 out.files_changed += 1;
462 out.lines_added += added.unwrap_or(0);
463 out.lines_removed += removed.unwrap_or(0);
464 }
465 }
466 out
467}
468
469fn load_session(state_dir: &Path, session_id: &str) -> Result<CoderSession, String> {
470 if !session_id.starts_with("coder-")
471 || !session_id
472 .bytes()
473 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
474 {
475 return Err(format!("invalid coder session id {session_id:?}"));
476 }
477 CoderSession::load(&state_dir.join(format!("{session_id}.json")))
478 .map_err(|_| format!("no coder session '{session_id}'"))
479}
480
481pub struct PublishRequest<'a> {
487 pub session_id: &'a str,
490 pub item: Option<&'a str>,
493 pub remote: &'a str,
494 pub account: &'a str,
496}
497
498pub fn publish(state_dir: &Path, req: PublishRequest<'_>) -> Result<Value, String> {
504 let session = load_session(state_dir, req.session_id)?;
505 if session.project.is_some() {
506 return Err(
507 "a managed-project session delivers straight to its `main` and cannot be a \
508 multiplayer stage; start the stage with `repo`"
509 .into(),
510 );
511 }
512 let repo = session.repo.clone();
513 require_remote(&repo, req.remote)?;
514 let contract = session
515 .contract
516 .clone()
517 .ok_or("the session has no confirmed contract")?;
518 let base = session
519 .base
520 .clone()
521 .or_else(|| session.start_commit.clone())
522 .ok_or(
523 "the session does not record the commit it started from, so its stage cannot be \
524 placed",
525 )?;
526 let base = match session.inputs_snapshot.as_deref() {
535 Some(snapshot) => {
536 git(&repo, &["rev-parse", "--verify", &format!("{snapshot}^")]).map_err(|e| {
537 format!(
538 "the session's inputs snapshot {snapshot} has no parent commit to place \
539 its stage on: {e}"
540 )
541 })?
542 }
543 None => base,
544 };
545 let (result_commit, no_change) = match session.state {
546 CoderState::Merged => {
547 let branch = session
548 .result_branch
549 .as_deref()
550 .ok_or("the merged session names no result branch")?;
551 let commit = git(
552 &repo,
553 &["rev-parse", "--verify", &format!("{branch}^{{commit}}")],
554 )?;
555 let parent =
560 git(&repo, &["rev-parse", "--verify", &format!("{commit}^")]).unwrap_or_default();
561 if parent != base {
562 return Err(format!(
563 "{branch} is not the single approved commit on the session's start \
564 {base}; it has moved since approval, so its tip is not the reviewed work"
565 ));
566 }
567 (commit, false)
568 }
569 CoderState::Reported => (base.clone(), true),
570 other => {
571 return Err(format!(
572 "publish a stage after its coder session is approved (`merged`) or its \
573 no-change finding is accepted (`reported`); {} is `{}`",
574 req.session_id,
575 other.as_str()
576 ));
577 }
578 };
579 let now = std::time::SystemTime::now()
580 .duration_since(std::time::UNIX_EPOCH)
581 .map(|d| d.as_secs())
582 .unwrap_or(0);
583
584 admissible(&contract)?;
585 let touched = git(
586 &repo,
587 &[
588 "diff",
589 "--name-only",
590 &base,
591 &result_commit,
592 "--",
593 RECORD_DIR,
594 ],
595 )?;
596 if !touched.is_empty() {
597 return Err(format!(
598 "the stage edited {RECORD_DIR} ({}); only the runtime writes it",
599 touched.lines().collect::<Vec<_>>().join(", ")
600 ));
601 }
602
603 let (mut item, stage, parent) = match req.item {
604 None => {
605 if no_change {
606 return Err(
607 "Build has to build something; a no-change finding cannot start a \
608 work item"
609 .into(),
610 );
611 }
612 git(&repo, &["fetch", "--quiet", "--", req.remote]).map_err(|e| {
613 format!(
614 "could not fetch `{}` to place the Build's origin ({e})",
615 req.remote
616 )
617 })?;
618 let on_remote = git(
619 &repo,
620 &[
621 "for-each-ref",
622 "--contains",
623 &base,
624 "--format=%(refname)",
625 &format!("refs/remotes/{}/", req.remote),
626 ],
627 )?;
628 if on_remote.trim().is_empty() {
629 return Err(format!(
630 "the Build started at {base}, which is not on `{}`; the item's final \
631 squash is based there, so unpushed commits under it would reach the pull \
632 request without any stage owning them — push them first, or start the \
633 Build from a commit that is on the remote",
634 req.remote
635 ));
636 }
637 let root = car_fleet::worker::root_commit(&repo).map_err(|e| e.to_string())?;
638 let mut hasher = Sha256::new();
639 for part in [&root, &base, req.account, req.session_id] {
640 hasher.update(part.as_bytes());
641 hasher.update([0u8]);
642 }
643 let id = format!("mp-{}", &format!("{:x}", hasher.finalize())[..16]);
644 let existing = git(
645 &repo,
646 &[
647 "ls-remote",
648 "--heads",
649 "--",
650 req.remote,
651 &format!("refs/heads/{BRANCH_PREFIX}{id}"),
652 ],
653 )?;
654 if !existing.is_empty() {
655 return Err(format!("work item {id} already exists on `{}`", req.remote));
656 }
657 let item = WorkItem {
658 schema_version: SCHEMA_VERSION,
659 id,
660 repo_root_commit: root,
661 origin_commit: base.clone(),
662 intent: session.intent.clone(),
663 contract: recorded(&contract, &session.intent),
664 stages: Vec::new(),
665 };
666 (item, Stage::Build, result_commit.clone())
667 }
668 Some(id) => {
669 let tip = fetch_item(&repo, req.remote, id)?;
670 let item = read_item(&repo, &tip, id)?;
671 if item.owners().any(|owner| owner == req.account) {
672 return Err(format!(
673 "account {} already owns a stage of {id}; each stage must be a different \
674 developer (advisory until stage receipts are attested)",
675 req.account
676 ));
677 }
678 if base != tip {
679 return Err(format!(
680 "a {} stage must start from the work item's tip {tip}; session {} started \
681 at {base} — start it with `coder.start {{ base: \"{tip}\" }}` or \
682 `multiplayer.start_stage`",
683 item.next_stage().as_str(),
684 req.session_id
685 ));
686 }
687 let stage = item.next_stage();
688 let parent = if no_change {
689 tip.clone()
690 } else {
691 result_commit.clone()
692 };
693 (item, stage, parent)
694 }
695 };
696
697 let checks_added = contract_grows(&item.contract, &contract)?;
698 item.contract = recorded(&contract, &item.intent);
699 item.stages.push(StageRecord {
700 stage,
701 account_id: req.account.to_string(),
702 session_id: Some(req.session_id.to_string()),
703 engine: session.engine.label(),
704 model: session.model.clone(),
705 base_commit: base.clone(),
706 result_commit: result_commit.clone(),
707 no_change,
708 contract_hash: contract_hash(&contract),
709 finished_at: now,
710 signals: signals(&repo, &base, &result_commit, checks_added),
711 cost_usd: session.cost_usd,
712 });
713
714 commit_and_push(&repo, req.remote, &item, stage, &parent)
715}
716
717fn commit_and_push(
720 repo: &Path,
721 remote: &str,
722 item: &WorkItem,
723 stage: Stage,
724 parent: &str,
725) -> Result<Value, String> {
726 let mut body = serde_json::to_vec_pretty(item).map_err(|e| e.to_string())?;
727 body.push(b'\n');
728 let blob = git_with(repo, &["hash-object", "-w", "--stdin"], &[], Some(&body))?;
729 let path = record_path(&item.id);
730 let tree = tree_with(repo, parent, &path, Some(&blob))?;
731 let message = format!(
732 "multiplayer: {} of {}\n\nMultiplayer-Item: {}\nMultiplayer-Stage: {}\n",
733 stage.as_str(),
734 item.id,
735 item.id,
736 stage.as_str()
737 );
738 let commit = commit_tree(repo, &tree, parent, &message)?;
739 let branch = format!("{BRANCH_PREFIX}{}", item.id);
740 git(
741 repo,
742 &[
743 "push",
744 "--quiet",
745 "--",
746 remote,
747 &format!("{commit}:refs/heads/{branch}"),
748 ],
749 )
750 .map_err(|e| {
751 format!(
752 "could not push {branch} to `{remote}` — if another developer published this \
753 stage first, the work item has moved on ({e})"
754 )
755 })?;
756 let local_branch_updated = update_local_branch(repo, &branch, &commit);
760
761 Ok(json!({
762 "item_id": item.id,
763 "local_branch_updated": local_branch_updated,
764 "stage": stage.as_str(),
765 "commit": commit,
766 "branch": branch,
767 "remote": remote,
768 "next_stage": item.next_stage().as_str(),
769 "owners": item.owners().collect::<Vec<_>>(),
770 }))
771}
772
773fn update_local_branch(repo: &Path, branch: &str, commit: &str) -> bool {
776 let full = format!("refs/heads/{branch}");
777 let checked_out = git(repo, &["worktree", "list", "--porcelain"])
778 .map(|list| list.lines().any(|l| l == format!("branch {full}")))
779 .unwrap_or(true);
780 !checked_out && git(repo, &["update-ref", &full, commit]).is_ok()
781}
782
783fn unique_label(base: &str) -> String {
787 use std::sync::atomic::{AtomicU64, Ordering};
788 static N: AtomicU64 = AtomicU64::new(0);
789 format!(
790 "{base}-{}-{}",
791 std::process::id(),
792 N.fetch_add(1, Ordering::Relaxed)
793 )
794}
795
796async fn run_contract_at(
804 repo: &Path,
805 rev: &str,
806 worktree_base: &Path,
807 label: &str,
808 contract: &OutcomeContract,
809) -> Result<Vec<CheckResult>, String> {
810 let (repo, base, rev, label) = (
811 repo.to_path_buf(),
812 worktree_base.to_path_buf(),
813 rev.to_string(),
814 unique_label(label),
815 );
816 let sink_label = label.clone();
817 let (workspace, executor) = tokio::task::spawn_blocking(move || {
818 let config = car_multi::WorkspaceConfig::git_worktree_at(&repo, &base).with_rev(rev);
819 let workspace = car_multi::AgentWorkspace::provision(&config, &label)?;
820 let executor = super::shell_tool::WorktreeExecutor::for_coder_session(workspace.path())?
821 .withholding_forge_credentials();
822 Ok::<_, String>((workspace, executor))
823 })
824 .await
825 .map_err(|e| e.to_string())??;
826 let mut contract = contract.clone();
827 contract.allow_credentials = false;
828 let sink = EventSink::new(sink_label, None, None);
829 let results = super::contract::evaluate_contract(&contract, &executor, &sink).await;
830 drop(executor);
831 let _ = tokio::task::spawn_blocking(move || drop(workspace)).await;
833 Ok(results)
834}
835
836fn all_green(results: &[CheckResult]) -> bool {
837 !results.is_empty() && results.iter().all(|r| r.passed)
838}
839
840pub struct SubmitRequest<'a> {
842 pub repo: &'a Path,
844 pub item: &'a str,
845 pub commit: &'a str,
847 pub remote: &'a str,
848 pub account: &'a str,
849 pub contract_additions: Vec<super::contract::ContractCheck>,
851}
852
853pub async fn submit_stage(req: SubmitRequest<'_>, worktree_base: &Path) -> Result<Value, String> {
859 let (repo, remote, id, commit, account) = (
860 req.repo.to_path_buf(),
861 req.remote.to_string(),
862 req.item.to_string(),
863 req.commit.to_string(),
864 req.account.to_string(),
865 );
866 let (tip, item, commit) = {
867 let (repo, remote, id, account) =
868 (repo.clone(), remote.clone(), id.clone(), account.clone());
869 tokio::task::spawn_blocking(move || -> Result<(String, WorkItem, String), String> {
870 let (tip, item) = prepare_stage(&repo, &remote, &id, &account)?;
871 if commit.starts_with('-') {
872 return Err(format!("invalid commit {commit:?}"));
873 }
874 let commit = git(
875 &repo,
876 &["rev-parse", "--verify", &format!("{commit}^{{commit}}")],
877 )
878 .map_err(|_| format!("{commit} does not name a commit in {}", repo.display()))?;
879 git(&repo, &["merge-base", "--is-ancestor", &tip, &commit])
880 .map_err(|_| format!("{commit} does not descend from the work item's tip {tip}"))?;
881 if commit == tip {
882 return Err(
883 "the submitted commit is the tip itself: a stage done outside CAR must \
884 change something, because CAR did not watch the work and cannot judge \
885 a \"no change\" conclusion"
886 .into(),
887 );
888 }
889 let touched = git(
890 &repo,
891 &["diff", "--name-only", &tip, &commit, "--", RECORD_DIR],
892 )?;
893 if !touched.is_empty() {
894 return Err(format!(
895 "the stage edited the work item record ({}); only the runtime writes it",
896 touched.lines().collect::<Vec<_>>().join(", ")
897 ));
898 }
899 Ok((tip, item, commit))
900 })
901 .await
902 .map_err(|e| e.to_string())??
903 };
904
905 let mut contract = item.contract.clone();
906 for check in req.contract_additions {
907 if contract.checks.iter().any(|c| c.name == check.name) {
908 return Err(format!(
909 "`contract_additions` may only add checks; `{}` is already in the locked \
910 contract",
911 check.name
912 ));
913 }
914 contract.checks.push(check);
915 }
916 admissible(&contract)?;
917 let results = run_contract_at(
918 &repo,
919 &commit,
920 worktree_base,
921 &format!("{id}-submit"),
922 &contract,
923 )
924 .await?;
925 if !all_green(&results) {
926 let red: Vec<&str> = results
927 .iter()
928 .filter(|r| !r.passed)
929 .map(|r| r.name.as_str())
930 .collect();
931 return Err(format!(
932 "the contract is not green at {commit} (failing: {}); fix the work and submit \
933 again",
934 if red.is_empty() {
935 "no checks ran".to_string()
936 } else {
937 red.join(", ")
938 }
939 ));
940 }
941
942 tokio::task::spawn_blocking(move || {
943 let mut item = item;
944 let checks_added = contract_grows(&item.contract, &contract)?;
945 let stage = item.next_stage();
946 item.contract = recorded(&contract, &item.intent);
947 item.stages.push(StageRecord {
948 stage,
949 account_id: account,
950 session_id: None,
951 engine: "external-unmanaged".into(),
952 model: None,
953 base_commit: tip.clone(),
954 result_commit: commit.clone(),
955 no_change: false,
956 contract_hash: contract_hash(&contract),
957 finished_at: std::time::SystemTime::now()
958 .duration_since(std::time::UNIX_EPOCH)
959 .map(|d| d.as_secs())
960 .unwrap_or(0),
961 signals: signals(&repo, &tip, &commit, checks_added),
962 cost_usd: None,
963 });
964 let mut out = commit_and_push(&repo, &remote, &item, stage, &commit)?;
965 out["checks"] = json!(results);
966 Ok(out)
967 })
968 .await
969 .map_err(|e| e.to_string())?
970}
971
972pub fn prepare_stage(
978 repo: &Path,
979 remote: &str,
980 id: &str,
981 account: &str,
982) -> Result<(String, WorkItem), String> {
983 require_remote(repo, remote)?;
984 let tip = fetch_item(repo, remote, id)?;
985 let item = read_item(repo, &tip, id)?;
986 if item.owners().any(|owner| owner == account) {
987 return Err(format!(
988 "account {account} already owns a stage of {id}; each stage must be a different \
989 developer (advisory until stage receipts are attested)"
990 ));
991 }
992 Ok((tip, item))
993}
994
995fn summary_row(item: &WorkItem, tip: &str, account: Option<&str>) -> Value {
996 let distinct: HashSet<&str> = item.owners().collect();
997 json!({
998 "item_id": item.id,
999 "intent": item.intent,
1000 "tip": tip,
1001 "stages": item.stages.iter().map(|s| json!({
1002 "stage": s.stage.as_str(),
1003 "account_id": s.account_id,
1004 "engine": s.engine,
1005 "no_change": s.no_change,
1006 "signals": s.signals,
1007 "cost_usd": s.cost_usd,
1008 })).collect::<Vec<_>>(),
1009 "next_stage": item.next_stage().as_str(),
1010 "eligible": account.map(|a| !item.owners().any(|owner| owner == a)),
1011 "ready_to_merge": item.stages.len() >= 3 && distinct.len() == item.stages.len(),
1013 })
1014}
1015
1016pub fn list(repo: &Path, remote: &str, account: Option<&str>) -> Result<Value, String> {
1019 require_remote(repo, remote)?;
1020 let heads = git(
1021 repo,
1022 &[
1023 "ls-remote",
1024 "--heads",
1025 "--",
1026 remote,
1027 &format!("refs/heads/{BRANCH_PREFIX}*"),
1028 ],
1029 )?;
1030 let ids: Vec<String> = heads
1031 .lines()
1032 .filter_map(|line| line.split('\t').nth(1))
1033 .filter_map(|r| r.strip_prefix(&format!("refs/heads/{BRANCH_PREFIX}")))
1034 .filter(|id| valid_item_id(id))
1035 .map(str::to_string)
1036 .collect();
1037 let mut rows = Vec::new();
1038 let mut unreadable = Vec::new();
1039 for id in &ids {
1040 match fetch_item(repo, remote, id).and_then(|tip| {
1041 let item = read_item(repo, &tip, id)?;
1042 Ok(summary_row(&item, &tip, account))
1043 }) {
1044 Ok(row) => rows.push(row),
1045 Err(e) => unreadable.push(json!({ "item_id": id, "error": e })),
1046 }
1047 }
1048 Ok(json!({ "items": rows, "unreadable": unreadable }))
1049}
1050
1051pub fn get(repo: &Path, remote: &str, id: &str) -> Result<Value, String> {
1053 require_remote(repo, remote)?;
1054 let tip = fetch_item(repo, remote, id)?;
1055 let item = read_item(repo, &tip, id)?;
1056 Ok(json!({ "tip": tip, "item": item }))
1057}
1058
1059fn history_problems(repo: &Path, tip: &str, item: &WorkItem) -> Result<Vec<String>, String> {
1066 let mut problems = Vec::new();
1067 let required = [Stage::Build, Stage::Improve, Stage::Polish];
1068 if item.stages.len() < required.len() {
1069 problems.push(format!(
1070 "only {} of the required stages (build, improve, polish) are recorded",
1071 item.stages.len()
1072 ));
1073 }
1074 for (i, record) in item.stages.iter().enumerate() {
1075 if record.stage != Stage::after(i) {
1076 problems.push(format!(
1077 "stage {} is recorded as {}, expected {}",
1078 i + 1,
1079 record.stage.as_str(),
1080 Stage::after(i).as_str()
1081 ));
1082 }
1083 }
1084 let mut seen = HashSet::new();
1085 for owner in item.owners() {
1086 if !seen.insert(owner) {
1087 problems.push(format!("account {owner} owns more than one stage"));
1088 }
1089 }
1090
1091 let path = record_path(&item.id);
1101 let log = git(
1102 repo,
1103 &["log", "--first-parent", "--format=%H", tip, "--", &path],
1104 )?;
1105 let mut commits: Vec<&str> = log.lines().collect();
1106 commits.reverse();
1107 if commits.last().copied() != Some(tip) {
1108 problems.push(format!(
1109 "the tip {tip} is not a record commit: something was pushed to the branch after \
1110 the last stage was published"
1111 ));
1112 }
1113 let mut previous: Option<(String, WorkItem)> = None;
1114 for (i, commit) in commits.iter().enumerate() {
1115 let message = git(repo, &["log", "-1", "--format=%B", commit])?;
1116 if !message
1117 .lines()
1118 .any(|l| l.trim() == format!("Multiplayer-Item: {}", item.id))
1119 {
1120 problems.push(format!(
1121 "{commit} changed the record but is not a record commit; only the runtime \
1122 writes it"
1123 ));
1124 continue;
1125 }
1126 let changed = git(
1127 repo,
1128 &["diff", "--name-only", &format!("{commit}^"), commit],
1129 )?;
1130 if changed.lines().collect::<Vec<_>>() != [path.as_str()] {
1131 problems.push(format!(
1132 "record commit {commit} changes more than the record ({})",
1133 changed.lines().collect::<Vec<_>>().join(", ")
1134 ));
1135 }
1136 let version = match read_item(repo, commit, &item.id) {
1137 Ok(version) => version,
1138 Err(e) => {
1139 problems.push(e);
1140 continue;
1141 }
1142 };
1143 let Some(stage) = version.stages.last() else {
1144 problems.push(format!("record commit {commit} records no stage"));
1145 continue;
1146 };
1147 if version.stages.len() != i + 1 {
1148 problems.push(format!(
1149 "record commit {commit} is the #{} record commit but lists {} stage(s)",
1150 i + 1,
1151 version.stages.len()
1152 ));
1153 }
1154 let parent = git(repo, &["rev-parse", &format!("{commit}^")])?;
1155 if parent != stage.result_commit {
1156 problems.push(format!(
1157 "record commit {commit} does not sit on its stage's result {}",
1158 stage.result_commit
1159 ));
1160 }
1161 let expected_base = match &previous {
1162 None => version.origin_commit.clone(),
1163 Some((prev_commit, _)) => prev_commit.clone(),
1164 };
1165 if stage.base_commit != expected_base {
1166 problems.push(format!(
1167 "stage {} records base {} but should start from {expected_base}",
1168 i + 1,
1169 stage.base_commit
1170 ));
1171 }
1172 if git(
1173 repo,
1174 &[
1175 "merge-base",
1176 "--is-ancestor",
1177 &stage.base_commit,
1178 &stage.result_commit,
1179 ],
1180 )
1181 .is_err()
1182 {
1183 problems.push(format!(
1184 "stage {}'s result does not descend from its base",
1185 i + 1
1186 ));
1187 }
1188 if let Some((_, prev)) = &previous {
1189 if version.stages[..prev.stages.len().min(version.stages.len())]
1190 != prev.stages[..prev.stages.len().min(version.stages.len())]
1191 || version.origin_commit != prev.origin_commit
1192 {
1193 problems.push(format!("{commit} rewrites earlier stages of the record"));
1194 }
1195 if let Err(e) = contract_grows(&prev.contract, &version.contract) {
1196 problems.push(format!("{commit}: {e}"));
1197 }
1198 }
1199 previous = Some((commit.to_string(), version));
1200 }
1201 if commits.len() != item.stages.len() {
1202 problems.push(format!(
1203 "the record was written {} time(s) for {} stage(s)",
1204 commits.len(),
1205 item.stages.len()
1206 ));
1207 }
1208 if let Err(e) = admissible(&item.contract) {
1209 problems.push(e);
1210 }
1211 Ok(problems)
1212}
1213
1214pub async fn merge_check(
1223 repo: &Path,
1224 remote: &str,
1225 id: &str,
1226 worktree_base: &Path,
1227) -> Result<Value, String> {
1228 let (tip, item) = {
1229 let repo = repo.to_path_buf();
1230 let (remote, id) = (remote.to_string(), id.to_string());
1231 tokio::task::spawn_blocking(move || -> Result<(String, WorkItem), String> {
1232 require_remote(&repo, &remote)?;
1233 let tip = fetch_item(&repo, &remote, &id)?;
1234 let item = read_item(&repo, &tip, &id)?;
1235 Ok((tip, item))
1236 })
1237 .await
1238 .map_err(|e| e.to_string())??
1239 };
1240 let mut problems = {
1241 let (repo, tip, item) = (repo.to_path_buf(), tip.clone(), item.clone());
1242 tokio::task::spawn_blocking(move || history_problems(&repo, &tip, &item))
1243 .await
1244 .map_err(|e| e.to_string())??
1245 };
1246
1247 let squash = if problems.is_empty() {
1253 let (repo, tip, id, item) = (
1254 repo.to_path_buf(),
1255 tip.clone(),
1256 id.to_string(),
1257 item.clone(),
1258 );
1259 Some(
1260 tokio::task::spawn_blocking(move || -> Result<String, String> {
1261 let tree = tree_with(&repo, &tip, &record_path(&id), None)?;
1262 let mut message = format!(
1263 "{}\n\nMultiplayer-Item: {id}\n",
1264 item.intent
1265 .lines()
1266 .next()
1267 .unwrap_or("multiplayer work item")
1268 );
1269 for record in &item.stages {
1270 message.push_str(&format!(
1271 "Multiplayer-{}: {}\n",
1272 capitalize(record.stage.as_str()),
1273 record.account_id
1274 ));
1275 }
1276 commit_tree(&repo, &tree, &item.origin_commit, &message)
1277 })
1278 .await
1279 .map_err(|e| e.to_string())??,
1280 )
1281 } else {
1282 None
1283 };
1284 let results = match &squash {
1285 Some(commit) => {
1286 run_contract_at(
1287 repo,
1288 commit,
1289 worktree_base,
1290 &format!("{id}-check"),
1291 &item.contract,
1292 )
1293 .await?
1294 }
1295 None => Vec::new(),
1296 };
1297 if squash.is_some() && !all_green(&results) {
1298 problems.push("the final contract is not green on the squash that would merge".to_string());
1299 }
1300
1301 let (final_branch, final_commit) = match squash.filter(|_| problems.is_empty()) {
1302 Some(commit) => {
1303 let (repo, id) = (repo.to_path_buf(), id.to_string());
1304 tokio::task::spawn_blocking(
1305 move || -> Result<(Option<String>, Option<String>), String> {
1306 let branch = format!("{BRANCH_PREFIX}{id}-final");
1307 if !update_local_branch(&repo, &branch, &commit) {
1308 return Err(format!(
1309 "{branch} is checked out in a worktree; switch away from it and run \
1310 the merge check again"
1311 ));
1312 }
1313 Ok((Some(branch), Some(commit)))
1314 },
1315 )
1316 .await
1317 .map_err(|e| e.to_string())??
1318 }
1319 None => (None, None),
1320 };
1321
1322 Ok(json!({
1323 "item_id": id,
1324 "tip": tip,
1325 "mergeable": problems.is_empty(),
1326 "problems": problems,
1327 "checks": results,
1328 "final_branch": final_branch,
1329 "final_commit": final_commit,
1330 "advisory": "stage ownership is self-reported until stage receipts are attested",
1331 }))
1332}
1333
1334fn capitalize(s: &str) -> String {
1335 let mut chars = s.chars();
1336 match chars.next() {
1337 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1338 None => String::new(),
1339 }
1340}
1341
1342async fn refuse_agent(session: &ClientSession, method: &str) -> Result<(), String> {
1350 if let Some(agent) = session.agent_id.lock().await.clone() {
1351 if !session.is_host.load(std::sync::atomic::Ordering::Acquire) {
1352 return Err(format!(
1353 "`{method}` is operator-only: `{agent}` cannot act as a developer in a \
1354 multiplayer work item"
1355 ));
1356 }
1357 }
1358 Ok(())
1359}
1360
1361async fn current_account() -> Result<String, String> {
1362 car_auth::local_auth_snapshot()
1363 .await?
1364 .active_account_id
1365 .ok_or_else(|| {
1366 "multiplayer stages are attributed to your Parslee account; sign in first \
1367 (`car auth login`)"
1368 .to_string()
1369 })
1370}
1371
1372fn remote_param(params: &Value) -> String {
1373 params
1374 .get("remote")
1375 .and_then(Value::as_str)
1376 .filter(|r| !r.trim().is_empty())
1377 .unwrap_or("origin")
1378 .to_string()
1379}
1380
1381#[derive(Deserialize)]
1382struct PublishParams {
1383 session_id: String,
1384 #[serde(default)]
1385 item: Option<String>,
1386}
1387
1388pub async fn handle_publish(
1389 req: &JsonRpcMessage,
1390 session: &ClientSession,
1391) -> Result<Value, String> {
1392 refuse_agent(session, "multiplayer.publish").await?;
1393 let params: PublishParams =
1394 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1395 let remote = remote_param(&req.params);
1396 let account = current_account().await?;
1397 let state_dir = super::rpc::coder_state_dir()?;
1398 tokio::task::spawn_blocking(move || {
1399 publish(
1400 &state_dir,
1401 PublishRequest {
1402 session_id: ¶ms.session_id,
1403 item: params.item.as_deref(),
1404 remote: &remote,
1405 account: &account,
1406 },
1407 )
1408 })
1409 .await
1410 .map_err(|e| e.to_string())?
1411}
1412
1413#[derive(Deserialize)]
1414struct SubmitParams {
1415 repo: PathBuf,
1416 item: String,
1417 commit: String,
1418 #[serde(default)]
1419 contract_additions: Vec<super::contract::ContractCheck>,
1420}
1421
1422pub async fn handle_submit_stage(
1423 req: &JsonRpcMessage,
1424 session: &ClientSession,
1425) -> Result<Value, String> {
1426 refuse_agent(session, "multiplayer.submit_stage").await?;
1427 let params: SubmitParams =
1428 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1429 let remote = remote_param(&req.params);
1430 let account = current_account().await?;
1431 let worktrees = super::rpc::coder_state_dir()?.join("multiplayer-worktrees");
1432 submit_stage(
1433 SubmitRequest {
1434 repo: ¶ms.repo,
1435 item: ¶ms.item,
1436 commit: ¶ms.commit,
1437 remote: &remote,
1438 account: &account,
1439 contract_additions: params.contract_additions,
1440 },
1441 &worktrees,
1442 )
1443 .await
1444}
1445
1446#[derive(Deserialize)]
1447struct StartStageParams {
1448 repo: PathBuf,
1449 item: String,
1450 #[serde(default)]
1451 engine: Option<String>,
1452 #[serde(default)]
1453 model: Option<String>,
1454}
1455
1456pub async fn handle_start_stage(
1461 req: &JsonRpcMessage,
1462 state: &Arc<ServerState>,
1463 session: &Arc<ClientSession>,
1464) -> Result<Value, String> {
1465 refuse_agent(session, "multiplayer.start_stage").await?;
1466 let params: StartStageParams =
1467 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1468 let remote = remote_param(&req.params);
1469 let account = current_account().await?;
1470 let (tip, item) = {
1471 let (repo, id) = (params.repo.clone(), params.item.clone());
1472 tokio::task::spawn_blocking(move || prepare_stage(&repo, &remote, &id, &account))
1473 .await
1474 .map_err(|e| e.to_string())??
1475 };
1476 let mut start = json!({
1477 "repo": params.repo,
1478 "intent": item.intent,
1479 "base": tip,
1480 });
1481 if let Some(engine) = ¶ms.engine {
1482 start["engine"] = json!(engine);
1483 }
1484 if let Some(model) = ¶ms.model {
1485 start["model"] = json!(model);
1486 }
1487 let start_req = JsonRpcMessage {
1488 params: start,
1489 ..req.clone()
1490 };
1491 let mut response = super::rpc::handle_coder_start(&start_req, state, session).await?;
1492 let mut locked = item.contract.clone();
1496 locked.allow_credentials = false;
1497 response["multiplayer"] = json!({
1498 "item_id": item.id,
1499 "stage": item.next_stage().as_str(),
1500 "locked_contract": locked,
1501 "owners": item.owners().collect::<Vec<_>>(),
1502 });
1503 Ok(response)
1504}
1505
1506#[derive(Deserialize)]
1507struct RepoParams {
1508 repo: PathBuf,
1509 #[serde(default)]
1510 item: Option<String>,
1511}
1512
1513pub async fn handle_list(req: &JsonRpcMessage, session: &ClientSession) -> Result<Value, String> {
1514 refuse_agent(session, "multiplayer.list").await?;
1517 let params: RepoParams =
1518 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1519 let remote = remote_param(&req.params);
1520 let account = current_account().await.ok();
1522 tokio::task::spawn_blocking(move || list(¶ms.repo, &remote, account.as_deref()))
1523 .await
1524 .map_err(|e| e.to_string())?
1525}
1526
1527pub async fn handle_get(req: &JsonRpcMessage, session: &ClientSession) -> Result<Value, String> {
1528 refuse_agent(session, "multiplayer.get").await?;
1529 let params: RepoParams =
1530 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1531 let id = params.item.ok_or("`item` is required")?;
1532 let remote = remote_param(&req.params);
1533 tokio::task::spawn_blocking(move || get(¶ms.repo, &remote, &id))
1534 .await
1535 .map_err(|e| e.to_string())?
1536}
1537
1538pub async fn handle_merge_check(
1539 req: &JsonRpcMessage,
1540 session: &ClientSession,
1541) -> Result<Value, String> {
1542 refuse_agent(session, "multiplayer.merge_check").await?;
1543 let params: RepoParams =
1544 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1545 let id = params.item.ok_or("`item` is required")?;
1546 let remote = remote_param(&req.params);
1547 let worktrees = super::rpc::coder_state_dir()?.join("multiplayer-worktrees");
1548 merge_check(¶ms.repo, &remote, &id, &worktrees).await
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553 use super::*;
1554 use crate::coder::contract::ContractCheck;
1555 use crate::coder::router::EngineChoice;
1556 use crate::coder::test_cmds;
1557
1558 fn run(dir: &Path, args: &[&str]) -> String {
1559 let out = Command::new("git")
1560 .env("GIT_CONFIG_NOSYSTEM", "1")
1561 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1562 .arg("-C")
1563 .arg(dir)
1564 .args([
1565 "-c",
1566 "user.name=t",
1567 "-c",
1568 "user.email=t@t",
1569 "-c",
1570 "commit.gpgSign=false",
1571 ])
1572 .args(args)
1573 .output()
1574 .unwrap();
1575 assert!(
1576 out.status.success(),
1577 "git {args:?}: {}",
1578 String::from_utf8_lossy(&out.stderr)
1579 );
1580 String::from_utf8(out.stdout).unwrap().trim().to_string()
1581 }
1582
1583 fn check(name: &str, file: &str) -> ContractCheck {
1584 ContractCheck {
1585 name: name.into(),
1586 command: test_cmds::file_exists(file),
1587 expect_exit_zero: true,
1588 output_contains: None,
1589 timeout_secs: 30,
1590 baseline: false,
1591 differential: None,
1592 }
1593 }
1594
1595 fn contract(checks: Vec<ContractCheck>) -> OutcomeContract {
1596 OutcomeContract {
1597 allow_credentials: false,
1598 description: "files exist".into(),
1599 checks,
1600 }
1601 }
1602
1603 fn coder_branch(repo: &Path, tag: &str, at: &str, file: &str) -> String {
1606 let branch = format!("car/coder/{tag}");
1607 run(repo, &["checkout", "-q", "-b", &branch, at]);
1608 std::fs::write(repo.join(file), tag).unwrap();
1609 run(repo, &["add", file]);
1610 run(repo, &["commit", "-q", "-m", tag]);
1611 run(repo, &["checkout", "-q", "--detach"]);
1612 branch
1613 }
1614
1615 fn stage_session(
1617 state_dir: &Path,
1618 repo: &Path,
1619 state: CoderState,
1620 branch: Option<&str>,
1621 base: Option<&str>,
1622 start: Option<&str>,
1623 contract: OutcomeContract,
1624 ) -> String {
1625 let mut s = CoderSession::new(
1626 repo,
1627 "make a.txt and b.txt exist",
1628 EngineChoice::Native,
1629 1,
1630 Some(state_dir.to_path_buf()),
1631 );
1632 s.state = state;
1633 s.result_branch = branch.map(str::to_string);
1634 s.base = base.map(str::to_string);
1635 s.start_commit = start.map(str::to_string);
1636 s.contract = Some(contract);
1637 s.persist().unwrap();
1638 s.id
1639 }
1640
1641 struct Team {
1642 _root: tempfile::TempDir,
1643 state_dir: tempfile::TempDir,
1644 alice: PathBuf,
1645 bob: PathBuf,
1646 carol: PathBuf,
1647 origin: String,
1648 }
1649
1650 fn team() -> Team {
1652 let root = tempfile::tempdir().unwrap();
1653 let remote = root.path().join("remote.git");
1654 std::fs::create_dir_all(&remote).unwrap();
1655 run(&remote, &["init", "-q", "--bare", "-b", "main"]);
1656 let clone = |name: &str| {
1657 let dir = root.path().join(name);
1658 run(
1659 root.path(),
1660 &["clone", "-q", remote.to_str().unwrap(), name],
1661 );
1662 dir
1663 };
1664 let alice = clone("alice");
1665 std::fs::write(alice.join("README"), "hi").unwrap();
1666 run(&alice, &["add", "README"]);
1667 run(&alice, &["commit", "-q", "-m", "init"]);
1668 run(&alice, &["push", "-q", "origin", "HEAD:main"]);
1669 let origin = run(&alice, &["rev-parse", "HEAD"]);
1670 let bob = clone("bob");
1671 let carol = clone("carol");
1672 Team {
1673 _root: root,
1674 state_dir: tempfile::tempdir().unwrap(),
1675 alice,
1676 bob,
1677 carol,
1678 origin,
1679 }
1680 }
1681
1682 fn publish_as(t: &Team, session: &str, item: Option<&str>, who: &str) -> Result<Value, String> {
1683 publish(
1684 t.state_dir.path(),
1685 PublishRequest {
1686 session_id: session,
1687 item,
1688 remote: "origin",
1689 account: who,
1690 },
1691 )
1692 }
1693
1694 fn built(t: &Team) -> String {
1696 let branch = coder_branch(&t.alice, "b1", &t.origin, "a.txt");
1697 let s = stage_session(
1698 t.state_dir.path(),
1699 &t.alice,
1700 CoderState::Merged,
1701 Some(&branch),
1702 None,
1703 Some(&t.origin),
1704 contract(vec![check("a", "a.txt")]),
1705 );
1706 let out = publish_as(t, &s, None, "alice").unwrap();
1707 assert_eq!(out["stage"], "build");
1708 assert_eq!(out["next_stage"], "improve");
1709 out["item_id"].as_str().unwrap().to_string()
1710 }
1711
1712 fn improved(t: &Team, id: &str) -> String {
1714 let (tip, item) = prepare_stage(&t.bob, "origin", id, "bob").unwrap();
1715 assert_eq!(item.next_stage(), Stage::Improve);
1716 let branch = coder_branch(&t.bob, "i1", &tip, "b.txt");
1717 let s = stage_session(
1718 t.state_dir.path(),
1719 &t.bob,
1720 CoderState::Merged,
1721 Some(&branch),
1722 Some(&tip),
1723 None,
1724 contract(vec![check("a", "a.txt"), check("b", "b.txt")]),
1725 );
1726 publish_as(t, &s, Some(id), "bob").unwrap();
1727 tip
1728 }
1729
1730 fn polished(t: &Team) -> String {
1732 let id = built(t);
1733 improved(t, &id);
1734 let (tip, _) = prepare_stage(&t.carol, "origin", &id, "carol").unwrap();
1735 let s = stage_session(
1736 t.state_dir.path(),
1737 &t.carol,
1738 CoderState::Reported,
1739 None,
1740 Some(&tip),
1741 None,
1742 contract(vec![check("a", "a.txt"), check("b", "b.txt")]),
1743 );
1744 publish_as(t, &s, Some(&id), "carol").unwrap();
1745 id
1746 }
1747
1748 #[tokio::test]
1749 async fn a_work_item_moves_through_three_developers_and_merges() {
1750 let t = team();
1751 let id = built(&t);
1752 improved(&t, &id);
1753
1754 let (tip, _) = prepare_stage(&t.carol, "origin", &id, "carol").unwrap();
1757 let s = stage_session(
1758 t.state_dir.path(),
1759 &t.carol,
1760 CoderState::Reported,
1761 None,
1762 Some(&tip),
1763 None,
1764 contract(vec![check("a", "a.txt"), check("b", "b.txt")]),
1765 );
1766 let out = publish_as(&t, &s, Some(&id), "carol").unwrap();
1767 assert_eq!(out["stage"], "polish");
1768
1769 let listed = list(&t.carol, "origin", Some("dave")).unwrap();
1770 let row = &listed["items"][0];
1771 assert_eq!(row["item_id"], json!(id));
1772 assert_eq!(row["ready_to_merge"], true);
1773 assert_eq!(row["eligible"], true, "dave owns no stage");
1774 let listed = list(&t.carol, "origin", Some("bob")).unwrap();
1775 assert_eq!(listed["items"][0]["eligible"], false, "bob owns one");
1776
1777 let worktrees = tempfile::tempdir().unwrap();
1778 let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
1779 .await
1780 .unwrap();
1781 assert_eq!(verdict["mergeable"], true, "{verdict}");
1782 let final_branch = verdict["final_branch"].as_str().unwrap();
1783 assert_eq!(
1785 run(&t.carol, &["rev-parse", &format!("{final_branch}^")]),
1786 t.origin
1787 );
1788 let files = run(&t.carol, &["ls-tree", "-r", "--name-only", final_branch]);
1789 assert!(
1790 files.contains("a.txt") && files.contains("b.txt"),
1791 "{files}"
1792 );
1793 assert!(!files.contains(RECORD_DIR), "{files}");
1794 }
1795
1796 #[tokio::test]
1797 async fn two_stages_are_not_mergeable() {
1798 let t = team();
1799 let id = built(&t);
1800 improved(&t, &id);
1801 let worktrees = tempfile::tempdir().unwrap();
1802 let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
1803 .await
1804 .unwrap();
1805 assert_eq!(verdict["mergeable"], false);
1806 assert!(verdict["final_branch"].is_null());
1807 assert!(
1808 verdict["problems"].to_string().contains("only 2"),
1809 "{verdict}"
1810 );
1811 }
1812
1813 #[tokio::test]
1817 async fn a_hand_edited_record_fails_the_merge_check() {
1818 let t = team();
1819 let id = built(&t);
1820 let tip = fetch_item(&t.bob, "origin", &id).unwrap();
1821 let mut item = read_item(&t.bob, &tip, &id).unwrap();
1822 for (stage, who) in [(Stage::Improve, "bob"), (Stage::Polish, "carol")] {
1823 let mut forged = item.stages[0].clone();
1824 forged.stage = stage;
1825 forged.account_id = who.into();
1826 item.stages.push(forged);
1827 }
1828 run(&t.bob, &["checkout", "-q", "--detach", &tip]);
1829 std::fs::write(
1830 t.bob.join(record_path(&id)),
1831 serde_json::to_string_pretty(&item).unwrap(),
1832 )
1833 .unwrap();
1834 run(&t.bob, &["commit", "-q", "-am", "totally a record commit"]);
1835 run(
1836 &t.bob,
1837 &[
1838 "push",
1839 "-q",
1840 "origin",
1841 &format!("HEAD:refs/heads/{BRANCH_PREFIX}{id}"),
1842 ],
1843 );
1844
1845 let worktrees = tempfile::tempdir().unwrap();
1846 let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
1847 .await
1848 .unwrap();
1849 assert_eq!(verdict["mergeable"], false, "{verdict}");
1850 assert!(
1851 verdict["problems"]
1852 .to_string()
1853 .contains("not a record commit"),
1854 "{verdict}"
1855 );
1856 }
1857
1858 #[test]
1861 fn the_second_publisher_of_a_stage_loses_the_race() {
1862 let t = team();
1863 let id = built(&t);
1864 let (tip_b, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
1865 let (tip_c, _) = prepare_stage(&t.carol, "origin", &id, "carol").unwrap();
1866 assert_eq!(tip_b, tip_c);
1867 let mk = |repo: &Path, tag: &str, file: &str| {
1868 let branch = coder_branch(repo, tag, &tip_b, file);
1869 stage_session(
1870 t.state_dir.path(),
1871 repo,
1872 CoderState::Merged,
1873 Some(&branch),
1874 Some(&tip_b),
1875 None,
1876 contract(vec![check("a", "a.txt")]),
1877 )
1878 };
1879 let bob = mk(&t.bob, "race-b", "b.txt");
1880 let carol = mk(&t.carol, "race-c", "c.txt");
1881 publish_as(&t, &bob, Some(&id), "bob").unwrap();
1882 let err = publish_as(&t, &carol, Some(&id), "carol").unwrap_err();
1883 assert!(
1884 err.contains("must start from the work item's tip") || err.contains("could not push"),
1885 "{err}"
1886 );
1887 }
1888
1889 #[test]
1899 fn a_dirty_checkout_stage_publishes_on_the_commit_its_work_was_parented_on() {
1900 let t = team();
1901 let id = built(&t);
1902 let (tip, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
1903 run(&t.bob, &["checkout", "-q", &tip]);
1906 std::fs::write(t.bob.join("wip.txt"), "the user's uncommitted work").unwrap();
1907 run(&t.bob, &["add", "wip.txt"]);
1908 run(&t.bob, &["commit", "-q", "-m", "car: inputs snapshot"]);
1909 let snapshot = run(&t.bob, &["rev-parse", "HEAD"]);
1910 let branch = coder_branch(&t.bob, "dirty", &tip, "b.txt");
1913
1914 let session = stage_session(
1915 t.state_dir.path(),
1916 &t.bob,
1917 CoderState::Merged,
1918 Some(&branch),
1919 Some(&snapshot),
1920 None,
1921 contract(vec![check("a", "a.txt")]),
1922 );
1923 let mut loaded = load_session(t.state_dir.path(), &session).unwrap();
1924 loaded.state_dir = Some(t.state_dir.path().to_path_buf());
1927 loaded.inputs_snapshot = Some(snapshot.clone());
1928 loaded.persist().unwrap();
1929 assert_eq!(
1930 load_session(t.state_dir.path(), &session)
1931 .unwrap()
1932 .inputs_snapshot
1933 .as_deref(),
1934 Some(snapshot.as_str()),
1935 "the fixture must actually record the snapshot it is testing"
1936 );
1937
1938 let out = publish_as(&t, &session, Some(&id), "bob").unwrap();
1939 assert_eq!(out["stage"], "improve");
1940 let published = run(&t.bob, &["rev-parse", &format!("car/mp/{id}^")]);
1941 assert_eq!(
1942 published,
1943 run(&t.bob, &["rev-parse", &branch]),
1944 "the stage carries the reviewed commit"
1945 );
1946 assert!(
1947 run(&t.bob, &["log", "--format=%H", &format!("car/mp/{id}")])
1948 .lines()
1949 .all(|commit| commit != snapshot),
1950 "the user's snapshot must not ride into the item"
1951 );
1952
1953 let unsnapshotted = stage_session(
1956 t.state_dir.path(),
1957 &t.bob,
1958 CoderState::Merged,
1959 Some(&branch),
1960 Some(&snapshot),
1961 None,
1962 contract(vec![check("a", "a.txt")]),
1963 );
1964 let err = publish_as(&t, &unsnapshotted, Some(&id), "bob").unwrap_err();
1965 assert!(err.contains("has moved since approval"), "{err}");
1966 }
1967
1968 #[tokio::test]
1969 async fn a_stage_done_outside_car_is_verified_before_it_is_recorded() {
1970 let t = team();
1971 let id = built(&t);
1972 let tip = fetch_item(&t.bob, "origin", &id).unwrap();
1973 let worktrees = tempfile::tempdir().unwrap();
1974 fn submit<'a>(
1975 t: &'a Team,
1976 id: &'a str,
1977 commit: &'a str,
1978 additions: Vec<ContractCheck>,
1979 ) -> SubmitRequest<'a> {
1980 SubmitRequest {
1981 repo: &t.bob,
1982 item: id,
1983 commit,
1984 remote: "origin",
1985 account: "bob",
1986 contract_additions: additions,
1987 }
1988 }
1989
1990 let err = submit_stage(submit(&t, &id, &tip, vec![]), worktrees.path())
1992 .await
1993 .unwrap_err();
1994 assert!(err.contains("must change something"), "{err}");
1995
1996 let branch = coder_branch(&t.bob, "own-terminal", &tip, "b.txt");
1998 let head = run(&t.bob, &["rev-parse", &branch]);
1999 let err = submit_stage(
2000 submit(&t, &id, &head, vec![check("c", "c.txt")]),
2001 worktrees.path(),
2002 )
2003 .await
2004 .unwrap_err();
2005 assert!(err.contains("not green") && err.contains("c"), "{err}");
2006
2007 let err = submit_stage(
2009 submit(&t, &id, &head, vec![check("a", "b.txt")]),
2010 worktrees.path(),
2011 )
2012 .await
2013 .unwrap_err();
2014 assert!(err.contains("may only add checks"), "{err}");
2015
2016 let out = submit_stage(
2018 submit(&t, &id, &head, vec![check("b", "b.txt")]),
2019 worktrees.path(),
2020 )
2021 .await
2022 .unwrap();
2023 assert_eq!(out["stage"], "improve");
2024 let item = read_item(&t.bob, out["commit"].as_str().unwrap(), &id).unwrap();
2025 let stage = item.stages.last().unwrap();
2026 assert_eq!(stage.engine, "external-unmanaged");
2027 assert_eq!(stage.session_id, None);
2028 assert_eq!(stage.signals.checks_added, 1);
2029 assert_eq!(item.contract.checks.len(), 2);
2030 }
2031
2032 #[tokio::test]
2037 async fn code_pushed_after_the_last_stage_fails_the_merge_check() {
2038 let t = team();
2039 let id = polished(&t);
2040 let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2041 let sneaky = coder_branch(&t.bob, "after-polish", &tip, "sneaky.txt");
2042 run(
2043 &t.bob,
2044 &[
2045 "push",
2046 "-q",
2047 "origin",
2048 &format!("{sneaky}:refs/heads/{BRANCH_PREFIX}{id}"),
2049 ],
2050 );
2051 let worktrees = tempfile::tempdir().unwrap();
2052 let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
2053 .await
2054 .unwrap();
2055 assert_eq!(verdict["mergeable"], false, "{verdict}");
2056 assert!(verdict["problems"]
2057 .to_string()
2058 .contains("pushed to the branch after"));
2059 assert!(
2060 verdict["checks"].as_array().unwrap().is_empty(),
2061 "no commands ran"
2062 );
2063 }
2064
2065 #[tokio::test]
2068 async fn a_forged_record_commit_that_changes_code_fails_the_merge_check() {
2069 let t = team();
2070 let id = built(&t);
2071 let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2072 let mut item = read_item(&t.bob, &tip, &id).unwrap();
2073 let mut forged = item.stages[0].clone();
2074 forged.stage = Stage::Improve;
2075 forged.account_id = "bob".into();
2076 forged.base_commit = tip.clone();
2077 forged.result_commit = tip.clone();
2078 item.stages.push(forged);
2079 run(&t.bob, &["checkout", "-q", "--detach", &tip]);
2080 std::fs::write(
2081 t.bob.join(record_path(&id)),
2082 serde_json::to_string_pretty(&item).unwrap(),
2083 )
2084 .unwrap();
2085 std::fs::write(t.bob.join("backdoor.txt"), "x").unwrap();
2086 run(&t.bob, &["add", "-A"]);
2087 run(
2088 &t.bob,
2089 &[
2090 "commit",
2091 "-q",
2092 "-m",
2093 &format!("forged\n\nMultiplayer-Item: {id}"),
2094 ],
2095 );
2096 run(
2097 &t.bob,
2098 &[
2099 "push",
2100 "-q",
2101 "origin",
2102 &format!("HEAD:refs/heads/{BRANCH_PREFIX}{id}"),
2103 ],
2104 );
2105 let worktrees = tempfile::tempdir().unwrap();
2106 let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
2107 .await
2108 .unwrap();
2109 assert_eq!(verdict["mergeable"], false);
2110 assert!(
2111 verdict["problems"]
2112 .to_string()
2113 .contains("changes more than the record"),
2114 "{verdict}"
2115 );
2116 }
2117
2118 #[test]
2121 fn a_branch_moved_after_approval_is_refused() {
2122 let t = team();
2123 let branch = coder_branch(&t.alice, "moved", &t.origin, "a.txt");
2124 run(&t.alice, &["checkout", "-q", &branch]);
2125 std::fs::write(t.alice.join("extra.txt"), "later").unwrap();
2126 run(&t.alice, &["add", "extra.txt"]);
2127 run(&t.alice, &["commit", "-q", "-m", "after approval"]);
2128 run(&t.alice, &["checkout", "-q", "--detach"]);
2129 let s = stage_session(
2130 t.state_dir.path(),
2131 &t.alice,
2132 CoderState::Merged,
2133 Some(&branch),
2134 None,
2135 Some(&t.origin),
2136 contract(vec![check("a", "a.txt")]),
2137 );
2138 let err = publish_as(&t, &s, None, "alice").unwrap_err();
2139 assert!(err.contains("moved since approval"), "{err}");
2140 }
2141
2142 #[test]
2145 fn a_build_from_an_unpushed_commit_is_refused() {
2146 let t = team();
2147 std::fs::write(t.alice.join("local.txt"), "unpushed").unwrap();
2148 run(&t.alice, &["add", "local.txt"]);
2149 run(&t.alice, &["commit", "-q", "-m", "local only"]);
2150 let local = run(&t.alice, &["rev-parse", "HEAD"]);
2151 let branch = coder_branch(&t.alice, "from-local", &local, "a.txt");
2152 let s = stage_session(
2153 t.state_dir.path(),
2154 &t.alice,
2155 CoderState::Merged,
2156 Some(&branch),
2157 None,
2158 Some(&local),
2159 contract(vec![check("a", "a.txt")]),
2160 );
2161 let err = publish_as(&t, &s, None, "alice").unwrap_err();
2162 assert!(err.contains("not on `origin`"), "{err}");
2163 }
2164
2165 #[test]
2166 fn a_contract_the_merge_check_could_never_run_is_refused() {
2167 let t = team();
2168 let branch = coder_branch(&t.alice, "baseline", &t.origin, "a.txt");
2169 let mut capture = check("before", "a.txt");
2170 capture.baseline = true;
2171 let s = stage_session(
2172 t.state_dir.path(),
2173 &t.alice,
2174 CoderState::Merged,
2175 Some(&branch),
2176 None,
2177 Some(&t.origin),
2178 contract(vec![check("a", "a.txt"), capture]),
2179 );
2180 let err = publish_as(&t, &s, None, "alice").unwrap_err();
2181 assert!(err.contains("baseline/differential"), "{err}");
2182 }
2183
2184 #[test]
2185 fn remote_names_are_validated() {
2186 let t = team();
2187 for bad in ["-x", "--upload-pack=touch /tmp/pwn", "nope"] {
2188 let err = list(&t.alice, bad, None).unwrap_err();
2189 assert!(
2190 err.contains("invalid remote") || err.contains("no remote named"),
2191 "{bad}: {err}"
2192 );
2193 }
2194 }
2195
2196 #[test]
2199 fn publish_leaves_the_developers_index_and_checkout_alone() {
2200 let t = team();
2201 let branch = coder_branch(&t.alice, "idx", &t.origin, "a.txt");
2202 run(&t.alice, &["checkout", "-q", "main"]);
2203 std::fs::write(t.alice.join("staged.txt"), "mine").unwrap();
2204 run(&t.alice, &["add", "staged.txt"]);
2205 std::fs::write(t.alice.join("README"), "edited, unstaged").unwrap();
2206 let before = (
2207 run(&t.alice, &["diff", "--cached", "--name-only"]),
2208 run(&t.alice, &["status", "--porcelain"]),
2209 run(&t.alice, &["rev-parse", "HEAD"]),
2210 );
2211 let s = stage_session(
2212 t.state_dir.path(),
2213 &t.alice,
2214 CoderState::Merged,
2215 Some(&branch),
2216 None,
2217 Some(&t.origin),
2218 contract(vec![check("a", "a.txt")]),
2219 );
2220 publish_as(&t, &s, None, "alice").unwrap();
2221 let after = (
2222 run(&t.alice, &["diff", "--cached", "--name-only"]),
2223 run(&t.alice, &["status", "--porcelain"]),
2224 run(&t.alice, &["rev-parse", "HEAD"]),
2225 );
2226 assert_eq!(before, after);
2227 }
2228
2229 #[test]
2232 fn the_push_itself_refuses_a_second_record_from_the_same_tip() {
2233 let t = team();
2234 let id = built(&t);
2235 let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2236 let item = read_item(&t.bob, &tip, &id).unwrap();
2237 let with_owner = |who: &str| {
2239 let mut next = item.clone();
2240 let mut stage = next.stages[0].clone();
2241 stage.stage = Stage::Improve;
2242 stage.account_id = who.into();
2243 next.stages.push(stage);
2244 next
2245 };
2246 commit_and_push(&t.bob, "origin", &with_owner("bob"), Stage::Improve, &tip).unwrap();
2247 run(&t.carol, &["fetch", "-q", "origin"]);
2248 let err = commit_and_push(
2249 &t.carol,
2250 "origin",
2251 &with_owner("carol"),
2252 Stage::Improve,
2253 &tip,
2254 )
2255 .unwrap_err();
2256 assert!(err.contains("could not push"), "{err}");
2257 }
2258
2259 #[test]
2262 fn the_record_keeps_the_intent_not_the_stages_prose() {
2263 let t = team();
2264 let id = built(&t);
2265 let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2266 let item = read_item(&t.bob, &tip, &id).unwrap();
2267 assert_eq!(item.contract.description, item.intent);
2268 assert_ne!(item.contract.description, "files exist");
2269 }
2270
2271 #[tokio::test]
2272 async fn a_submission_must_descend_from_the_tip_and_leave_the_record_alone() {
2273 let t = team();
2274 let id = built(&t);
2275 let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2276 let worktrees = tempfile::tempdir().unwrap();
2277 let off_tip = coder_branch(&t.bob, "off-tip", &t.origin, "b.txt");
2278 let off = run(&t.bob, &["rev-parse", &off_tip]);
2279 let err = submit_stage(
2280 SubmitRequest {
2281 repo: &t.bob,
2282 item: &id,
2283 commit: &off,
2284 remote: "origin",
2285 account: "bob",
2286 contract_additions: vec![],
2287 },
2288 worktrees.path(),
2289 )
2290 .await
2291 .unwrap_err();
2292 assert!(err.contains("does not descend"), "{err}");
2293
2294 let touch = coder_branch(&t.bob, "touch-record", &tip, &record_path(&id));
2295 let touched = run(&t.bob, &["rev-parse", &touch]);
2296 let err = submit_stage(
2297 SubmitRequest {
2298 repo: &t.bob,
2299 item: &id,
2300 commit: &touched,
2301 remote: "origin",
2302 account: "bob",
2303 contract_additions: vec![],
2304 },
2305 worktrees.path(),
2306 )
2307 .await
2308 .unwrap_err();
2309 assert!(err.contains("only the runtime writes it"), "{err}");
2310 }
2311
2312 #[test]
2313 fn the_builder_cannot_also_improve() {
2314 let t = team();
2315 let id = built(&t);
2316 let err = prepare_stage(&t.alice, "origin", &id, "alice").unwrap_err();
2317 assert!(err.contains("already owns a stage"), "{err}");
2318 let tip = fetch_item(&t.alice, "origin", &id).unwrap();
2320 let branch = coder_branch(&t.alice, "i2", &tip, "b.txt");
2321 let s = stage_session(
2322 t.state_dir.path(),
2323 &t.alice,
2324 CoderState::Merged,
2325 Some(&branch),
2326 Some(&tip),
2327 None,
2328 contract(vec![check("a", "a.txt")]),
2329 );
2330 let err = publish_as(&t, &s, Some(&id), "alice").unwrap_err();
2331 assert!(err.contains("already owns a stage"), "{err}");
2332 }
2333
2334 #[test]
2335 fn a_stage_may_not_drop_or_change_a_locked_check() {
2336 let t = team();
2337 let id = built(&t);
2338 let (tip, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
2339 let branch = coder_branch(&t.bob, "i3", &tip, "b.txt");
2340 let dropped = stage_session(
2341 t.state_dir.path(),
2342 &t.bob,
2343 CoderState::Merged,
2344 Some(&branch),
2345 Some(&tip),
2346 None,
2347 contract(vec![check("b", "b.txt")]),
2348 );
2349 let err = publish_as(&t, &dropped, Some(&id), "bob").unwrap_err();
2350 assert!(err.contains("drops the locked check `a`"), "{err}");
2351
2352 let changed = stage_session(
2353 t.state_dir.path(),
2354 &t.bob,
2355 CoderState::Merged,
2356 Some(&branch),
2357 Some(&tip),
2358 None,
2359 contract(vec![check("a", "b.txt")]),
2360 );
2361 let err = publish_as(&t, &changed, Some(&id), "bob").unwrap_err();
2362 assert!(err.contains("changes the locked check `a`"), "{err}");
2363 }
2364
2365 #[test]
2366 fn a_stage_must_start_from_the_tip_and_leave_the_record_alone() {
2367 let t = team();
2368 let id = built(&t);
2369 let branch = coder_branch(&t.bob, "i4", &t.origin, "b.txt");
2371 let s = stage_session(
2372 t.state_dir.path(),
2373 &t.bob,
2374 CoderState::Merged,
2375 Some(&branch),
2376 Some(&t.origin),
2377 None,
2378 contract(vec![check("a", "a.txt")]),
2379 );
2380 let err = publish_as(&t, &s, Some(&id), "bob").unwrap_err();
2381 assert!(err.contains("must start from the work item's tip"), "{err}");
2382
2383 let (tip, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
2385 let record = record_path(&id);
2386 let branch = coder_branch(&t.bob, "i5", &tip, &record);
2387 let s = stage_session(
2388 t.state_dir.path(),
2389 &t.bob,
2390 CoderState::Merged,
2391 Some(&branch),
2392 Some(&tip),
2393 None,
2394 contract(vec![check("a", "a.txt")]),
2395 );
2396 let err = publish_as(&t, &s, Some(&id), "bob").unwrap_err();
2397 assert!(err.contains("only the runtime writes it"), "{err}");
2398 }
2399
2400 #[test]
2401 fn build_needs_a_diff_and_a_session_that_finished() {
2402 let t = team();
2403 let s = stage_session(
2404 t.state_dir.path(),
2405 &t.alice,
2406 CoderState::Reported,
2407 None,
2408 None,
2409 Some(&t.origin),
2410 contract(vec![check("a", "a.txt")]),
2411 );
2412 let err = publish_as(&t, &s, None, "alice").unwrap_err();
2413 assert!(err.contains("Build has to build something"), "{err}");
2414
2415 let s = stage_session(
2416 t.state_dir.path(),
2417 &t.alice,
2418 CoderState::NeedsApproval,
2419 None,
2420 None,
2421 Some(&t.origin),
2422 contract(vec![check("a", "a.txt")]),
2423 );
2424 let err = publish_as(&t, &s, None, "alice").unwrap_err();
2425 assert!(err.contains("needs_approval"), "{err}");
2426 }
2427
2428 #[test]
2429 fn a_record_with_an_unknown_field_is_refused() {
2430 let item = json!({
2431 "schema_version": 1, "id": "mp-0123456789abcdef", "repo_root_commit": "r",
2432 "origin_commit": "o", "intent": "i",
2433 "contract": { "description": "d", "checks": [] },
2434 "stages": [], "handoff_notes": "here is why I did it this way"
2435 });
2436 let err = serde_json::from_value::<WorkItem>(item).unwrap_err();
2437 assert!(err.to_string().contains("handoff_notes"), "{err}");
2438 }
2439
2440 #[test]
2441 fn contract_growth_rules() {
2442 let a = contract(vec![check("a", "a.txt")]);
2443 let ab = contract(vec![check("a", "a.txt"), check("b", "b.txt")]);
2444 assert_eq!(contract_grows(&a, &ab), Ok(1));
2445 assert_eq!(contract_grows(&a, &a), Ok(0));
2446 assert!(contract_grows(&ab, &a).is_err());
2447 let mut creds = ab.clone();
2448 creds.allow_credentials = true;
2449 assert!(contract_grows(&ab, &creds)
2450 .unwrap_err()
2451 .contains("credential"));
2452 assert_eq!(
2453 contract_hash(&ab),
2454 contract_hash(&contract(vec![check("b", "b.txt"), check("a", "a.txt")])),
2455 "order-insensitive"
2456 );
2457 assert_ne!(contract_hash(&a), contract_hash(&ab));
2458 }
2459
2460 #[test]
2461 fn item_ids_are_validated_before_reaching_git() {
2462 assert!(valid_item_id("mp-0123456789abcdef"));
2463 for bad in [
2464 "mp-0123",
2465 "mp-0123456789ABCDEF",
2466 "--upload-pack=x",
2467 "mp-0123456789abcdeg",
2468 ] {
2469 assert!(!valid_item_id(bad), "{bad}");
2470 }
2471 }
2472}