🛰️ 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.
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__WorkspaceAgentand deliberately NOTCoreDevelopmentMode— that one answers the whole API anonymously, so it must not be the switch that merely reaches these routes.WorkspaceControllerandWorkspaceAgentControllerboth 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 reportsdeveloperTools,productModelDevModeandworkspaceAgent.
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.jsonheld the password in plain text. The decryptingPassword/Tokengetters were serialized next to their DPAPI blobs, together with the computedIsComplete/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
Runkey — 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.exeexited immediately with0xE0434352, no window, no tray icon and no crash file.System.Drawing.Commonwas pinned at 10.0.1 inZuloOne.SharedandZuloOne.Lib, which target net9.0. Version 10 of that package carriesSystem.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, andApplicationConfiguration.Initialize()could not loadOsVersionfrom it. The throw happens BEFOREProgram.Maininstalls its handlers, which is why the crash file the app otherwise writes to%TEMP%\zuloone-mirror-crash.txtwas 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 thedeps.jsonthat was actually published, where a transitive pull would show. The narrowness is the point: mostSystem.*packages are out-of-band on purpose —System.Text.JsonandSystem.IO.Pipelinesarrive 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
cmdwith2>&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__WorkspaceUserandAgent__WorkspacePasswordanswer 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.cmdstill 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 back409. A two-way loop would log the hold and go on to pull: freshly writtenDocumentExtensions/*.extension.jsonwould be deleted and a bumpedmodelVersionreverted 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
.conflictfile 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 inDevWorkspaceLedgerTests.
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-treewrites 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 amodel.jsonthe 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.DeveloperStandis 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.
WorkspaceChangeSignaland/hubs/agentexist 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
.conflictfiles, but the stand folder has no lock — two pushes race. - Conflicts: check the log and
.conflictfiles; 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.