Docs

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.

PieceState
open-app.ts — the ported openerShipped
scripts/dev.ts — std-only dev serverShipped
Port discovery independent of the script’s folderShipped
tabs.openlocation / activateShipped, documented in the guide
hideURLBar — open-time option and tab.toggleURLBar()Shipped, documented in the guide
Save-before-run for a modified bufferShipped, then reverted — see Follow-ups
flag-overdue.ts, hide-url.ts — the other two demo scriptsShipped
Dock-aware core:close (Cmd-W)Shipped

What shipped

The opener, ported. automations/open-app.jsopen-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.tsDeno.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 appGET /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/bottomleft 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.

PieceFile
The openertranquil-ops-demo/automations/open-app.ts
The other demo scriptstranquil-ops-demo/automations/flag-overdue.ts, hide-url.ts
Dev servertranquil-ops-demo/scripts/dev.ts
Import map, task, scoped permissionstranquil-ops-demo/deno.json
SDK surfacetranquil-automations/deno/tabs.ts
Host capabilitytranquil-automations/lib/runner-capabilities.js (TabsCap.open, setURLBarHidden)
Cmd-W overridetranquil-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

  1. deno check on open-app.ts and dev.ts, against the repo’s own config.
  2. 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.
  3. 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.
  4. The discovery sweep against a live server: 3000 answered /theme.js with the marker, every other port in range refused instantly.
  5. curl -I on HTML, JS and CSS — cache-control: no-store on each.
  6. 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.ts hiding and restoring the address bar on the active tab across repeated runs.
  7. flag-overdue.ts against 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 (config is namespaced to the script’s own state). The script carries a THEME constant; re-run it to switch.