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
3 changes: 2 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ agents can react without launching a diagnostic chain on every blip:
- Advanced command. Starts detached background sync by default; use `--foreground` for sync debugging, or explicit one-way sync (`up`/`down`).
- By default each configured mapping syncs in its own `direction` (`spec.sync.paths[].direction`, falling back to `bi`); an explicit `--mode` forces that mode onto every mapping for the invocation. See the config manifest for choosing persistent directions — `down` makes the pod the authority so local writes can never clobber pod-generated results.
- With multiple mappings, each becomes its own syncthing folder; the first (primary) mapping is the one shared to mesh receivers, and `sync reset-remote` clears only the primary remote.
- For default `--mode bi`, no-op when background sync is already active for the session.
- A mapping's local root may nest inside the primary root: okdev maintains a managed block in the primary root's `.stignore` excluding it from the primary folder (written before the sync daemon starts). Removing a mapping retains the entry as a tombstone so the subtree never silently joins the primary folder; sync start prints a notice and `status --details` lists active/retained excludes.
- Without an explicit `--mode`, 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.
- `--reset --force` / `--reset -f`: unconditionally reset without health checks.
Expand Down
10 changes: 6 additions & 4 deletions docs/config-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,17 @@ spec:
```yaml
paths:
- .:/workspace # primary: code, bidirectional
- local: ../collected # sibling of the repo — local roots must
remote: /data/results # not nest inside the primary mapping
- local: ./collected # nested inside the repo — allowed:
remote: /data/results # okdev excludes it from the primary folder
direction: down # pod-generated results, pod is authority
- local: ../datasets
- local: ../datasets # disjoint roots work too
remote: /data/datasets
direction: up # dataset push, local is authority
```

Note the disjoint rule applies to the **local** side too: a results directory *inside* the synced repo (e.g. `./collected` under `.`) is rejected — place it next to the repo instead, or narrow the primary mapping to a subdirectory.
**Nested local roots.** An additional mapping's local root may live *inside* the primary mapping's local root (`./collected` under `.`). okdev maintains a managed block in the primary root's `.stignore` (`// okdev:begin/end managed sync excludes`) so the subtree travels only through its own folder — the block is written before the sync daemon starts, so the exclusion is in force from the first scan. Any other local overlap (equal roots, nesting between additional mappings, an additional root containing the primary) is rejected, and **remote roots must always be disjoint**.

**Removing a mapping never widens the primary folder.** When a nested mapping is removed, its folder is pruned from both syncthing instances and local files are kept, but the `.stignore` entry is *retained* as a tombstone (with an explanatory comment) — otherwise the subtree would suddenly bulk-sync through the code channel. To fold the directory back into the primary folder, delete its line from `.stignore`. `status --details` lists both active and retained excludes.

**Volumes are provisioned automatically.** An additional mapping's remote root must be visible to the okdev sidecar (its syncthing serves the remote side), which means it must live on a volume — a root on the container's own filesystem cannot sync. You don't need to write any volume YAML: okdev auto-provisions an emptyDir at any extra remote root not already covered by a target-container volume mount, and mirrors the target container's mounts into the sidecar. Adding or removing a mapping changes the pod spec, so the next `okdev up` asks for `--reconcile` (pod recreate). Auto-provisioned emptyDirs don't survive pod recreation — fine for both directions (`up` re-pushes from local, `down` results are mirrored locally); mount your own PVC at the root if the data must persist, and okdev will use it instead. Sync startup still probes each extra root and fails with a clear error if it is not shared with the sidecar.

Expand Down
11 changes: 8 additions & 3 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,23 @@
## Sync Failures

- Validate workspace path exists and is writable in the container.
- Isolate directionality with one-way runs: `okdev sync --mode up`, `okdev sync --mode down`.
- Isolate directionality with one-way runs: `okdev sync --mode up`, `okdev sync --mode down` (an explicit `--mode` forces every mapping for that invocation).
- `sync.engine=syncthing` requires local Syncthing bootstrap (handled by `okdev`) and sidecar image pull success.
- Current Syncthing implementation supports a single `local:remote` mapping.
- Multiple mappings are supported (each is its own syncthing folder with its own `direction`); remote roots must be disjoint, and a local root nested inside the primary root is excluded from the primary folder via the managed block in its `.stignore`.
- If you see `ErrImagePull` / GHCR `403`, use a publicly pullable `spec.sidecar.image` or adjust registry permissions.
- If `okdev status --details` shows stale local sync state or a stale PID file, run `okdev sync --reset`. For mesh sessions, `--reset` also probes receiver health and repairs any broken mesh connections.
- `okdev status --details` shows live mesh health when receivers exist, including per-receiver connection and sync status.
- If the session pod was recreated, rerun `okdev up` to re-bootstrap local sync against the new pod.
- Re-run `okdev ports` if the sync connection depends on managed SSH forwarding that may have been interrupted.

