ZuloOne
English
Sign in
Menu

🛰️ The dev workspace agent: editing a stand you are nowhere near

Updated

The live two-way sync makes the business layer a folder: save a file, the stand has it; change something in the designer, the file is rewritten. It assumes one thing that stops being true the moment the instance moves to the cloud — that the folder is on the same disk as the core.

The dev workspace agent is that assumption removed. A developer keeps a copy of the folder on a laptop and the agent keeps the two copies equal.

← Developer wiki

Why a mirror and not a second import path#

The obvious design is an API that takes an edited file and imports it. It is the wrong one. Everything that makes the local sync safe lives on the path between a file changed and a row changed:

  • a per-file quiet period, so an editor's multi-syscall save is not read half-written;
  • the own-echo tracker, so the exporter's own output never comes back as an edit;
  • imports drained before exports, so a save is never overwritten by stale database truth;
  • the blast-radius gate, which refuses a change set too big to be a live edit and freezes both directions until a human decides;
  • the Core model gate and the conflict rule.

A second import path would have to grow all of that again, and would get it subtly different. So the agent does not import anything. It writes bytes into the stand's ordinary watched folder, and the ordinary sync picks them up. A remote edit and a local one are the same event by the time anything decides what it means — including the refusals, which is the point: the developer far away must learn the same lesson from the same message.

Identity is content#

Both sides hash with SHA-256 and compare hashes, never mtimes. A checkout, a copy, or a bind mount restamps a file without changing who authored the bytes; two clocks on two machines make "newer" a race. This is the same rule the export ledger already uses, which is why the pull diff needed no new idea.

The three calls#

