Ops Demo Automation Migration — Delivery Notes
tranquil-ops-demo still opened itself with a pre-ADR-0022 .js automation — renderer code that reached straight for atom. The runner refuses those now, so
both demo automations were rewritten against the automation SDK. Three things the old opener did
had no equivalent in the sandboxed SDK: two of them became small SDK additions (dock placement,
address-bar control) and one is gone by construction (following the UI theme live). This note is
the delivery log.
Status
Shipped, verified by hand in the app.
| Piece | State |
|---|---|
open-app.ts — the ported opener | Shipped |
scripts/dev.ts — std-only dev server | Shipped |
| Port discovery independent of the script’s folder | Shipped |
tabs.open — location / activate | Shipped, documented in the guide |
hideURLBar — open-time option and tab.toggleURLBar() | Shipped, documented in the guide |
| Save-before-run for a modified buffer | Shipped, then reverted — see Follow-ups |
flag-overdue.ts, hide-url.ts — the other two demo scripts | Shipped |
Dock-aware core:close (Cmd-W) | Shipped |
What shipped
The opener, ported. automations/open-app.js → open-app.ts: a @permissions browser net=localhost header, import { tabs, ui } from "tranquil/automation", and tabs.open in place
of atom.workspace.open. It opens the app entry in the center and the order-detail panel in the
right dock, both themed, and runs from any window or folder.
A std-only dev server. The old task was a one-line npm:browser-sync invocation pinned to :3000. It is now scripts/dev.ts — Deno.serve + serveDir from @std/http, with live reload
over ServerSentEventStream and a Deno.watchFs watcher. No npm dependency at all; regenerating
the lockfile dropped it from 737 lines to 76.
Port selection is the native mechanism: Deno.serve binds eagerly and throws Deno.errors.AddrInUse, so the server walks up from 3000 and reports server.addr.port.
Discovery that doesn’t depend on where the script lives. The port started out in a .dev-port file the automation read — exact, and no network grant. It was replaced once the demo had to run
from any window: everything a script reads resolves against context.scriptDir, and the read=../.dev-port grant can name nothing else, so the file pinned the script to one folder in one
repo. A copy anywhere else looked beside itself and reported a running server as missing.
The script now sweeps 3000..3009 and takes the first server that answers as this app — GET /theme.js containing ops.theme. Identity matters more than liveness here: port 3000 is
shared ground, and trusting whatever replies is how the demo once opened showing an unrelated local
app. That costs a net=localhost grant, which is no escalation next to the browser grant the
script already holds.
Removing the file removed its lifecycle with it: no cleanup, so no SIGINT handler, so none of
the Ctrl-C trouble below.
Tab placement in the SDK. tabs.open(url, { location, activate }), forwarded to workspace.open() by TabsCap. location is center/right/bottom — left is rejected, since
that dock belongs to the tree-view and the browser model’s getAllowedLocations() excludes it for
the same reason. After an unactivated open into a dock, the capability reveals the dock explicitly
via paneContainerForItem(...).show(): workspace.open() only shows one as a side effect of
activating its pane, so activate: false would otherwise open it invisibly.
Dock-aware Cmd-W. core:close runs workspace.closeActivePaneItemOrEmptyPaneOrWindow(), which
reads getCenter() unconditionally — with focus in a dock it closed a center tab, something the
user could not see. Overridden in tranquil-config rather than patched in src/.
hideURLBar through the SDK, both ways. tabs.open forwards it, the opener hands its whole
options object to the browser model as opt, and the model serializes the flag — so a page opened
as a panel keeps its bare chrome across a window reload. That covers tabs a script opens, which
left out the more obvious case: the tab already in front of you. tab.toggleURLBar(hidden?) flips
the class on the view’s urlbar outlet and writes the same flag onto the model, so the live
toggle and the open-time option end in the same state. automations/hide-url.ts is the whole
feature in three lines against tabs.active().
Save before run, then reverted. Runs execute the file on disk, so a modified buffer runs the previous version silently — and an empty never-saved file is refused for declaring no permissions, naming a file the author thought they had just written. Saving the buffer before a run fixed both and was reverted the same day: an editor that writes your file because you pressed Run is doing something you did not ask for, and “maybe I did not want that save” has no answer. The behavior stands as it was; see Follow-ups.
flag-overdue ported. The second demo automation moved to the sandboxed runner: tabs.find by URL rather than “whatever tab is focused”, so it runs from any window.
Where everything lives
Four repos: the demo carries the scripts and its server, the SDK and its host half live in tranquil-automations, and the one workspace behavior change sits in tranquil-config.
| Piece | File |
|---|---|
| The opener | tranquil-ops-demo/automations/open-app.ts |
| The other demo scripts | tranquil-ops-demo/automations/flag-overdue.ts, hide-url.ts |
| Dev server | tranquil-ops-demo/scripts/dev.ts |
| Import map, task, scoped permissions | tranquil-ops-demo/deno.json |
| SDK surface | tranquil-automations/deno/tabs.ts |
| Host capability | tranquil-automations/lib/runner-capabilities.js (TabsCap.open, setURLBarHidden) |
| Cmd-W override | tranquil-config/src/index.js, in activate() |
Gotchas worth remembering
A parent deno.json wins, and an incomplete one breaks the import. findDenoConfig walks up
from the script and stops at the first config, so the repo’s own deno.json shadowed ~/.tranquil/automations/deno.json and tranquil/automation would not resolve. The mapping has to
be added to the project’s config. Point it at the SDK relatively (../tranquil-automations/deno/mod.ts)
rather than the absolute path Tranquil generates — with the repos cloned side by side that resolves
on any machine, and nothing machine-specific gets committed.
tabs.open resolves the new tab by matching the URL it asked for. Every page in this app
redirects on load — the entry routes to dashboard or login, guarded pages bounce to login — so the
match raced the redirect and the open timed out or flaked. The fix is to anchor on the one page that
never redirects and then navigate: the handle is bound to the tab, not the URL, so it survives.
const tab = await tabs.open(`${BASE}/login.html?theme=${THEME}&pane=${location}`, { location });
await tab.goto(`${BASE}${path}?theme=${THEME}`, { waitUntil: "load" }); The workspace remembers item locations, keyed on URI. subscribeToMovedItems saves uri → location whenever an item lands somewhere other than its default, and open() replays that
whenever the caller passes no location. The store is app-global, and the dock open rewrites it on
every run, so it never stays cleared. Both opens originally went through one anchor URL, which put
the main view in the dock alongside the panel — with nothing in the script saying so. Two defenses,
and the second is why the anchor above carries &pane=: name the location on every open, and give
each destination its own URI so the two calls cannot teach each other anything.
Renderer package code is frozen at window load. The fix above went on working in one window and
not another, because the second had been open since before TabsCap changed and was still dropping
the location option on the floor — the remembered dock location then decided. Automations make
this especially confusing: the .ts half is a fresh subprocess every run, so it is always current,
while the host capability it calls is not. Comparing performance.timeOrigin in the window against
the file’s mtime settles it in one step; reloading the window is the fix.
A dev server on a conventional port inherits another app’s cache. Both tabs opened blank, showing
a different local app’s shell — served from the HTTP cache under http://localhost:3000/…, for the
exact URLs the old automation used, from when that app held the port. The server never saw the
request. Service workers and Cache Storage were both empty; it was the plain HTTP cache. serveDir sends no cache headers of its own, so the dev server now sends cache-control: no-store on everything, as browser-sync did. Recovering an already-poisoned tab needs one hard reload.
server.shutdown() waits for connections that never end. Registering a SIGINT listener
replaces Deno’s own exit-on-Ctrl-C, and awaiting shutdown() inside it waits for every open
connection — including live-reload streams, which never close. With one app tab open, Ctrl-C hung
and Ctrl-Z did nothing, because the parent is deno task and suspending it leaves the child
holding the port. The server has no signal handler at all now; the default exit was always the
right behavior, and there is no longer any file to clean up on the way out.
Verification
deno checkonopen-app.tsanddev.ts, against the repo’s own config.- Spawned the runner’s exact command line — bootstrap entry,
--no-prompt, the computed--allow-*set — with no host: it fails only at the RPC bridge, proving the import map, the permission header and the module graph all resolve from that directory. - Two dev-server instances at once: the second logged
Port 3000 is taken — trying 3001…up to 3003 (3001 and 3002 were held by unrelated processes) and served from 3003. - The discovery sweep against a live server: 3000 answered
/theme.jswith the marker, every other port in range refused instantly. curl -Ion HTML, JS and CSS —cache-control: no-storeon each.- In the app: main view in the center, order detail in the right dock, focus left on the main view;
Cmd-W closing the focused dock tab rather than a center one;
hide-url.tshiding and restoring the address bar on the active tab across repeated runs. flag-overdue.tsagainst the live Orders page — overdue rows tinted, the outstanding total reported, found by URL rather than by focus.
Follow-ups
- An unsaved buffer still runs the previous version. Save before running; the runner reads the file from disk. Selection runs are the sharp edge — the selection comes from the buffer while the imports and the permissions header come from disk, so a run can mix the two. Saving automatically was tried and rejected (implicit writes); a refusal that touches nothing is the open option.
- Two demo servers at once resolve to the lower port. The sweep identifies the app but cannot tell two instances of it apart; the port file could. Nothing depends on this today.
- Live theme-following is gone by construction. A run is a subprocess that exits, so it cannot
hold a listener on the editor’s theme, and the sandbox cannot read the active theme (
configis namespaced to the script’s own state). The script carries aTHEMEconstant; re-run it to switch.