Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ agents can react without launching a diagnostic chain on every blip:
### `okdev sync [--mode up|down|bi] [--foreground] [--reset] [--dry-run]`

- Advanced command. Starts detached background sync by default; use `--foreground` for sync debugging, or explicit one-way sync (`up`/`down`).
- `--mode` defaults to the configured path's `direction` (`spec.sync.paths[].direction`, falling back to `bi`); an explicit flag overrides it for that invocation. See the config manifest for choosing a persistent direction — `down` makes the pod the authority so local writes can never clobber pod-generated results.
- For default `--mode bi`, no-op when background sync is already active for the session.
- `--background`: explicitly request detached background mode.
- `--reset`: check local-to-hub sync and mesh receiver health, then reset only what is broken. Skips the local sync teardown when the primary sync is already healthy. For sessions with mesh receivers, probes each receiver and re-runs mesh setup only when broken or disconnected receivers are found.
Expand Down
25 changes: 23 additions & 2 deletions docs/config-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,8 @@ spec:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `engine` | `string` | — | Sync engine (currently only `syncthing`) |
| `paths` | `[]string` | — | Mappings in `local:remote` format (max 1 entry) |
| `paths` | `[]entry` | — | Sync mappings (max 1 entry). Each entry is either the compact string `local:remote`, or a mapping `{local, remote, direction}` with an optional per-path `direction` |
| `paths[].direction` | `string` | `bi` | Sync direction for this mapping's folder: `bi` (bidirectional), `up` (local is the authority: local sendonly, pod receiveonly), `down` (pod is the authority: pod sendonly, local receiveonly) |
| `syncthing.version` | `string` | `v1.29.7` | Local Syncthing binary version |
| `syncthing.autoInstall` | `bool` | `true` | Auto-install local Syncthing |
| `syncthing.image` | `string` | `ghcr.io/acmore/okdev:<version>` | Sidecar image (fallback: `edge`) |
Expand All @@ -242,7 +243,27 @@ spec:
| `syncthing.compression` | `bool` | `false` | Use Syncthing `always` compression for peer connections instead of the default `metadata` mode |
| `syncthing.versioningDays` | `int` | `30` | Keep files overwritten or deleted by sync in `.stversions` for this many days (staggered versioning, both sides); `0` disables |

**Validation:** `engine` must be `syncthing`; each `paths[]` entry must be `local:remote`; `versioningDays` must be `>= 0`.
**Validation:** `engine` must be `syncthing`; each `paths[]` entry must have non-empty local and remote; `direction` must be `bi`, `up`, or `down`; `versioningDays` must be `>= 0`.

**Sync direction.** `direction` applies to a mapping's whole synced folder — finer per-subpath direction inside one folder is impossible with Syncthing (ignore rules live in the directory itself; per-path folder types were rejected upstream). Pick the direction by what the folder carries:

- `bi` (default): source code you edit on both sides. Either side can overwrite the other, so **keep generated outputs (results, checkpoints, logs) out of the synced path** — write them to a non-synced location and fetch with `okdev cp` or `okdev jobs logs`. Versioning (`.stversions/`) is the safety net, not the design.
- `up`: the folder is a code drop. Nothing the pod writes can ever touch local files; pod-side writes into the folder are flagged by Syncthing but not propagated.
- `down`: the folder carries pod-generated results. A stray local write (for example an empty file from a failed redirect) can never clobber the pod's data. `okdev up --reset-workspace` and `okdev sync reset-remote` are rejected in this direction because they reseed the remote from local.

An explicit `okdev sync --mode` overrides the configured direction for that invocation. Changing `direction` takes effect on the next `okdev up` (it restarts sync with the new folder types). Mesh receiver pods are always receiveonly regardless of direction.

```yaml
# compact form (direction defaults to bi):
paths:
- .:/workspace

# structured form with an explicit direction:
paths:
- local: .
remote: /workspace
direction: up
```

