Documentation
¶
Overview ¶
ETH-2 "transactional escalation": the container-side execution surface of a three-phase transaction the platform drives against a workspace container — push deltas (browser→container), run a command, pull deltas (container→browser). The browser keeps a WASM editing plane; this package is the executor the daemon exposes over HTTP and the CLI wraps.
The JSON shapes below are PINNED CONTRACTS — field names, nesting and zero-values must not change without coordinating with the platform parser (see docs/txn-protocol.md, the cross-repo contract doc).
Everything here is intentionally dependency-free: it shells out to git, touches no configuration, no LLM and no agent state, so the same entry points serve the CLI (`sprout txn-status` …) and the daemon (running in-container as root against /work) with identical output.
Index ¶
Constants ¶
const ( // MaxRequestBytes is the HTTP request-body cap the daemon enforces on // /api/txn/push — the transport-level mirror of MaxTotalBytes. MaxRequestBytes = 100 << 20 // MaxOutputBytes is the rolling per-stream (stdout, stderr) cap kept // from a run: the LAST 256 KiB of each stream. MaxOutputBytes = 256 << 10 // DefaultTimeoutSeconds is used when a run request omits or zeroes the // timeout. DefaultTimeoutSeconds = 600 // MaxTimeoutSeconds is the hard ceiling on a requested timeout. MaxTimeoutSeconds = 900 // TimeoutExitCode is the exit_code reported for a timed-out run (the // GNU timeout convention). TimeoutExitCode = 124 // StartFailureExitCode is reported when /bin/sh could not even be // started (missing workdir, exec failure) — POSIX 126, "found but not // executable"-adjacent. StartFailureExitCode = 126 // DefaultFileMode and DefaultDirMode are used when a manifest omits // "mode". DefaultFileMode = 0o644 DefaultDirMode = 0o755 )
Remaining caps and defaults. Consts: nothing needs to vary them in tests.
const ( SkipReasonEmptyPath = "empty_path" SkipReasonNulInPath = "nul_in_path" SkipReasonAbsolutePath = "absolute_path" SkipReasonPathTraversal = "path_traversal" SkipReasonGitPath = "git_path" SkipReasonInvalidPath = "invalid_path" SkipReasonInvalidBase64 = "invalid_base64" SkipReasonInvalidMode = "invalid_mode" SkipReasonExceedsPerFile = "exceeds_per_file_cap" SkipReasonExceedsFileCount = "exceeds_file_count_cap" SkipReasonExceedsTotal = "exceeds_total_cap" SkipReasonSymlinkEscape = "symlink_escape" SkipReasonWriteFailed = "write_failed" SkipReasonDeleteFailed = "delete_failed" SkipReasonDeleteMissing = "delete_missing" SkipReasonNotAFile = "not_a_file" SkipReasonSymlink = "symlink" SkipReasonReadFailed = "read_failed" )
Skip reasons. "reason" is a free-form string in the wire contract, but the values below are the pinned vocabulary (docs/txn-protocol.md); the platform matches on them to explain a partial apply/pull in the UI.
const ( // StatusOK: every entry in the request was applied (push) or the // manifest describes the whole tree (pull). StatusOK = "ok" // StatusPartial: at least one entry was skipped. StatusPartial = "partial" )
Apply/pull statuses.
const ( TxnClientWASM = "wasm" TxnClientContainer = "container" )
ApplyStatus values.
Variables ¶
var ( // MaxFileBytes is the per-file decoded-content cap (5 MiB). MaxFileBytes = 5 << 20 // MaxFileCount is the per-manifest file cap. MaxFileCount = 2000 // MaxTotalBytes is the per-manifest total decoded cap (100 MiB). MaxTotalBytes = 100 << 20 )
Caps and defaults of the contract. They bound both directions: a push manifest may not exceed them, and a pull manifest reports over-cap entries in "skipped" instead of inlining them.
The three manifest caps are vars (not consts) for the same reason pkg/git.SyncGitTimeout is: tests tighten them rather than allocating a real 100 MiB fixture. Their DEFAULT values below are the contract — TestContractCapsIsPinned guards them against drift.
var TxnGitTimeout = 10 * time.Second
TxnGitTimeout bounds each individual git invocation BuildStatus/BuildPull makes. It is a var (not a const) so tests can tighten or relax it.
Functions ¶
This section is empty.
Types ¶
type ApplyResult ¶
type ApplyResult struct {
Applied int `json:"applied"`
Deleted int `json:"deleted"`
Skipped []SkippedEntry `json:"skipped"`
Status string `json:"status"`
}
ApplyResult is the pinned POST /api/txn/push response.
{"applied": 3, "deleted": 1, "skipped": [...], "status": "ok"|"partial"}
func ApplyDelta ¶
func ApplyDelta(ctx context.Context, workdir string, manifest DeltaManifest) (ApplyResult, error)
ApplyDelta writes a push manifest into the workdir: parents are created as needed (0755), file contents land with their requested mode (0644 when absent), then deletes are processed.
Skipping is per-entry and never fails the request: an unsafe path, a bad base64 blob, a cap violation or an unwritable target is recorded in result.Skipped with its reason and the rest of the manifest still lands. result.Status is "partial" iff anything was skipped.
Only a catastrophic failure — an unresolvable workdir — returns an error, in which case nothing was applied.
type DeltaBase ¶
DeltaBase identifies what a manifest was computed against. On a push it is the browser's editing base; on a pull it is the container's HEAD.
type DeltaFile ¶
type DeltaFile struct {
Path string `json:"path"`
ContentBase64 string `json:"content_base64"`
Size int `json:"size"`
Mode string `json:"mode"`
}
DeltaFile is one file entry of a manifest. Size is the decoded byte count (advisory on push: the container decodes and measures for itself).
type DeltaManifest ¶
type DeltaManifest struct {
Base DeltaBase `json:"base"`
Files []DeltaFile `json:"files"`
Deletes []string `json:"deletes"`
Truncated bool `json:"truncated"`
Skipped []SkippedEntry `json:"skipped"`
}
DeltaManifest is the pinned shape #1: the push request body and the pull response body.
{
"base": {"git_sha": "", "client": "wasm"},
"files": [{"path": "src/main.go", "content_base64": "...", "size": 123, "mode": "0644"}],
"deletes": ["old.go"],
"truncated": false,
"skipped": [{"path": "x.bin", "reason": "exceeds_per_file_cap"}]
}
Paths are repo-relative with forward slashes. Files/Deletes/Skipped are always arrays on the wire (never null).
func BuildPull ¶
func BuildPull(ctx context.Context, workdir string) (DeltaManifest, error)
BuildPull computes shape #1 from the working tree: every dirty tracked file (contents base64-encoded) plus every untracked file, with deleted tracked files listed in "deletes". It NEVER touches the working tree — no add, no stash, no reset — so a pull is safe to run at any point in a transaction, including after a failed run.
Caps are honored by omission: an over-cap entry lands in "skipped" with its reason and sets "truncated", so the platform knows the manifest does not fully describe the tree and can fall back to a narrower sync. Paths that cannot be read (permissions, a symlink, a directory) are skipped the same way rather than failing the manifest.
type RunRequest ¶
type RunRequest struct {
Command string `json:"command"`
TimeoutSeconds int `json:"timeout_seconds"`
Workdir string `json:"workdir"`
}
RunRequest is the pinned POST /api/txn/run request body.
{"command": "go build ./...", "timeout_seconds": 600, "workdir": ""}
type RunResult ¶
type RunResult struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationMs int64 `json:"duration_ms"`
TimedOut bool `json:"timed_out"`
Truncated bool `json:"truncated"`
}
RunResult is the pinned POST /api/txn/run response.
{"stdout": "...", "stderr": "...", "exit_code": 0, "duration_ms": 1234,
"timed_out": false, "truncated": false}
func RunCommand ¶
RunCommand executes shape #2: `command` runs under /bin/sh -c with the workdir as cwd and the inherited environment, and its two output streams are captured separately, each capped to the last 256 KiB.
The timeout is the ONLY canceller. ctx is deliberately not wired into the subprocess: a client hangup (or a cancelled request) must not SIGKILL a command mid-write, so the daemon calls this under context.WithoutCancel and the process group is killed only when the timeout fires. Killing the group — not just the shell — is what makes compiler children die with it.
Every outcome is reportable: a non-zero exit, a timeout (exit_code 124, timed_out true) and even a failure to start (exit_code 126, the error in stderr) come back as a result, never as a Go error. Only an unresolvable workdir returns one.
type SkippedEntry ¶
SkippedEntry records one manifest entry that was not transferred, and why.
type Status ¶
type Status struct {
InGitRepo bool `json:"in_git_repo"`
Branch string `json:"branch"`
DirtyFiles []string `json:"dirty_files"`
UntrackedFiles []string `json:"untracked_files"`
DeletedFiles []string `json:"deleted_files"`
TotalChanges int `json:"total_changes"`
Timestamp string `json:"timestamp"`
}
Status is the pinned shape #3 (GET /api/txn/status, `sprout txn-status`).
{
"in_git_repo": true, "branch": "main",
"dirty_files": ["a.go"], "untracked_files": ["b.out"], "deleted_files": ["c.go"],
"total_changes": 3, "timestamp": "RFC3339"
}
func BuildStatus ¶
BuildStatus reports shape #3: the working-tree state a transaction is about to run against. It is strictly read-only — one `git status` and one branch probe, no fetch, no write.
"Not a git repository" is a reportable state (InGitRepo=false, empty lists, timestamp set), not an error; only a catastrophic failure (unreadable directory, broken git, corrupt index) returns a non-nil error. A porcelain failure inside a real repo is catastrophic too: a status the caller cannot trust must not be laundered into "clean".