Repository navigation
cloud: replay project setup recipes by lockfile hash #16151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b28ac7b
08fc33d
262db81
eda96d9
a7e36fd
1eeb945
3168165
ed47391
885e66d
327d452
917c4a3
ec6353d
5e7e2be
56d7001
361f02b
0699f6f
ce76610
5a8f0b2
32ec0aa
c585d4a
52b2b08
f971c14
2b05695
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||
| import CryptoKit | ||||||||||||||||||||||||||||||||||||||
| import Foundation | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /// `cmux vm dev`: one command from a local folder to a running dev layout on a | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -56,7 +57,7 @@ extension CMUXCLI { | |||||||||||||||||||||||||||||||||||||
| \(openFocusDefaultHelp) | ||||||||||||||||||||||||||||||||||||||
| --dry-run Print the plan (detection, remote path, layout) without touching anything. | ||||||||||||||||||||||||||||||||||||||
| --json {machine, workspace_id, local_workspace_id, workspace_name, existing, local, | ||||||||||||||||||||||||||||||||||||||
| remote, synced, command, port, url, terminals: {dev, shell}, layout_applied, opened} | ||||||||||||||||||||||||||||||||||||||
| remote, synced, command, recipe, port, url, terminals: {dev, shell}, layout_applied, opened} | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Examples: | ||||||||||||||||||||||||||||||||||||||
| cmux vm dev brave-otter # this folder → brave-otter, layout opened here | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -82,6 +83,77 @@ extension CMUXCLI { | |||||||||||||||||||||||||||||||||||||
| static let unrecognized = VMDevDetection(kind: "none", command: nil, port: nil, detail: "no package.json, Cargo.toml, go.mod, Makefile dev target, manage.py, pyproject.toml, requirements.txt, or index.html here") | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /// A checked-in `.cmux/cloud.json` recipe. Values are intentionally plain | ||||||||||||||||||||||||||||||||||||||
| /// commands and named ports; secrets stay in `cmux vm env`. | ||||||||||||||||||||||||||||||||||||||
| struct VMDevRecipe: Equatable { | ||||||||||||||||||||||||||||||||||||||
| let setup: [String] | ||||||||||||||||||||||||||||||||||||||
| let checks: [String] | ||||||||||||||||||||||||||||||||||||||
| let lockHash: String? | ||||||||||||||||||||||||||||||||||||||
| let source: String | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| static func vmDevRecipe(in directory: URL) -> VMDevRecipe? { | ||||||||||||||||||||||||||||||||||||||
| let url = directory.appendingPathComponent(".cmux/cloud.json") | ||||||||||||||||||||||||||||||||||||||
| guard let data = try? Data(contentsOf: url), | ||||||||||||||||||||||||||||||||||||||
| let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | ||||||||||||||||||||||||||||||||||||||
| let setup = object["setup"] as? [String], | ||||||||||||||||||||||||||||||||||||||
| setup.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return nil } | ||||||||||||||||||||||||||||||||||||||
| let checks = (object["checks"] as? [String]) ?? [] | ||||||||||||||||||||||||||||||||||||||
| // Include every supported lockfile in the digest. A changed lockfile | ||||||||||||||||||||||||||||||||||||||
| // therefore invalidates the materialized setup on the next `vm dev`. | ||||||||||||||||||||||||||||||||||||||
| let lockfiles = ["bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "package-lock.json", "uv.lock", "poetry.lock", "Cargo.lock", "go.sum"] | ||||||||||||||||||||||||||||||||||||||
| var digestInput = Data("cmux-cloud-recipe-v1\0".utf8) | ||||||||||||||||||||||||||||||||||||||
| for command in setup { | ||||||||||||||||||||||||||||||||||||||
| digestInput.append(Data(command.utf8)); digestInput.append(0) | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| for check in checks { | ||||||||||||||||||||||||||||||||||||||
| digestInput.append(Data("check\0".utf8)); digestInput.append(Data(check.utf8)); digestInput.append(0) | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+105
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '87,156p' CLI/CMUXCLI+VMDev.swiftRepository: manaflow-ai/cmux Length of output: 4296 🏁 Script executed: set -o pipefail
printf '%s\n' '--- symbols and callers ---'
rg -n -C 4 'vmDevRecipe|vmDevSetupCommand|cloud\.json|ready|checks|recipe' CLI/CMUXCLI+VMDev.swift CLI 2>/dev/null | head -n 260
printf '%s\n' '--- surrounding file outline ---'
ast-grep outline CLI/CMUXCLI+VMDev.swift
printf '%s\n' '--- relevant diff ---'
git diff --stat 70e997fb47b0f9315ca716ba93f981788b6e6519 f971c14856648d9b6a281c394d20f007897a7318 -- CLI/CMUXCLI+VMDev.swift
git diff --unified=30 70e997fb47b0f9315ca716ba93f981788b6e6519 f971c14856648d9b6a281c394d20f007897a7318 -- CLI/CMUXCLI+VMDev.swift | sed -n '1,320p'
printf '%s\n' '--- focused tests and docs ---'
rg -n -C 4 'cloud\.json|vm dev|checks|setup|recipe' --glob '*Tests*' --glob '*.swift' --glob '*.md' --glob '*.json' . 2>/dev/null | head -n 260Repository: manaflow-ai/cmux Length of output: 41918 Frame setup and checks as separate arrays in the recipe digest.
Suggested fix var digestInput = Data("cmux-cloud-recipe-v1\0".utf8)
+ func appendFramed(_ value: Data) {
+ digestInput.append(Data("\(value.count):".utf8))
+ digestInput.append(value)
+ }
+ digestInput.append(Data("setup:\(setup.count);".utf8))
for command in setup {
- digestInput.append(Data(command.utf8)); digestInput.append(0)
+ appendFramed(Data(command.utf8))
}
+ digestInput.append(Data("checks:\(checks.count);".utf8))
for check in checks {
- digestInput.append(Data("check\0".utf8)); digestInput.append(Data(check.utf8)); digestInput.append(0)
+ appendFramed(Data(check.utf8))
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| for name in lockfiles { | ||||||||||||||||||||||||||||||||||||||
| let lock = directory.appendingPathComponent(name) | ||||||||||||||||||||||||||||||||||||||
| guard let bytes = try? Data(contentsOf: lock) else { continue } | ||||||||||||||||||||||||||||||||||||||
| digestInput.append(Data(name.utf8)); digestInput.append(0); digestInput.append(bytes); digestInput.append(0) | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+104
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- diff stat ---'
git diff --stat 70e997fb47b0f9315ca716ba93f981788b6e6519 f971c14856648d9b6a281c394d20f007897a7318 -- CLI/CMUXCLI+VMDev.swift
printf '%s\n' '--- changed diff ---'
git diff --unified=80 70e997fb47b0f9315ca716ba93f981788b6e6519 f971c14856648d9b6a281c394d20f007897a7318 -- CLI/CMUXCLI+VMDev.swift
printf '%s\n' '--- file outline ---'
ast-grep outline CLI/CMUXCLI+VMDev.swift
printf '%s\n' '--- relevant references ---'
rg -n -C 5 'sync|lockHash|lock hash|ready|marker|setup|checks|digestInput|flock|cloud\.json' CLI/CMUXCLI+VMDev.swiftRepository: manaflow-ai/cmux Length of output: 42025 🏁 Script executed: set -eu
git diff --stat 70e997fb47b0f9315ca716ba93f981788b6e6519 f971c14856648d9b6a281c394d20f007897a7318 -- CLI/CMUXCLI+VMDev.swift
git diff --unified=80 70e997fb47b0f9315ca716ba93f981788b6e6519 f971c14856648d9b6a281c394d20f007897a7318 -- CLI/CMUXCLI+VMDev.swift
ast-grep outline CLI/CMUXCLI+VMDev.swift
rg -n -C 5 'sync|lockHash|lock hash|ready|marker|setup|checks|digestInput|flock|cloud\.json' CLI/CMUXCLI+VMDev.swiftRepository: manaflow-ai/cmux Length of output: 42123 🌐 Web query:
💡 Result: Hash lockfiles from the remote tree when
When sync is disabled, compute the lockfile digest inside the remote lock body from the remote project path. Otherwise, require 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| let hash = SHA256.hash(data: digestInput).map { String(format: "%02x", $0) }.joined() | ||||||||||||||||||||||||||||||||||||||
| return VMDevRecipe(setup: setup, checks: checks, lockHash: hash, source: ".cmux/cloud.json") | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| static func vmDevSetupCommand(_ recipe: VMDevRecipe, remote: String) -> String { | ||||||||||||||||||||||||||||||||||||||
| guard let hash = recipe.lockHash else { return ":" } | ||||||||||||||||||||||||||||||||||||||
| // The same recipe can be used by two checkouts on one machine. Include | ||||||||||||||||||||||||||||||||||||||
| // the remote project path in the cache scope so an install for one | ||||||||||||||||||||||||||||||||||||||
| // checkout never suppresses setup for another. | ||||||||||||||||||||||||||||||||||||||
| let scope = SHA256.hash(data: Data("\(remote)\0\(hash)".utf8)).map { String(format: "%02x", $0) }.joined() | ||||||||||||||||||||||||||||||||||||||
| let root = "$HOME/.cache/cmux/setup/\(scope)" | ||||||||||||||||||||||||||||||||||||||
| let marker = "\(root)/ready" | ||||||||||||||||||||||||||||||||||||||
| let lock = "\(root)/lock" | ||||||||||||||||||||||||||||||||||||||
| let setup = recipe.setup | ||||||||||||||||||||||||||||||||||||||
| .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } | ||||||||||||||||||||||||||||||||||||||
| .map { "(\($0))" } | ||||||||||||||||||||||||||||||||||||||
| .joined(separator: " && ") | ||||||||||||||||||||||||||||||||||||||
| let run = setup.isEmpty ? ":" : setup | ||||||||||||||||||||||||||||||||||||||
| let checksRun = recipe.checks | ||||||||||||||||||||||||||||||||||||||
| .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } | ||||||||||||||||||||||||||||||||||||||
| .map { "(\($0))" } | ||||||||||||||||||||||||||||||||||||||
| .joined(separator: " && ") | ||||||||||||||||||||||||||||||||||||||
| let verify = checksRun.isEmpty ? ":" : checksRun | ||||||||||||||||||||||||||||||||||||||
| // `flock` is provided by util-linux in every devbox image. Holding the | ||||||||||||||||||||||||||||||||||||||
| // descriptor for the whole check/install/marker transaction means a | ||||||||||||||||||||||||||||||||||||||
| // killed owner cannot leave a stale directory that blocks future runs; | ||||||||||||||||||||||||||||||||||||||
| // the marker is checked again after lock acquisition so a waiter never | ||||||||||||||||||||||||||||||||||||||
| // replays a recipe that another owner completed while it was waiting. | ||||||||||||||||||||||||||||||||||||||
| // Run the complete check/install/marker transaction as the lock child so | ||||||||||||||||||||||||||||||||||||||
| // concurrent dev invocations serialize and killed owners are reclaimed | ||||||||||||||||||||||||||||||||||||||
| // by the kernel. Use pathname mode: `flock 9 ... 9>lock` treats `9` as | ||||||||||||||||||||||||||||||||||||||
| // a pathname on util-linux when a command follows it, rather than as | ||||||||||||||||||||||||||||||||||||||
| // the descriptor we intended. The body is single-quoted for `/bin/sh | ||||||||||||||||||||||||||||||||||||||
| // -c`; escape any recipe quotes without changing their meaning inside | ||||||||||||||||||||||||||||||||||||||
| // the nested shell. | ||||||||||||||||||||||||||||||||||||||
| let body = "if [ -f \"\(marker)\" ]; then :; else \(run) && \(verify) && : > \"\(marker)\"; fi" | ||||||||||||||||||||||||||||||||||||||
| let quotedBody = "'" + body.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" | ||||||||||||||||||||||||||||||||||||||
| return "mkdir -p \"\(root)\" && flock \"\(lock)\" /bin/sh -c \(quotedBody)" | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /// Framework → default dev port, decided from the script's words (what the author | ||||||||||||||||||||||||||||||||||||||
| /// actually runs: `next dev`, `bunx vite --host`, `ng serve`) and, when the script | ||||||||||||||||||||||||||||||||||||||
| /// names no framework, from the dependencies. Whole tokens only, so `expose-gc` | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -448,7 +520,16 @@ extension CMUXCLI { | |||||||||||||||||||||||||||||||||||||
| let remote = options.remote ?? "work/\(basename)" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| let detection = Self.detectVMDevProject(in: localURL) | ||||||||||||||||||||||||||||||||||||||
| let command = options.command ?? detection.command | ||||||||||||||||||||||||||||||||||||||
| let recipe = Self.vmDevRecipe(in: localURL) | ||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Recipe-only projects default to Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||
| let detectedCommand = options.command ?? detection.command | ||||||||||||||||||||||||||||||||||||||
| let command: String? | ||||||||||||||||||||||||||||||||||||||
| if let recipe, let detectedCommand { | ||||||||||||||||||||||||||||||||||||||
| command = "\(Self.vmDevSetupCommand(recipe, remote: remote)) && \(detectedCommand)" | ||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||
| } else if let recipe { | ||||||||||||||||||||||||||||||||||||||
| command = Self.vmDevSetupCommand(recipe, remote: remote) | ||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||
| command = detectedCommand | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| let port: Int? | ||||||||||||||||||||||||||||||||||||||
| if let explicit = options.port { | ||||||||||||||||||||||||||||||||||||||
| port = explicit | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -499,6 +580,7 @@ extension CMUXCLI { | |||||||||||||||||||||||||||||||||||||
| "open": !options.noOpen, | ||||||||||||||||||||||||||||||||||||||
| "detected": ["kind": detection.kind, "detail": detection.detail], | ||||||||||||||||||||||||||||||||||||||
| "command": Self.vmDevJSON(command), | ||||||||||||||||||||||||||||||||||||||
| "recipe": Self.vmDevJSON(recipe.map { ["source": $0.source, "setup": $0.setup, "checks": $0.checks, "lock_hash": Self.vmDevJSON($0.lockHash)] }), | ||||||||||||||||||||||||||||||||||||||
| "port": Self.vmDevJSON(port), | ||||||||||||||||||||||||||||||||||||||
| "layout": Self.vmDevJSON(documentObject), | ||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -664,6 +746,7 @@ extension CMUXCLI { | |||||||||||||||||||||||||||||||||||||
| "synced": sync, | ||||||||||||||||||||||||||||||||||||||
| "detected": ["kind": detection.kind, "detail": detection.detail], | ||||||||||||||||||||||||||||||||||||||
| "command": Self.vmDevJSON(command), | ||||||||||||||||||||||||||||||||||||||
| "recipe": Self.vmDevJSON(recipe.map { ["source": $0.source, "setup": $0.setup, "checks": $0.checks, "lock_hash": Self.vmDevJSON($0.lockHash)] }), | ||||||||||||||||||||||||||||||||||||||
| "port": Self.vmDevJSON(port), | ||||||||||||||||||||||||||||||||||||||
| "url": Self.vmDevJSON(publicURL), | ||||||||||||||||||||||||||||||||||||||
| "terminals": ["dev": Self.vmDevJSON(terminals["dev"]), "shell": Self.vmDevJSON(terminals["shell"])], | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: The recipe guard behaves inconsistently for empty/checks-only recipes.
allSatisfyis vacuously true for an empty array, so"setup": []is accepted as a recipe (marker machinery runs, dev command is wrapped in a no-op setup prefix), while a recipe with onlychecksand nosetupkey is silently dropped and falls back to auto-detection — even though the docs say the slice "acceptssetupandchecks". Reject an empty setup array explicitly (or accept checks-only recipes) so presence of.cmux/cloud.jsonalone decides whether the recipe is honored.Prompt for AI agents