All of them are api/dev/workspace/agent/* and gated like WorkspaceController: CoreDevOnly and the Developer permission. The exception is GET …/package: the browser navigates there with a 15-minute ticket from POST …/package-link, because there is no session cookie for a plain href to carry. A server with developer tools off does not have these routes at all — a stand that lets a remote process write its metadata folder has handed over the business layer, so this is a decision somebody makes when they start the server, not a checkbox.

Call What it is for
GET …/status Is there a folder here, and is the sync frozen?
POST …/pull Given the hashes I hold, what am I missing?
POST …/push Lay these bytes into the folder.

pull takes the remote's whole ledger ({ "rel/path": "SHA256HEX" }) and answers with the files whose hash differs, the paths the stand no longer has (those are deletions for the remote, because the remote took them from here), and a truncated flag. The answer is capped at 8 MB, so a cold first sync is a handful of ordinary requests instead of one that has to carry a whole tree through two heaps.

push reports three lists, and unchanged is the load-bearing one: a file whose bytes already match is not rewritten. Restamping identical content would wake the watcher and cost an import of a change nobody made.

Held is an answer, not an error to swallow. push onto a frozen stand is 409 with the reason and the quarantined paths. An agent that cannot say "held" out loud leaves its developer typing into a folder that stopped being connected to anything.

What travels#

The whole tree except .git, bin, obj and node_modules. .generated/ does travel, on purpose: without it the laptop has no IntelliSense for the entity classes, and that is most of what makes editing scripts bearable.

Text when the bytes round-trip as UTF-8, base64 otherwise — a json/cs tree stays readable in a wire log and in a diff, which is worth more than the few bytes base64 would save.

A path is whatever a remote process chose to send, so every push path is resolved and checked for containment. ../../etc/passwd is not a write, and neither is an absolute path that happens to point inside.

Getting it#

Developer → Workspace sync in the office, where somebody is already deciding where the workspace lives. The block offers a zip per platform, and the stand's own address is baked in — that is the one value a developer would otherwise copy by hand and get wrong.

Note

Two flags, and the page needs both kinds of route. The control plane's Workspace dev switch sets ZuloOne__WorkspaceAgent and deliberately NOT CoreDevelopmentMode — that one answers the whole API anonymously, so it must not be the switch that merely reaches these routes. WorkspaceController and WorkspaceAgentController both carry [CoreDevOnly(OrWorkspaceAgent = true)].

If only the agent's controller carried the attribute, the page would half work on exactly the stand it was built for: the mirror card would render while the path, the status and every action answered 404 "Developer tools are disabled on this server". It grants nothing extra — every route here also needs the Developer permission, and the mirror already pushes files into this folder and has them applied. The Core guard is separate and stays shut: it keys off CoreDevMode.Enabled, which a workspace-dev stand does not set, so system models remain read-only.

To check a stand from the outside, read its /health: it reports developerTools, productModelDevMode and workspaceAgent.

On Windows: the tray app#

ZuloOne.Mirror.exe in the zip. Double-click it; it asks for the stand, a folder and a sign-in once, and then lives in the notification area:

Menu What
Start / Stop the mirror, without restarting the app
Open folder the folder it keeps equal
Show log the same lines the console prints — "held", "refused and put back", "could not reach it"
Settings… stand, folder, user and password or token, and "start with Windows"

The credentials are stored per Windows account under %APPDATA%\ZuloOne\mirror.json, encrypted with DPAPI for that account — useless if copied to another machine or another user. A token is accepted instead of a password and wins when both are set, the same precedence the agent's configuration uses.

Warning

An older mirror.json held the password in plain text. The decrypting Password/Token getters were serialized next to their DPAPI blobs, together with the computed IsComplete/MissingReason (which is why a working mirror's file said "no sign-in"). They are [JsonIgnore], and the app rewrites an old file without them on its next start. If you ran an older mirror, change that password.

Note

It is an app, not a Windows service, and that is not a shortcut. A service runs in session 0: it can show neither a tray icon nor a dialog, so the switch and the settings would need a second process anyway. It would also write a developer's folder as a different account than the developer, while the whole point of the mirror's sign-in is that a push is logged with a PERSON. "Start with Windows" is a per-user Run key — visible in Task Manager's startup tab and removable without an administrator.

One instance per user, enforced with a mutex: two mirrors on one folder would fight over every file and each would see the other's writes as a remote edit.

Warning

If it starts and nothing appears, the folder is mixing two runtimes. ZuloOne.Mirror.exe exited immediately with 0xE0434352, no window, no tray icon and no crash file. System.Drawing.Common was pinned at 10.0.1 in ZuloOne.Shared and ZuloOne.Lib, which target net9.0. Version 10 of that package carries System.Private.Windows.Core — the assembly WinForms 10 was split into — so the publish folder ended up with WinForms 9.0.18 beside a 10.x core, and ApplicationConfiguration.Initialize() could not load OsVersion from it. The throw happens BEFORE Program.Main installs its handlers, which is why the crash file the app otherwise writes to %TEMP%\zuloone-mirror-crash.txt was not there.

Nothing in the build complains: both projects compile, every test passes, and only a WinForms process ever loads the combination that fails. The console agent in the same folder keeps working throughout.

The rule is now a test — PackageVersionAlignmentTests: no package from the WINDOWS DESKTOP family (System.Drawing.Common, System.Private.Windows.Core, System.Windows.Extensions, Microsoft.Windows.Compatibility) may be a major version AHEAD of the target framework, and CI re-checks the same thing against the deps.json that was actually published, where a transitive pull would show. The narrowness is the point: most System.* packages are out-of-band on purpose — System.Text.Json and System.IO.Pipelines arrive here at 10.x and are MEANT to run on an older runtime. Only the desktop assemblies also come from the runtime pack, and only those end up as two halves of two versions.

To see the real reason behind a silent exit, run the exe from cmd with 2>&1 — a WinExe writes the unhandled exception to stderr and has nowhere to show it otherwise.

Anywhere: the script#

sync.cmd C:\src\our-workspace        # Windows
./sync.sh ~/src/our-workspace        # Linux

Still the right shape for a terminal, a second folder, or a machine with no desktop. It runs while you edit and stops with Ctrl+C.

The package is the same binary the job runner uses, assembled for a different job (AgentPackageBuilder.BuildMirrorPackage). Three deliberate differences from the job agent's download:

  • no enroll key. A key names a machine; this names a person, because the mirror writes the stand's metadata and the push is logged with whoever did it. The scripts prompt, or Agent__WorkspaceUser and Agent__WorkspacePassword answer ahead of time. The account needs the Developer permission.
  • no install script. Registering a Windows service for an editing session is the wrong shape, and still is: session 0 has no tray and no dialogs, and a service would write the folder as another account. The package ships the tray app above — a per-user program that starts with the session and can be switched off. sync.cmd still runs while you edit and stops with Ctrl+C.
  • the folder is an argument, not configuration: a developer with two checkouts points it at a different one each time.

The buttons appear only when the server actually carries the agent payloads (GET …/agent/package-status) — a button that 404s is worse than no button.

Use the button, not a URL you type. Opening …/api/dev/workspace/agent/package?rid=win-x64 in a new tab answers 401 on any stand that checks who is asking: there is no session cookie, and a browser navigating to a URL does not send the bearer token from localStorage. The button first POSTs package-link (that call carries the token) and then navigates to the returned URL. The query has a 15-minute HMAC ticket (t=), bound to the platform; the GET itself is anonymous so the browser owns the transfer — native progress, Range resume, no 100 MB in the SPA heap.

A ticket for win-x64 will not open linux-x64. After 15 minutes mint another; the zip is still cached on the server.

Why the zip is cached. Building it on every click (~490 files, ~200 MB into memory) before the first byte left, then holding the whole file in RAM, piled two waits. It is built once and kept (AgentPackageCache), keyed by the platform, the address baked into the config and a fingerprint of the payload folder — so a new Core image invalidates it by itself. The browser then downloads the cached file itself. Time to first byte 1.5 s on the build, then 0.0 s, and the response carries a Content-Length.

The cache lives in the container's temp folder and costs ~100 MB per platform; Agent:PackageCachePath moves it. A cache that cannot be written is not an error — the download falls back to building in memory, which is only the old behaviour.

Running it by hand#

The mode is DevWorkspaceWorker inside ZuloOne.Agent. The job runner needs an enrolled machine and the mirror needs a folder; either starts alone, which is what lets the mirror run with no enroll key at all:

export Agent__WorkspaceUser=alex
export Agent__WorkspacePassword=...
ZuloOne.Agent --workspace ~/src/our-workspace --stand https://dev.example.zulo.one

--workspace and --stand only set Agent:Workspace and ZuloOne:BaseUrl, so the terminal one-liner and the packaged script take the same path through the worker. Nothing is written to disk by the flags: trying a stand out should not leave a config file to undo afterwards.

Why it signs in as a person#

The mirror writes the stand's metadata folder. An enroll key names a machine, and this needs to name who is editing — the push is logged with the caller. Use Agent:WorkspaceToken for a pre-issued JWT, or Agent:WorkspaceUser + Agent:WorkspacePassword. Same reasoning as print agents, which sign in as a person for the same kind of reason.

The pull is three-way: a local edit is never overwritten#

A two-way pull that made the folder equal to the stand would send the hashes of its own disk, take every file that differed and every path it did not have, and write and delete accordingly. That is only safe when every local edit has already been pushed, and the push does not always go through. The pull is therefore three-way.

Warning

If the stand's sync is HELD on one model.json ("edited outside the exporter"), every push comes back 409. A two-way loop would log the hold and go on to pull: freshly written DocumentExtensions/*.extension.json would be deleted and a bumped modelVersion reverted to the stand's, about a second after they were saved — before anything was in git. A local-docker developer cannot lose an edit that way: the exporter holds instead of overwriting a file edited outside it.

Now the agent keeps the same promise. It remembers what both copies last agreed on and sends that, not its disk, with a pull. The stand therefore names only what moved there; each of those paths is decided against the local file (DevWorkspaceLedger.DecideChanged / DecideDeleted):

Local file vs. last agreement Stand changed it Stand deleted it
unchanged written deleted
missing (deleted here) written back — content beats a deletion forgotten
equal to the stand's bytes agreed, nothing written —
edited here conflict: yours kept, the stand's copy written beside it as <file>.conflict conflict: yours kept

A conflict is settled by a person, never by the next loop:

  • save your file again (even unchanged — the new write time is the signal a hash cannot see) → yours is pushed;
  • copy the .conflict file over yours → the stand's is taken;
  • delete your file → the deletion is pushed.

The .conflict file never travels (WorkspaceAgentMirror.ConflictSuffix, skipped by the scan on both sides) and is already ignored by the generated .gitignore.

A held stand loses nothing. A 409 is logged once per hold — "N local change(s) wait here, untouched" — and the edits stay pending; the three-way pull does not touch them meanwhile. When the hold is released they go.

The memory survives a restart#

The agreement and the open conflicts are kept per stand and folder in %LOCALAPPDATA%\ZuloOne\mirror\<hash>.json (~/.local/share/ZuloOne/mirror on Linux, Agent:WorkspaceStatePath to move it) — beside the user's settings, never in the mirrored folder, so it neither travels nor lands in git.

So a restart is not a cold pull any more: edits made while the mirror was off — a branch checkout, a script, an editor — are pushed first, against the last agreement, exactly like edits made while it runs.

The first run has no memory, and then nothing local is taken for granted. A file that differs from the stand's is a conflict; a file the stand does not have is parked — neither pushed nor deleted — and listed in the log ("save one again to push it"). Files equal on both sides are simply agreed: pointing the mirror at a fresh git clone of the model repo pushes nothing.

Refusals#

A refused path (a system model, i.e. Core) is agreed at the local bytes, so the next pull sees the stand's copy as the only change and lays it back — or removes the file when the stand never had it. A refused deletion is forgotten, so the stand's copy comes back as a new file. Either way the refusal is learned once, not re-sent four times a second.

Note

If a log repeats N refused and put back: a ledger seeded only by the files the first pull happened to send makes an untouched Core tree look edited, and refused paths dropped from the ledger in the hope the next pull would re-add them have nothing to send. Kept in DevWorkspaceLedgerTests.

Same behaviour as a local docker#

Local docker (folder bind-mounted into the core) Cloud stand (this agent)
Your edit the watcher imports it pushed, then the stand's watcher imports it — same code
Stand change over an untouched file the exporter rewrites it the pull writes it
Stand change over your edit exporter holds, nothing overwritten conflict, yours kept, theirs as .conflict
Sync held both directions stop your edits wait here, untouched
Refusal (Core, blast radius) same message same message, file put back once

Tip

Do not install models into a stand you are also mirroring. install-tree writes the database; the stand pauses live export while that runs (RuntimeHeavyWork) and then re-exports once. The mirror still meets that export over your folder — every file you are still editing becomes a conflict, and a model.json the stand rewrote in its own words can hold the stand's sync. The mirror IS the transport; push through it, or stop it while installing.

Limits of the mirror#

  • Environment names are fixed. Production uses the tenant slug (okrasheno); day-to-day model work uses that tenant's Development environment (okrasheno-dev). Workspace dev (Environment.WorkspaceDev → CoreDevelopmentMode, container recreate) is refused on Production. Environment.DeveloperStand is a separate flag — it unlocks Zulo product models only. Promotion beyond "push to the dev environment" is a control-plane concern.
  • Polling, not push. The agent pulls every ~3 seconds. WorkspaceChangeSignal and /hubs/agent exist server-side but are not wired to the desktop agent yet — expect seconds of lag after a save in the browser.
  • Compile errors surface on the stand. The tray log records push actions, not compiler diagnostics; run compile on the stand or in CI to see script errors.
  • Last push wins on the stand. Each developer mirror keeps local edits and .conflict files, but the stand folder has no lock — two pushes race.
  • Conflicts: check the log and .conflict files; the tray does not list them.
  • Whole tree only — no single-model mirror.

Where next#

  • Forms system — what the stand does with a file once it lands.
  • Model import — how a folder becomes installed models.
  • Print agents — the other agent that signs in as a person.
Was this page helpful?