Local ignore rules come from the synced workspace's `.stignore`. `okdev init` writes a starter `.stignore` for built-in templates, and `okdev up` creates one with default patterns if the local sync root does not already have one. Editing `.stignore` takes effect automatically as Syncthing notices the change, but it does not remove files that were already synced to the remote workspace. For faster initial syncs, consider ignoring large generated build outputs or local test artifacts such as `debug/`, `release/`, caches, and dataset directories when they do not need to exist remotely.

Expand Down
9 changes: 9 additions & 0 deletions internal/cli/status_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ type detailedStatusSSH struct {

type detailedStatusSync struct {
Engine string `json:"engine"`
Direction string `json:"direction,omitempty"`
Health string `json:"health,omitempty"`
HealthDetail string `json:"healthDetail,omitempty"`
ConfiguredPaths []string `json:"configuredPaths,omitempty"`
Expand Down Expand Up @@ -420,8 +421,13 @@ func buildDetailedSync(sessionName string, cfg *config.DevEnvironment, cfgPath s
if engine == "" {
engine = "syncthing"
}
direction := ""
if pairs, err := syncengine.ParsePairs(cfg.Spec.Sync.Paths, cfg.EffectiveWorkspaceMountPath(cfgPath)); err == nil && len(pairs) > 0 {
direction = pairs[0].Direction
}
detail := detailedStatusSync{
Engine: engine,
Direction: direction,
ConfiguredPaths: summarizeConfiguredSyncPaths(cfg, cfgPath),
}
if engine != "syncthing" {
Expand Down Expand Up @@ -593,6 +599,9 @@ func printDetailedStatus(w io.Writer, detail detailedStatus) {

fmt.Fprintln(w, "\nSync:")
fmt.Fprintf(w, "- engine: %s\n", detail.Sync.Engine)
if detail.Sync.Direction != "" {
fmt.Fprintf(w, "- direction: %s (%s)\n", detail.Sync.Direction, modeSymbol(detail.Sync.Direction))
}
if detail.Sync.BackgroundStatus != "" {
status := detail.Sync.BackgroundStatus
if detail.Sync.BackgroundPID > 0 {
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/status_details_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestGatherDetailedStatusIncludesDiagnostics(t *testing.T) {
if err := os.WriteFile(conflictPath, []byte("conflict"), 0o644); err != nil {
t.Fatalf("write conflict file: %v", err)
}
cfg.Spec.Sync.Paths = []string{syncRoot + ":/workspace"}
cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: syncRoot, Remote: "/workspace"}}
cfg.Spec.Ports = []config.PortMapping{{Name: "http", Local: 8080, Remote: 80}}
cfg.Spec.Agents = []config.AgentSpec{{Name: "codex", Auth: &config.AgentAuth{LocalPath: "~/.codex/auth.json"}}}

Expand Down Expand Up @@ -347,6 +347,7 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
},
Sync: detailedStatusSync{
Engine: "syncthing",
Direction: "down",
BackgroundStatus: "running",
BackgroundPID: 123,
ConfiguredPaths: []string{"/tmp/repo -> /workspace"},
Expand Down Expand Up @@ -391,6 +392,7 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
"SSH:",
"managed forward: running",
"Sync:",
"direction: down (<-)",
"background: running (pid 123)",
"conflicts: 2",
"./a.sync-conflict-1",
Expand Down
33 changes: 32 additions & 1 deletion internal/cli/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"syscall"
"time"

"github.com/acmore/okdev/internal/config"
"github.com/acmore/okdev/internal/kube"
"github.com/acmore/okdev/internal/logx"
"github.com/acmore/okdev/internal/session"
Expand Down Expand Up @@ -88,6 +89,10 @@ func newSyncCmd(opts *Options) *cobra.Command {
if err != nil {
return err
}
mode = resolveSyncMode(cmd.Flags().Changed("mode"), mode, pairs[0].Direction)
if err := validateSyncMode(mode); err != nil {
return err
}
if dryRun {
fmt.Fprintf(cmd.OutOrStdout(), "DRY RUN: sync session=%s namespace=%s engine=%s mode=%s\n", cc.sessionName, cc.namespace, engine, mode)
fmt.Fprintf(cmd.OutOrStdout(), "- paths: %v\n", pairs)
Expand Down Expand Up @@ -166,7 +171,7 @@ func newSyncCmd(opts *Options) *cobra.Command {
},
}

cmd.Flags().StringVar(&mode, "mode", "bi", "Sync mode: up|down|bi")
cmd.Flags().StringVar(&mode, "mode", "bi", "Sync mode: up|down|bi (default: spec.sync.direction, or bi)")
cmd.Flags().BoolVar(&background, "background", true, "Run syncthing sync as a detached background process")
cmd.Flags().BoolVar(&foreground, "foreground", false, "Run syncthing sync in the foreground for troubleshooting")
cmd.Flags().BoolVar(&reset, "reset", false, "Check sync health and reset broken components")
Expand All @@ -179,6 +184,26 @@ func newSyncCmd(opts *Options) *cobra.Command {
return cmd
}

// resolveSyncMode picks the effective sync mode: an explicit --mode always
// wins; otherwise the configured spec.sync.direction applies.
func resolveSyncMode(flagSet bool, flagMode, configDirection string) string {
if flagSet {
return flagMode
}
return configDirection
}

// validateSyncMode accepts the documented modes plus the internal
// "two-phase" bootstrap mode that `okdev up` spawns for fresh sessions.
func validateSyncMode(mode string) error {
switch mode {
case "up", "down", "bi", "two-phase":
return nil
default:
return fmt.Errorf("invalid sync mode %q: must be one of up, down, bi", mode)
}
}

func newSyncResetRemoteCmd(opts *Options) *cobra.Command {
return &cobra.Command{
Use: "reset-remote",
Expand All @@ -204,6 +229,12 @@ synced workspace subtree is cleared.`,
if len(pairs) != 1 {
return fmt.Errorf("reset-remote requires exactly one sync path mapping, got %d", len(pairs))
}
if pairs[0].Direction == config.SyncDirectionDown {
// reset-remote clears the remote and reseeds it from local;
// with direction "down" the pod is the authority and local
// is receiveonly — the wipe would sync back as deletions.
return fmt.Errorf("sync reset-remote conflicts with sync direction \"down\" (the pod is the sync authority)")
}

target, err := resolveTargetRef(cmd.Context(), cc.opts, cc.cfg, cc.namespace, cc.sessionName, cc.kube)
if err != nil {
Expand Down
41 changes: 41 additions & 0 deletions internal/cli/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,47 @@ func TestSyncthingSessionConfigHashTracksSyncSettings(t *testing.T) {
}
}

func TestSyncthingSessionConfigHashTracksDirection(t *testing.T) {
cfg := &config.DevEnvironment{}
cfg.SetDefaults()
base := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace")

// Explicit "bi" hashes identically to the legacy empty value so
// upgrading okdev (or spelling out the default) does not force a sync
// restart on existing sessions.
cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: ".", Remote: "/workspace", Direction: "bi"}}
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got != base {
t.Fatal("explicit bi direction must hash like the legacy default")
}

cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: ".", Remote: "/workspace", Direction: "down"}}
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got == base {
t.Fatal("expected direction change to affect sync config hash")
}
}

func TestResolveSyncMode(t *testing.T) {
// Explicit --mode wins over the configured direction.
if got := resolveSyncMode(true, "up", "down"); got != "up" {
t.Fatalf("explicit flag must win, got %q", got)
}
// Without the flag, spec.sync.direction applies.
if got := resolveSyncMode(false, "bi", "down"); got != "down" {
t.Fatalf("config direction must apply, got %q", got)
}
}

func TestValidateSyncMode(t *testing.T) {
for _, ok := range []string{"up", "down", "bi", "two-phase"} {
if err := validateSyncMode(ok); err != nil {
t.Fatalf("mode %q should validate, got %v", ok, err)
}
}
if err := validateSyncMode("push"); err == nil {
t.Fatal("expected invalid mode to be rejected")
}
}

func TestProcStatIsZombie(t *testing.T) {
if !procStatIsZombie([]byte("123 (okdev) Z 1 2 3")) {
t.Fatal("expected zombie proc stat to be detected")
Expand Down
16 changes: 13 additions & 3 deletions internal/cli/syncthing.go
Original file line number Diff line number Diff line change
Expand Up @@ -2465,9 +2465,14 @@ func resetRemoteWorkspace(ctx context.Context, k interface {
}

type syncthingSessionConfigState struct {
Engine string `json:"engine"`
LocalPath string `json:"localPath"`
RemotePath string `json:"remotePath"`
Engine string `json:"engine"`
LocalPath string `json:"localPath"`
RemotePath string `json:"remotePath"`
// Direction is part of the hash so editing spec.sync.direction makes
// the next `okdev up` restart sync with the new folder types. The
// default "bi" is hashed as the empty legacy value to avoid a
// restart on upgrade for existing sessions.
Direction string `json:"direction,omitempty"`
WatcherDelaySeconds int `json:"watcherDelaySeconds,omitempty"`
RescanIntervalSeconds int `json:"rescanIntervalSeconds,omitempty"`
RelaysEnabled bool `json:"relaysEnabled,omitempty"`
Expand All @@ -2479,10 +2484,15 @@ func syncthingSessionConfigHash(cfg *config.DevEnvironment, localPath, remotePat
if cfg == nil {
return ""
}
direction := ""
if pairs, err := syncengine.ParsePairs(cfg.Spec.Sync.Paths, remotePath); err == nil && len(pairs) > 0 && pairs[0].Direction != config.SyncDirectionBi {
direction = pairs[0].Direction
}
state := syncthingSessionConfigState{
Engine: strings.TrimSpace(cfg.Spec.Sync.Engine),
LocalPath: localPath,
RemotePath: remotePath,
Direction: direction,
WatcherDelaySeconds: cfg.Spec.Sync.Syncthing.WatcherDelaySeconds,
RescanIntervalSeconds: cfg.Spec.Sync.Syncthing.RescanIntervalSeconds,
RelaysEnabled: cfg.Spec.Sync.Syncthing.RelaysEnabled,
Expand Down
29 changes: 25 additions & 4 deletions internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ func upValidate(cmd *cobra.Command, opts *Options, flags upOptions) (*upState, e
if err != nil {
return nil, err
}
if flags.resetWorkspace && len(syncPairs) > 0 && syncPairs[0].Direction == config.SyncDirectionDown {
// reset-workspace clears the remote and reseeds it from local; with
// direction "down" the pod is the authority and local is
// receiveonly, so the combination would wipe the remote and then
// pull the deletions back over the local files.
return nil, fmt.Errorf("--reset-workspace conflicts with sync direction \"down\" (the pod is the sync authority); remove the flag or switch direction")
}
runtime, err := sessionRuntime(cc.cfg, cc.cfgPath, cc.sessionName, workloadName, labels, annotations, cc.cfg.Spec.PodTemplate.Spec, volumes, enableTmux, resolvePreStopCommand(cc.cfg, cc.cfgPath))
if err != nil {
return nil, err
Expand Down Expand Up @@ -1092,12 +1099,18 @@ func upSetupSync(state *upState, target workload.TargetRef) (string, string, str
}
configChanged = changed
}
direction := config.SyncDirectionBi
if len(state.syncPairs) > 0 {
direction = state.syncPairs[0].Direction
}
active := syncthingSessionActive(state.command.sessionName)
restartRequired := state.flags.resetWorkspace || configChanged || !active
startMode = syncStartMode(state.flags.resetWorkspace, targetReset, hasConfigState, configChanged, active)
startMode = syncStartMode(direction, state.flags.resetWorkspace, targetReset, hasConfigState, configChanged, active)
// Signal waitForInitialSync to probe the remote folder type for an
// incomplete bootstrap, reusing the port-forward it already opens.
bootstrapResume = len(state.syncPairs) == 1 && hasConfigState && !state.flags.resetWorkspace && !targetReset
// incomplete bootstrap, reusing the port-forward it already opens. Only
// meaningful for bi: directional folders never run the two-phase
// bootstrap, so there is nothing to resume.
bootstrapResume = len(state.syncPairs) == 1 && hasConfigState && !state.flags.resetWorkspace && !targetReset && direction == config.SyncDirectionBi
if restartRequired {
if err := refreshSyncthingSessionProcesses(state.command.sessionName); err != nil {
return "", "", "", false, fmt.Errorf("refresh local syncthing session state: %w", err)
Expand Down Expand Up @@ -1832,7 +1845,15 @@ func modeSymbol(mode string) string {
}
}

func syncStartMode(resetWorkspace, targetReset, hasConfigState, configChanged, active bool) string {
func syncStartMode(direction string, resetWorkspace, targetReset, hasConfigState, configChanged, active bool) string {
// A directional folder never needs the two-phase bootstrap: that
// protocol exists to protect the remote from the local side's initial
// emptiness before flipping both sides to sendreceive. With up/down one
// side is already sendonly, so the final folder types are safe from the
// first start.
if direction == config.SyncDirectionUp || direction == config.SyncDirectionDown {
return direction
}
switch {
case resetWorkspace:
return "two-phase"
Expand Down
22 changes: 15 additions & 7 deletions internal/cli/up_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,23 +119,31 @@ func TestSyncHelpers(t *testing.T) {
func TestSyncStartMode(t *testing.T) {
cases := []struct {
name string
direction string
resetWorkspace bool
targetReset bool
hasConfigState bool
configChanged bool
active bool
want string
}{
{name: "first bootstrap", hasConfigState: false, active: false, want: "two-phase"},
{name: "explicit reset", resetWorkspace: true, hasConfigState: true, configChanged: true, active: true, want: "two-phase"},
{name: "target recreated", targetReset: true, hasConfigState: true, active: false, want: "two-phase"},
{name: "config changed on active session", hasConfigState: true, configChanged: true, active: true, want: "bi"},
{name: "daemon recovery", hasConfigState: true, active: false, want: "bi"},
{name: "steady state", hasConfigState: true, active: true, want: "bi"},
{name: "first bootstrap", direction: "bi", hasConfigState: false, active: false, want: "two-phase"},
{name: "explicit reset", direction: "bi", resetWorkspace: true, hasConfigState: true, configChanged: true, active: true, want: "two-phase"},
{name: "target recreated", direction: "bi", targetReset: true, hasConfigState: true, active: false, want: "two-phase"},
{name: "config changed on active session", direction: "bi", hasConfigState: true, configChanged: true, active: true, want: "bi"},
{name: "daemon recovery", direction: "bi", hasConfigState: true, active: false, want: "bi"},
{name: "steady state", direction: "bi", hasConfigState: true, active: true, want: "bi"},
// Directional folders skip the two-phase bootstrap entirely: one
// side is already sendonly, so the final types are safe from the
// first start.
{name: "up direction fresh", direction: "up", hasConfigState: false, active: false, want: "up"},
{name: "up direction target reset", direction: "up", targetReset: true, hasConfigState: true, active: false, want: "up"},
{name: "down direction fresh", direction: "down", hasConfigState: false, active: false, want: "down"},
{name: "down direction steady", direction: "down", hasConfigState: true, active: true, want: "down"},
}

for _, tc := range cases {
if got := syncStartMode(tc.resetWorkspace, tc.targetReset, tc.hasConfigState, tc.configChanged, tc.active); got != tc.want {
if got := syncStartMode(tc.direction, tc.resetWorkspace, tc.targetReset, tc.hasConfigState, tc.configChanged, tc.active); got != tc.want {
t.Fatalf("%s: got %q want %q", tc.name, got, tc.want)
}
}
Expand Down
Loading
Loading