Before treating "changes are not syncing" as a fault, rule out two by-design behaviors:

- **Direction contract**: with `direction: up` pod-side writes never reach local; with `down` local writes never reach the pod (syncthing marks them as local additions). `okdev status --details` shows each mapping's direction arrow.
- **Managed excludes**: a directory that belongs to (or belonged to) its own nested mapping is excluded from the primary folder. `status --details` lists active excludes and tombstones retained after a mapping was removed; delete the entry from the primary root's `.stignore` to fold the directory back into the primary folder.

## Recovering A File Overwritten By Sync

The managed folder is bidirectional in steady state, so a bad write on one side (e.g. an empty file from a failed `okdev exec ... > result.txt` redirect) can propagate and overwrite the real file on the other side. Versioning (on by default, `spec.sync.syncthing.versioningDays`) archives the previous version on the side that applied the incoming change:
With the default `direction: bi` a folder is bidirectional in steady state, so a bad write on one side (e.g. an empty file from a failed `okdev exec ... > result.txt` redirect) can propagate and overwrite the real file on the other side. (Directional mappings — `up`/`down` — are structurally immune: the non-authoritative side's writes are never propagated.) Versioning (on by default, `spec.sync.syncthing.versioningDays`) archives the previous version on the side that applied the incoming change:

- On the pod: `<remote workspace>/.stversions/` (e.g. `/workspace/.stversions/`)
- Locally: `<local sync root>/.stversions/`
Expand Down
28 changes: 23 additions & 5 deletions internal/cli/status_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,14 @@ type detailedStatusSSH struct {
}

type detailedStatusSync struct {
Engine string `json:"engine"`
Direction string `json:"direction,omitempty"`
Engine string `json:"engine"`
Direction string `json:"direction,omitempty"`
// ManagedExcludes are nested child mappings excluded from the primary
// folder; RetainedExcludes are tombstones kept after a mapping was
// removed (the subtree stays out of the primary folder until the user
// deletes the entry from .stignore).
ManagedExcludes []string `json:"managedExcludes,omitempty"`
RetainedExcludes []string `json:"retainedExcludes,omitempty"`
Health string `json:"health,omitempty"`
HealthDetail string `json:"healthDetail,omitempty"`
ConfiguredPaths []string `json:"configuredPaths,omitempty"`
Expand Down Expand Up @@ -422,13 +428,19 @@ func buildDetailedSync(sessionName string, cfg *config.DevEnvironment, cfgPath s
engine = "syncthing"
}
direction := ""
var managedExcludes, retainedExcludes []string
if pairs, err := syncengine.ParsePairs(cfg.Spec.Sync.Paths, cfg.EffectiveWorkspaceMountPath(cfgPath)); err == nil && len(pairs) > 0 {
direction = pairs[0].Direction
if absPrimary, absErr := filepath.Abs(pairs[0].Local); absErr == nil {
managedExcludes, retainedExcludes = readManagedSTIgnoreBlock(absPrimary)
}
}
detail := detailedStatusSync{
Engine: engine,
Direction: direction,
ConfiguredPaths: summarizeConfiguredSyncPaths(cfg, cfgPath),
Engine: engine,
Direction: direction,
ManagedExcludes: managedExcludes,
RetainedExcludes: retainedExcludes,
ConfiguredPaths: summarizeConfiguredSyncPaths(cfg, cfgPath),
}
if engine != "syncthing" {
return detail
Expand Down Expand Up @@ -602,6 +614,12 @@ func printDetailedStatus(w io.Writer, detail detailedStatus) {
if detail.Sync.Direction != "" {
fmt.Fprintf(w, "- direction: %s (%s)\n", detail.Sync.Direction, modeSymbol(detail.Sync.Direction))
}
if len(detail.Sync.ManagedExcludes) > 0 {
fmt.Fprintf(w, "- excluded from primary folder (own mappings): %s\n", strings.Join(detail.Sync.ManagedExcludes, ", "))
}
if len(detail.Sync.RetainedExcludes) > 0 {
fmt.Fprintf(w, "- excluded from primary folder (retained after mapping removal): %s\n", strings.Join(detail.Sync.RetainedExcludes, ", "))
}
if detail.Sync.BackgroundStatus != "" {
status := detail.Sync.BackgroundStatus
if detail.Sync.BackgroundPID > 0 {
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/status_details_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
Sync: detailedStatusSync{
Engine: "syncthing",
Direction: "down",
ManagedExcludes: []string{"/results"},
RetainedExcludes: []string{"/old-datasets"},
BackgroundStatus: "running",
BackgroundPID: 123,
ConfiguredPaths: []string{"/tmp/repo -> /workspace"},
Expand Down Expand Up @@ -393,6 +395,8 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
"managed forward: running",
"Sync:",
"direction: down (<-)",
"excluded from primary folder (own mappings): /results",
"excluded from primary folder (retained after mapping removal): /old-datasets",
"background: running (pid 123)",
"conflicts: 2",
"./a.sync-conflict-1",
Expand Down
99 changes: 99 additions & 0 deletions internal/cli/stignore_managed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package cli

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestMergeManagedSTIgnoreBlock(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".stignore")

// Creating the block appends after existing user content.
if err := os.WriteFile(path, []byte("node_modules\n// user comment\n"), 0o644); err != nil {
t.Fatal(err)
}
retained, err := mergeManagedSTIgnoreBlock(dir, []string{"/results"})
if err != nil || len(retained) != 0 {
t.Fatalf("merge: retained=%v err=%v", retained, err)
}
content, _ := os.ReadFile(path)
text := string(content)
if !strings.Contains(text, "node_modules\n") || !strings.Contains(text, "// user comment") {
t.Fatalf("user content must be preserved, got %q", text)
}
if !strings.Contains(text, managedSTIgnoreBegin+"\n/results\n"+managedSTIgnoreEnd) {
t.Fatalf("expected managed block with /results, got %q", text)
}

// Idempotent: same input, same output.
if _, err := mergeManagedSTIgnoreBlock(dir, []string{"/results"}); err != nil {
t.Fatal(err)
}
again, _ := os.ReadFile(path)
if string(again) != text {
t.Fatalf("merge must be idempotent:\n%q\nvs\n%q", text, again)
}

// Growing the active set keeps sorted patterns inside the block.
if _, err := mergeManagedSTIgnoreBlock(dir, []string{"/datasets", "/results"}); err != nil {
t.Fatal(err)
}
active, tombs := readManagedSTIgnoreBlock(dir)
if strings.Join(active, ",") != "/datasets,/results" || len(tombs) != 0 {
t.Fatalf("unexpected block state: active=%v tombs=%v", active, tombs)
}

// Dropping a mapping tombstones its pattern instead of releasing the
// subtree into the primary folder.
retained, err = mergeManagedSTIgnoreBlock(dir, []string{"/datasets"})
if err != nil {
t.Fatal(err)
}
if len(retained) != 1 || retained[0] != "/results" {
t.Fatalf("expected /results newly retained, got %v", retained)
}
active, tombs = readManagedSTIgnoreBlock(dir)
if strings.Join(active, ",") != "/datasets" || strings.Join(tombs, ",") != "/results" {
t.Fatalf("unexpected block state: active=%v tombs=%v", active, tombs)
}
content, _ = os.ReadFile(path)
if !strings.Contains(string(content), managedSTIgnoreTombstone+"\n/results\n") {
t.Fatalf("tombstone comment must precede the retained pattern, got %q", content)
}

// A tombstoned pattern re-added to the config becomes active again and
// is reported as retained exactly once.
retained, err = mergeManagedSTIgnoreBlock(dir, []string{"/datasets", "/results"})
if err != nil || len(retained) != 0 {
t.Fatalf("re-adding must not re-report retention: retained=%v err=%v", retained, err)
}
active, tombs = readManagedSTIgnoreBlock(dir)
if strings.Join(active, ",") != "/datasets,/results" || len(tombs) != 0 {
t.Fatalf("re-added pattern must leave tombstones, got active=%v tombs=%v", active, tombs)
}

// User content after the block is preserved across rewrites.
if err := os.WriteFile(path, []byte(string(content)+"trailing-user-pattern\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := mergeManagedSTIgnoreBlock(dir, []string{"/datasets"}); err != nil {
t.Fatal(err)
}
final, _ := os.ReadFile(path)
if !strings.Contains(string(final), "trailing-user-pattern") {
t.Fatalf("content after block must survive, got %q", final)
}
}

func TestMergeManagedSTIgnoreBlockNoopWithoutFileOrPatterns(t *testing.T) {
dir := t.TempDir()
if retained, err := mergeManagedSTIgnoreBlock(dir, nil); err != nil || retained != nil {
t.Fatalf("no file + no patterns must be a no-op, got %v %v", retained, err)
}
if _, err := os.Stat(filepath.Join(dir, ".stignore")); !os.IsNotExist(err) {
t.Fatal("no-op must not create .stignore")
}
}
Loading
Loading