Skip to content
Open
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
12 changes: 11 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,17 @@ _Avoid_: element, current, each, loop var
The rule that a **Step**'s position on the flow map is computed from the tree on every render and
never persisted. This is what guarantees a hand-edited **Workflow Definition** and the map can never
disagree.
_Avoid_: auto-layout as a mere feature name — it is a constraint, not a convenience

A **Placement** is one Step's box on the map, named by the **Board** it is on and its id together —
never by a bare id, which two **Blocks** may share. The map is laid out one **Board** at a time
(ADR-0013), and what it takes besides the Board is which containers are drawn collapsed: chrome, the
one input that is not a function of the document, and the reason the totals describe the map that is
actually on screen rather than one with folded regions counted into it.

Positions are the builder's and nobody else's. A **Host** runner never lays anything out, so this is
the one cross-cutting rule in the repo implemented once rather than in both languages.
_Avoid_: auto-layout as a mere feature name — it is a constraint, not a convenience; node position
as something a document could carry

**Published Version**:
An immutable, numbered snapshot of a **Workflow Definition**, carrying `status: published`. Exactly
Expand Down
82 changes: 79 additions & 3 deletions docs/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ file a user hand-edits must not gain keys about what a session had laid out wher
│ 304px │ fills │ 372px │
│ │ │ ← expands into the │
│ Components │ the Step tree, laid out │ Data panel │
│ Workflow │ the start node on top │ │
│ Workflow │ the root node on top │ │
│ (Flow) │ │ │
└───────────────┴──────────────────────────────┴───────────────────────┘
```
Expand Down Expand Up @@ -58,6 +58,82 @@ decision. A Host mounting one region gets no tab strip at all.

---

## Flow map geometry

The numbers `@hatua/layout`'s `LAYOUT` carries, and what each one has to satisfy. The package cites
this section; change one in either place and the other is wrong.

| Constant | Pixels | What it measures |
| --- | --- | --- |
| `nodeWidth` | 236 | A card's width. Every card is the same width, on every Board and at every depth. |
| `nodeHeight` | 64 | A card with a name and nothing else. |
| `nodeHeightWithMeta` | 100 | A card that also carries the meta row. |
| `verticalGap` | 96 | Between one card's bottom and the next card's top, down a column. |
| `branchGap` | 44 | Between two Branch columns. |
| `regionLabel` | 28 | Reserved above a child region for the label naming it. |
| `joinMarker` | 26 | Reserved below a Fork's Branches for the mark where they converge. |

**Cards are a fixed size, and only two heights exist.** A card sized to its content makes a column's
rhythm a function of how long somebody's Step names are, and makes the map reflow when one is
renamed. The meta row is the container summary — how many Branches, how many Steps, whether there is
a handler — so a card is the taller one exactly when the Step owns child regions. That question is
`isContainer`, answered where the regions are enumerated rather than by whatever is drawing.

**What the gaps have to satisfy.** `verticalGap` exceeds `nodeHeight`, so the space between two cards
reads as a run of the flow and not as a crack between two cards that nearly touch. `branchGap` is
much smaller than `nodeWidth`, so two columns read as siblings under one Fork rather than as two
separate maps; it is what stops adjacent columns touching and is the only thing keeping them apart,
so it cannot be zero. `regionLabel` fits one chip's line box, and every child region gets one.

### Regions

**A Fork's Branches are columns, and they converge.** They are alternatives chosen between, and
*which one* is the reader's question — so they sit side by side, separated by `branchGap`, over a
`joinMarker`'s worth of room for the mark where they come back together.

**Every other region is stacked under the card that owns it.** A `core.try`'s body and its handler sit
one above the other, in document order, not side by side. They are not alternatives chosen between:
the handler runs *because* the body failed, and part of the body has already run by then. Columns
would make left-to-right mean "later" in the one place on the map where it means nothing else, and
would put a third thing on screen that reads as a Fork.

**The label band is what tells regions apart** — `try` and `on failure` over a `core.try`'s two,
`loop` over a loop's one, `if` / `else if` / `and` over a Branch's. Every child region gets a band, so
the second region of a `core.try` costs no shape the first did not already have, and a loop body and
a protected body are told apart by the word over them rather than by their geometry. `<StepList>`
gives the same answer with the chip over each region; the two surfaces draw differently but they do
not disagree about which regions there are or what they are called.

**Both draw every region the document carries, and neither reads the verb to decide.** A `handler:`
on a `core.fork` is meaningless and has no runner, but it is not invisible: `walkSteps` yields the
Steps inside it, so every generic rule reports against them by name — a `COMPONENT_UNKNOWN` naming a
Step no surface draws is a problem a user cannot go and fix, because they cannot reach the Step it
names. Refusing to draw a region does not make it not exist; it makes it undeletable. What the verb
decides is the *word* over the region, not whether there is one.

### The root node

The canvas draws one node above the first Step: the Triggers on the root Board, the Block's contract
inside one. It is **chrome, not a `steps[]` entry**, which is what makes `once:`/`fixed:` unnecessary
and keeps `removeStep`, `walkSteps` and `unknownComponents` from needing a case for it.

So it names no Step, and `FlowMap` carries it as a plain `Rect` beside the `Placement[]` rather than
as a `Placement` with no id. A `Placement` whose Step reference were optional would push "sometimes
there is no Step here" into every consumer's type to spare exactly one field here.

### Collapse

**Collapse is an input to the layout, and the only one that is not a function of the document.** It is
chrome — the document has no key for it, because a view state in the file is a diff in the Host's
repository every time somebody folds a loop shut.

A collapsed container's children get **no geometry at all**, rather than geometry the canvas then
hides. Laying them out anyway would leave the map's total width and height describing a map nobody is
looking at, and everything reading a total — the scroll extent, fit-to-screen, a minimap — would be
reading a number that is wrong whenever anything is folded.

---

## TopBar

Left cluster carries identity: `workflows /`, the workflow's name, its slug, then the version
Expand Down Expand Up @@ -124,7 +200,7 @@ per trigger, plus a `TRIGGER` builtin when more than one exists so an Expression
one fired. A single `core.start` Step cannot express two Triggers, and ADR-0006 makes the schema the
source of truth, so `core.start` is retired.

The canvas still draws a **start node** above the first Step, derived from `doc.triggers[]`. Drawing
The canvas still draws a **root node** above the first Step, derived from `doc.triggers[]`. Drawing
it as chrome rather than as a `steps[]` entry is what makes the original handoff's `once: true` and
`fixed: true` flags unnecessary: `removeStep` cannot find it, `walkSteps` does not yield it, and
`unknownComponents` does not flag it. The guarantees come from the model instead of from two
Expand Down Expand Up @@ -562,7 +638,7 @@ Recorded here so the two documents cannot disagree quietly. ADR-0011 already lis
| `inputs[]` on the document | Retired. A Trigger's declared outputs are the parameter contract |
| `dirty` enables **Save changes** | Editing autosaves (ADR-0005). No Save button, no flag |
| "Are manifests served by the Host?" — open | Answered: yes, through `ManifestSource` |
| Trigger is a Step: `core.start`, `once`, `fixed` | `doc.triggers[]` is top-level. The canvas draws a derived start node; `once`/`fixed` become unnecessary |
| Trigger is a Step: `core.start`, `once`, `fixed` | `doc.triggers[]` is top-level. The canvas draws a derived root node; `once`/`fixed` become unnecessary |
| The **Data tab** — reference tree over a variables editor | Split. The tree moves beside the step editor; variables move to the Workflow tab. One panel held two scopes, and listed every variable twice |
| The tab strip is **Flow / Library / Data** | **Components / Workflow**. Flow is optional; Data is not a tab |
| "Library" | "Components" — the glossary term, and the region's name |
Expand Down
5 changes: 3 additions & 2 deletions source/packages/layout/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary --coverage.reporter=lcovonly --passWithNoTests"
"test": "vitest run",
"test:coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary --coverage.reporter=lcovonly"
},
"dependencies": {
"@hatua/model": "workspace:*",
"@hatua/schema": "workspace:*"
}
}
200 changes: 200 additions & 0 deletions source/packages/layout/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type { WorkflowDefinition } from '@hatua/schema'

/** The shell every fixture below fills in, so each one is only its Step tree. */
const workflow = (name: string, rest: Partial<WorkflowDefinition>): WorkflowDefinition => ({
id: `wf_${name}`,
name,
version: 1,
status: 'draft',
triggers: [{ id: 'nightly', use: 'core.schedule', with: {} }],
steps: [],
...rest,
})

/**
* Every region a container owns, on one Board: two Branches, a loop body, and a
* `core.try`'s body and handler.
*
* The document the coverage test is worth running against. A fixture missing a
* region would let a layout that forgets that region pass, which is the failure
* the test exists to make impossible rather than unlikely.
*/
export const ALL_REGIONS = workflow('all regions', {
steps: [
{ id: 'fetch', use: 'component.email.fetch', with: {} },
{
id: 'sort',
use: 'core.fork',
with: { mode: 'condition' },
branches: [
{
label: 'Has new mail',
when: '{{steps.fetch.count}} > 0',
steps: [
{
id: 'each',
use: 'core.for_each',
with: { list: '{{steps.fetch.messages}}' },
steps: [
{
id: 'guarded',
use: 'core.try',
with: { attempts: 3 },
steps: [{ id: 'triage', use: 'component.agent.act', with: {} }],
handler: [{ id: 'shelve', use: 'component.email.send', with: {} }],
},
],
},
],
},
{ label: 'Otherwise', steps: [{ id: 'quiet', use: 'core.end' }] },
],
},
{ id: 'digest', use: 'component.email.send', with: {} },
],
})

/**
* Two Blocks, each holding a Step called `ret`.
*
* Step ids are Board-local (ADR-0013), so this is the document that tells a
* Placement keyed by a `StepRef` from one keyed by a bare id: under a bare id
* the two `ret`s are one entry, and one of the two Blocks draws the other's map.
*/
export const TWO_RETS = workflow('two rets', {
steps: [
{ id: 'first', use: 'block.alpha', with: {} },
{ id: 'second', use: 'block.beta', with: {} },
],
blocks: [
{
id: 'alpha',
outputs: [{ k: 'out', label: 'Out', t: 'text' }],
steps: [{ id: 'ret', use: 'core.return', with: { out: 'a' } }],
},
{
id: 'beta',
outputs: [{ k: 'out', label: 'Out', t: 'text' }],
steps: [{ id: 'ret', use: 'core.return', with: { out: 'b' } }],
},
],
})

/** A Board with no Steps at all: the root node and nothing under it. */
export const EMPTY_BOARD = workflow('empty', { steps: [] })

/** A straight run of leaves, the shape a layout is easiest to get wrong on last. */
export const STRAIGHT = workflow('straight', {
steps: [
{ id: 's1', use: 'component.email.fetch', with: {} },
{ id: 's2', use: 'component.agent.act', with: {} },
{ id: 's3', use: 'component.email.send', with: {} },
],
})

/** Containers holding regions with nothing in them, and a Fork of three. */
export const EMPTY_REGIONS = workflow('empty regions', {
steps: [
{ id: 'try_nothing', use: 'core.try', with: {}, steps: [], handler: [] },
{
id: 'wide',
use: 'core.fork',
with: { mode: 'parallel' },
branches: [
{ label: 'One', steps: [{ id: 'a', use: 'core.end' }] },
{ label: 'Two', steps: [] },
{
label: 'Three',
steps: [
{ id: 'b', use: 'core.repeat', until: '{{var.done}}', steps: [] },
{ id: 'c', use: 'core.end' },
],
},
],
},
],
})

/** Deep nesting: a loop inside a handler inside a branch inside a loop. */
export const DEEP = workflow('deep', {
steps: [
{
id: 'outer',
use: 'core.repeat',
until: '{{var.done}}',
steps: [
{
id: 'pick',
use: 'core.fork',
with: { mode: 'condition' },
branches: [
{
label: 'Risky',
when: '{{var.risky}}',
steps: [
{
id: 'attempt',
use: 'core.try',
with: { attempts: 2 },
steps: [{ id: 'call', use: 'block.alpha', with: {} }],
handler: [
{
id: 'sweep',
use: 'core.for_each',
with: { list: '{{steps.attempt.error.items}}' },
steps: [{ id: 'note', use: 'component.email.send', with: {} }],
},
],
},
],
},
{ label: 'Otherwise', steps: [] },
],
},
],
},
],
blocks: [{ id: 'alpha', steps: [{ id: 'ret', use: 'core.return', with: {} }] }],
})

/**
* One Step carrying Branches, a body and a handler at once.
*
* No verb owns all three, and nothing refuses a document that writes them — the
* schema's step keys are all optional and no rule reads them together. So this
* is the shape that separates "lay out the regions in hand" from "lay out the
* regions a Fork has": the second drops two of these three, silently.
*/
export const MIXED_REGIONS = workflow('mixed regions', {
steps: [
{
id: 'confused',
use: 'core.fork',
with: { mode: 'condition' },
branches: [
{ label: 'One', when: '{{var.go}}', steps: [{ id: 'in_branch', use: 'core.end' }] },
{ label: 'Two', steps: [{ id: 'in_other', use: 'core.end' }] },
],
steps: [{ id: 'in_body', use: 'core.end' }],
handler: [{ id: 'in_handler', use: 'core.end' }],
},
],
})

/**
* Every shape above, for the properties that must hold of all of them.
*
* A property checked against one hand-written example is a property checked
* against one hand-written example. Iterated rather than repeated, and asserted
* non-empty where it is iterated, because a list that shrank to nothing makes
* every property over it pass while checking none.
*/
export const SHAPES: readonly { name: string; doc: WorkflowDefinition }[] = [
{ name: 'all regions', doc: ALL_REGIONS },
{ name: 'two rets', doc: TWO_RETS },
{ name: 'empty board', doc: EMPTY_BOARD },
{ name: 'straight', doc: STRAIGHT },
{ name: 'empty regions', doc: EMPTY_REGIONS },
{ name: 'deep', doc: DEEP },
{ name: 'mixed regions', doc: MIXED_REGIONS },
]
33 changes: 1 addition & 32 deletions source/packages/layout/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1 @@
/**
* Derived layout: step tree in, flow-map geometry out.
*
* Positions are computed on every render and never stored (ADR-0001) — the map
* is a reading of the tree, so a hand-edited Workflow Definition cannot
* disagree with it. Constants come from the design handoff.
*/

export const LAYOUT = {
nodeWidth: 236,
nodeHeight: 64,
/** Node height when the card shows a meta row. */
nodeHeightWithMeta: 100,
verticalGap: 96,
/** Horizontal gap between branch columns. */
branchGap: 44,
joinMarker: 26,
} as const

export interface Placement {
stepId: string
x: number
y: number
width: number
height: number
}

export interface FlowMap {
placements: Placement[]
width: number
height: number
}
export * from './layout'
Loading
Loading