diff --git a/packages/core/src/samples/gallery.ts b/packages/core/src/samples/gallery.ts index d248211..c8b6069 100644 --- a/packages/core/src/samples/gallery.ts +++ b/packages/core/src/samples/gallery.ts @@ -426,6 +426,86 @@ export const eventStream: DiagramDraft = { ], }; +export const cacheMiss: DiagramDraft = { + kind: 'sequence', + title: 'Cache miss request', + subtitle: 'Dashboard load when the cache is cold', + phases: [ + { id: 'request', label: 'Request', edges: ['open', 'get', 'verify', 'claims'] }, + { id: 'fallback', label: 'Fallback', edges: ['read', 'miss', 'query', 'rows'] }, + { id: 'respond', label: 'Response and trace', edges: ['set', 'emit', 'json', 'render'] }, + ], + nodes: [ + { id: 'user', type: 'client', card: { title: 'User', subtitle: 'Browser session' } }, + { id: 'web', type: 'client', card: { title: 'Web app', subtitle: 'React UI', brand: 'react' } }, + { id: 'api', type: 'service', card: { title: 'API', subtitle: 'Request handler' } }, + { id: 'auth', type: 'security', card: { title: 'Auth', subtitle: 'JWT verify' } }, + { id: 'redis', type: 'cache', card: { title: 'Redis', subtitle: 'Cache', brand: 'redis' } }, + { id: 'db', type: 'database', card: { title: 'Postgres', subtitle: 'Source of truth', brand: 'postgresql' } }, + { id: 'trace', type: 'queue', card: { title: 'Trace', subtitle: 'Async event' } }, + ], + edges: [ + { id: 'open', from: 'user', to: 'web', label: 'open page' }, + { id: 'get', from: 'web', to: 'api', label: 'GET /dashboard', tone: 'main' }, + { id: 'verify', from: 'api', to: 'auth', label: 'verify JWT', tone: 'security' }, + { id: 'claims', from: 'auth', to: 'api', label: 'claims ok', kind: 'return' }, + { id: 'read', from: 'api', to: 'redis', label: 'read cache' }, + { id: 'miss', from: 'redis', to: 'api', label: 'miss', kind: 'return' }, + { id: 'query', from: 'api', to: 'db', label: 'query profile', tone: 'main' }, + { id: 'rows', from: 'db', to: 'api', label: 'rows', kind: 'return' }, + { id: 'set', from: 'api', to: 'redis', label: 'set cache', kind: 'async' }, + { id: 'emit', from: 'api', to: 'trace', label: 'emit trace', kind: 'async' }, + { id: 'json', from: 'api', to: 'web', label: '200 JSON', kind: 'return' }, + { id: 'render', from: 'web', to: 'user', label: 'render', kind: 'return' }, + ], + views: [{ id: 'fallback', label: 'Fallback', caption: 'The database is read only after the cache misses.', nodes: ['api', 'redis', 'db'] }], + notes: [ + { title: 'Happy path', items: ['Web app → API → data source → response', 'Replies are quieter than forward calls', 'Activation bars show how long each part is busy'] }, + { title: 'Policy and fallback', items: ['JWT verification is a security interaction', 'The cache miss is visible without overpowering the main path'] }, + { title: 'Async trace', items: ['Trace emission is fire-and-forget', 'It never blocks the response'] }, + ], +}; + +export const asyncJob: DiagramDraft = { + kind: 'sequence', + title: 'Async job roundtrip', + subtitle: 'Accept now, work in the background, notify later', + phases: [ + { id: 'accept', label: 'Accept', edges: ['post', 'enqueue', 'accepted'] }, + { id: 'work', label: 'Background work', edges: ['deliver', 'perform', 'result', 'retry', 'persist'] }, + { id: 'notify', label: 'Notify and reconcile', edges: ['completed', 'webhook', 'poll', 'status', 'state', 'final'] }, + ], + nodes: [ + { id: 'client', type: 'client', card: { title: 'Client', subtitle: 'Mobile app' } }, + { id: 'api', type: 'service', card: { title: 'Jobs API', subtitle: 'Request edge' } }, + { id: 'queue', type: 'queue', card: { title: 'Queue', subtitle: 'Durable work', brand: 'rabbitmq' } }, + { id: 'worker', type: 'service', card: { title: 'Worker', subtitle: 'Background' } }, + { id: 'provider', type: 'external', card: { title: 'Provider', subtitle: 'External API' } }, + { id: 'store', type: 'database', card: { title: 'Job store', subtitle: 'Source of truth', brand: 'postgresql' } }, + { id: 'notifier', type: 'queue', card: { title: 'Notifier', subtitle: 'Webhook' } }, + ], + edges: [ + { id: 'post', from: 'client', to: 'api', label: 'POST /jobs', tone: 'main' }, + { id: 'enqueue', from: 'api', to: 'queue', label: 'enqueue job', kind: 'async', tone: 'main' }, + { id: 'accepted', from: 'api', to: 'client', label: '202 + job id', kind: 'return' }, + { id: 'deliver', from: 'queue', to: 'worker', label: 'deliver', tone: 'main' }, + { id: 'perform', from: 'worker', to: 'provider', label: 'perform work' }, + { id: 'result', from: 'provider', to: 'worker', label: 'result or timeout', kind: 'return' }, + { id: 'retry', from: 'worker', to: 'queue', label: 'retry if timeout', kind: 'async', tone: 'error' }, + { id: 'persist', from: 'worker', to: 'store', label: 'persist final state', tone: 'main' }, + { id: 'completed', from: 'worker', to: 'notifier', label: 'job.completed', kind: 'async' }, + { id: 'webhook', from: 'notifier', to: 'client', label: 'signed webhook', kind: 'async', tone: 'security' }, + { id: 'poll', from: 'client', to: 'api', label: 'GET /jobs/:id' }, + { id: 'status', from: 'api', to: 'store', label: 'read status' }, + { id: 'state', from: 'store', to: 'api', label: 'completed', kind: 'return' }, + { id: 'final', from: 'api', to: 'client', label: '200 final result', kind: 'return' }, + ], + notes: [ + { title: 'Latency contract', items: ['The client gets 202 before any work starts', 'Retries stay inside the background phase'] }, + { title: 'Reconciliation', items: ['The webhook is signed', 'Polling reads the job store, the source of truth'] }, + ], +}; + export const GALLERY = { 'release-delivery': releaseDelivery, 'incident-response': incidentResponse, @@ -436,4 +516,6 @@ export const GALLERY = { 'web-app': webApp, 'product-analytics': productAnalytics, 'event-stream': eventStream, + 'cache-miss': cacheMiss, + 'async-job': asyncJob, } as const satisfies Record; diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index 39daa18..c7227a9 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -5,8 +5,10 @@ import type { ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api'; import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; import { findLabelSpot, labelWidth, placeLabel } from './labels'; import { layoutLanes } from './lanes'; +import { layoutSequence } from './sequence'; export { layoutLanes } from './lanes'; +export { layoutSequence } from './sequence'; /** Vertical space reserved at the top of a group for its label. */ export const GROUP_LABEL_BAND = 48; @@ -87,6 +89,7 @@ const round = (n: number) => Math.round(n * 100) / 100; export async function layoutDiagram(draft: DiagramDraft): Promise { if (isLaneKind(draft.kind)) return layoutLanes(draft); + if (draft.kind === 'sequence') return layoutSequence(draft); const direction = draft.direction ?? 'RIGHT'; const compact = usesCompactCards(draft); const variant = compact ? 'compact' : 'full'; diff --git a/packages/layout/src/sequence.ts b/packages/layout/src/sequence.ts new file mode 100644 index 0000000..c6348c2 --- /dev/null +++ b/packages/layout/src/sequence.ts @@ -0,0 +1,238 @@ +import { cardSize, measureText, type DiagramDraft, type DiagramEdge, type LaidOutDiagram, type Point, type Rect, type SequenceLayout } from '@stackmap/core'; + +// Sequence layout: participants across the top in draft order, messages top to bottom in edge order. The agent +// gives only order; stackmap derives column spacing from the labels, rows from the messages and phases, and +// activation bars from calls and replies. + +const PAD = 40; +/** Space between the participant cards and the first message. */ +const HEAD_GAP = 36; +/** Minimum distance between neighbouring lifelines, beyond the card width. */ +const COL_GAP = 40; +const ROW = 44; +const SELF_ROW = 60; +/** Width and height of a self-message's loop. */ +const LOOP = { width: 36, height: 22 }; +/** A phase's band: the label strip above its first message, and the room after its last. */ +export const PHASE_HEAD = 28; +const PHASE_TAIL = 12; +const PHASE_PAD_X = 16; +/** Activation bar width, and the sideways step of each nested bar. */ +export const ACTIVATION = { width: 10, nest: 5 }; +/** Shortest bar: an async hit or a call answered on the next row still shows. */ +const MIN_BAR = 24; +const LABEL = { face: 'sans400', size: 11.5, height: 16, gap: 6 } as const; +const labelWidth = (text: string) => Math.ceil(measureText(text, LABEL.face, LABEL.size)) + 12; + +const round = (n: number) => Math.round(n * 100) / 100; + +export interface Bar { + participant: string; + /** 0 = on the lifeline; a self-call nests one step right of what is open */ + depth: number; + /** first and last message row it covers */ + from: number; + to: number; + /** the self-message that drew it, for nested bars */ + self?: string; + /** who called, and the last row the participant was busy (opened or sent) */ + caller?: string; + busy?: number; +} + +/** + * Activation bars by simulation over the messages in order, per participant a stack of open bars: + * - a call to an idle participant opens a bar; a call to one that is waiting on its own call (a callback) + * nests a bar inside; a call from someone new to a participant that is busy but waiting on nobody first ends + * the old bar where it was last busy; + * - a reply closes the replier's bar opened by the one it answers (else its innermost), with any nested inside; + * - a self-call nests a short bar; sending or receiving keeps a bar busy; + * - a bar still open at the end runs to its participant's last message. + */ +export function activations(edges: DiagramEdge[]): Bar[] { + const stacks = new Map(); + /** per participant, the callees it is waiting to hear back from */ + const waiting = new Map(); + const last = new Map(); + const bars: Bar[] = []; + const stackOf = (p: string) => { + let st = stacks.get(p); + if (!st) stacks.set(p, (st = [])); + return st; + }; + const touch = (p: string, row: number) => { + const top = stacks.get(p)?.at(-1); + if (top) top.busy = row; + }; + edges.forEach((e, row) => { + last.set(e.from, row); + last.set(e.to, row); + touch(e.from, row); + if (e.from === e.to) { + bars.push({ participant: e.from, depth: stackOf(e.from).length, from: row, to: row, self: e.id }); + return; + } + if (e.kind === 'return') { + touch(e.to, row); + const st = stackOf(e.from); + let i = st.map((b) => b.caller).lastIndexOf(e.to); + if (i < 0) i = st.length - 1; + if (i >= 0) for (const bar of st.splice(i)) bar.to = row; + const w = waiting.get(e.to); + const k = w?.lastIndexOf(e.from) ?? -1; + if (k >= 0) w!.splice(k, 1); + return; + } + const st = stackOf(e.to); + const pending = (waiting.get(e.to)?.length ?? 0) > 0; + const top = st.at(-1); + if (top && !pending && top.caller !== e.from) { + // Busy with someone else and waiting on nobody: that earlier work is done. + for (const bar of st.splice(0)) bar.to = bar.busy ?? bar.from; + } + // Receiving counts as busy, but only for work this message belongs to (checked above). + touch(e.to, row); + if (!st.length || (pending && e.kind !== 'async')) { + const bar: Bar = { participant: e.to, depth: st.length, from: row, to: row, caller: e.from, busy: row }; + st.push(bar); + bars.push(bar); + } + if (e.kind !== 'async') waiting.set(e.from, [...(waiting.get(e.from) ?? []), e.to]); + }); + for (const st of stacks.values()) for (const bar of st) bar.to = Math.max(bar.to, last.get(bar.participant) ?? bar.to); + return bars.map(({ caller: _c, busy: _b, ...b }) => b); +} + +export function layoutSequence(draft: DiagramDraft): LaidOutDiagram { + const people = draft.nodes; + const index = new Map(people.map((n, i) => [n.id, i])); + const size = new Map(people.map((n) => [n.id, cardSize(n.card, 'compact')])); + const cardW = Math.max(176, ...[...size.values()].map((s) => s.width)); + const cardH = Math.max(...[...size.values()].map((s) => s.height)); + const edges = draft.edges.filter((e) => index.has(e.from) && index.has(e.to)); + + // Lifeline spacing: every gap at least a card plus COL_GAP; a label that spans gaps widens them evenly. + const gaps = Array.from({ length: Math.max(0, people.length - 1) }, () => cardW + COL_GAP); + let rightExtra = 0; + const spans = edges + .map((e) => ({ e, a: Math.min(index.get(e.from)!, index.get(e.to)!), b: Math.max(index.get(e.from)!, index.get(e.to)!) })) + .sort((x, y) => x.b - x.a - (y.b - y.a)); + for (const { e, a, b } of spans) { + if (!e.label) continue; + const w = labelWidth(e.label); + if (a === b) { + // A self-message's label sits right of its loop, before the next lifeline. + const need = LOOP.width + 8 + w + 16; + if (a < gaps.length) gaps[a] = Math.max(gaps[a]!, need + ACTIVATION.width); + else rightExtra = Math.max(rightExtra, need - cardW / 2); + continue; + } + const have = gaps.slice(a, b).reduce((s, g) => s + g, 0); + const need = w + 32; + if (need > have) for (let i = a; i < b; i++) gaps[i]! += (need - have) / (b - a); + } + const xs: number[] = [PAD + cardW / 2]; + for (const g of gaps) xs.push(xs.at(-1)! + g); + const xOf = (id: string) => xs[index.get(id)!]!; + + const nodes: Record = {}; + for (const n of people) { + const s = size.get(n.id)!; + nodes[n.id] = { x: round(xOf(n.id) - s.width / 2), y: PAD + (cardH - s.height), width: s.width, height: s.height }; + } + + // Rows: each message gets a row; a phase adds its label strip before its first message and room after its last. + const edgeIndex = new Map(edges.map((e, i) => [e.id, i])); + const phaseRange = (draft.phases ?? []) + .map((p) => { + const rows = (p.edges ?? []).flatMap((id) => (edgeIndex.has(id) ? [edgeIndex.get(id)!] : [])); + return rows.length ? { id: p.id, first: Math.min(...rows), last: Math.max(...rows) } : null; + }) + .filter((p): p is { id: string; first: number; last: number } => p !== null); + const rowY: number[] = []; + let cursor = PAD + cardH + HEAD_GAP; + const phaseTop = new Map(); + const phaseBottom = new Map(); + edges.forEach((e, i) => { + for (const p of phaseRange) if (p.first === i) { + phaseTop.set(p.id, cursor); + cursor += PHASE_HEAD; + } + const self = e.from === e.to; + // The arrow sits below its label. + rowY.push(cursor + LABEL.height + LABEL.gap); + cursor += self ? SELF_ROW : ROW; + for (const p of phaseRange) if (p.last === i) { + cursor += PHASE_TAIL; + phaseBottom.set(p.id, cursor); + } + }); + const bottom = cursor + 12; + + const bars = activations(edges); + const barRect = (b: Bar): Rect => { + const x = xOf(b.participant) - ACTIVATION.width / 2 + b.depth * ACTIVATION.nest; + // A self-call's bar starts where its loop comes back; a lifeline bar brackets its first and last rows. + const y0 = b.self ? rowY[b.from]! + LOOP.height - 6 : rowY[b.from]! - 6; + const y1 = Math.max(b.self ? y0 : rowY[b.to]! + 6, y0 + (b.self ? 18 : MIN_BAR)); + return { x: round(x), y: round(y0), width: ACTIVATION.width, height: round(y1 - y0) }; + }; + // A message leaves and arrives at the edge of the innermost bar open on its row. + const reach = (participant: string, row: number) => { + const open = bars.filter((b) => !b.self && b.participant === participant && b.from <= row && b.to >= row); + return open.length ? ACTIVATION.width / 2 + Math.max(...open.map((b) => b.depth)) * ACTIVATION.nest : 0; + }; + + const out: Record = {}; + const labels: Record = {}; + edges.forEach((e, i) => { + const y = rowY[i]!; + const x = xOf(e.from); + if (e.from === e.to) { + const x0 = x + reach(e.from, i); + const bar = bars.find((b) => b.self === e.id)!; + out[e.id] = [ + { x: round(x0), y: round(y) }, + { x: round(x0 + LOOP.width), y: round(y) }, + { x: round(x0 + LOOP.width), y: round(y + LOOP.height) }, + { x: round(x - ACTIVATION.width / 2 + bar.depth * ACTIVATION.nest + ACTIVATION.width), y: round(y + LOOP.height) }, + ]; + if (e.label) labels[e.id] = { x: round(x0 + LOOP.width + 8 + labelWidth(e.label) / 2), y: round(y + LOOP.height / 2) }; + return; + } + const tx = xOf(e.to); + const dir = tx > x ? 1 : -1; + const a = x + dir * reach(e.from, i); + const b = tx - dir * reach(e.to, i); + out[e.id] = [ + { x: round(a), y: round(y) }, + { x: round(b), y: round(y) }, + ]; + if (e.label) labels[e.id] = { x: round((a + b) / 2), y: round(y - LABEL.gap - LABEL.height / 2) }; + }); + + const lifelines: SequenceLayout['lifelines'] = {}; + for (const n of people) lifelines[n.id] = { x: round(xOf(n.id)), top: nodes[n.id]!.y + nodes[n.id]!.height, bottom: round(bottom) }; + + const left = xs[0]! - cardW / 2 - PHASE_PAD_X; + const right = xs.at(-1)! + cardW / 2 + PHASE_PAD_X + rightExtra; + const phases: Record = {}; + for (const p of phaseRange) { + const top = phaseTop.get(p.id)!; + phases[p.id] = { x: round(left), y: round(top), width: round(right - left), height: round(phaseBottom.get(p.id)! - top) }; + } + + return { + draft, + nodes, + groups: {}, + edges: out, + labels, + phases, + sequence: { + lifelines, + activations: bars.map((b) => ({ participant: b.participant, rect: barRect(b), depth: b.depth })), + }, + bounds: { width: round(right + PAD), height: round(bottom + PAD) }, + }; +} diff --git a/packages/layout/test/lanes.test.ts b/packages/layout/test/lanes.test.ts index e5f8c66..c97571c 100644 --- a/packages/layout/test/lanes.test.ts +++ b/packages/layout/test/lanes.test.ts @@ -23,6 +23,8 @@ describe.each(laid)('gallery sample %s', (_name, draft: DiagramDraft, out: LaidO }); it('routes every edge orthogonally from its source card to its target card, through no card', () => { + // Sequence messages run between lifelines, not cards: checked in sequence.test.ts. + if (draft.kind === 'sequence') return; for (const e of draft.edges) { const pts = out.edges[e.id]!; expect(pts.length, e.id).toBeGreaterThanOrEqual(2); diff --git a/packages/layout/test/sequence.test.ts b/packages/layout/test/sequence.test.ts new file mode 100644 index 0000000..6dcbc0e --- /dev/null +++ b/packages/layout/test/sequence.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import type { DiagramDraft, DiagramEdge } from '@stackmap/core'; +import { asyncJob, cacheMiss } from '@stackmap/core/gallery'; +import { activations, layoutSequence } from '../src/sequence'; + +const seq = (edges: DiagramEdge[], extra: Partial = {}): DiagramDraft => ({ + kind: 'sequence', + title: 't', + nodes: ['a', 'b', 'c'].map((id) => ({ id, type: 'service' as const, card: { title: id.toUpperCase() } })), + edges, + ...extra, +}); +const m = (id: string, from: string, to: string, over: Partial = {}): DiagramEdge => ({ id, from, to, ...over }); + +describe.each([ + ['cache miss', cacheMiss], + ['async job', asyncJob], +])('%s', (_n, draft) => { + const out = layoutSequence(draft); + + it('draws messages top to bottom in order, each flat, between its two lifelines', () => { + let y = -Infinity; + for (const e of draft.edges) { + const pts = out.edges[e.id]!; + expect(pts[0]!.y, e.id).toBeGreaterThan(y); + y = pts[0]!.y; + if (e.from === e.to) continue; + expect(pts).toHaveLength(2); + expect(pts[1]!.y).toBe(pts[0]!.y); + const [from, to] = [out.sequence!.lifelines[e.from]!.x, out.sequence!.lifelines[e.to]!.x]; + expect(Math.abs(pts[0]!.x - from), `${e.id} leaves its lifeline`).toBeLessThanOrEqual(5); + expect(Math.abs(pts[1]!.x - to), `${e.id} reaches its lifeline`).toBeLessThanOrEqual(5); + } + }); + + it('keeps lifelines in participant order, under their cards, down past the last message', () => { + const xs = draft.nodes.map((n) => out.sequence!.lifelines[n.id]!.x); + expect([...xs].sort((a, b) => a - b)).toEqual(xs); + const lastY = out.edges[draft.edges.at(-1)!.id]![0]!.y; + for (const n of draft.nodes) { + const l = out.sequence!.lifelines[n.id]!; + const card = out.nodes[n.id]!; + expect(l.x).toBeCloseTo(card.x + card.width / 2); + expect(l.top).toBe(card.y + card.height); + expect(l.bottom).toBeGreaterThan(lastY); + } + }); + + it('puts every phase band around exactly its messages and keeps labels apart', () => { + for (const p of draft.phases ?? []) { + const band = out.phases![p.id]!; + for (const id of p.edges!) { + const y = out.edges[id]![0]!.y; + expect(y > band.y && y < band.y + band.height, `${id} in ${p.id}`).toBe(true); + } + } + const pills = Object.values(out.labels!); + for (let i = 0; i < pills.length; i++) for (let j = i + 1; j < pills.length; j++) expect(pills[i]!.x !== pills[j]!.x || pills[i]!.y !== pills[j]!.y).toBe(true); + }); + + it('is deterministic', () => { + expect(layoutSequence(draft)).toEqual(out); + }); +}); + +describe('activations', () => { + it('a call opens a bar on the callee and its reply closes it', () => { + expect(activations([m('1', 'a', 'b'), m('2', 'b', 'c'), m('3', 'c', 'b', { kind: 'return' }), m('4', 'b', 'a', { kind: 'return' })])).toEqual([ + { participant: 'b', depth: 0, from: 0, to: 3 }, + { participant: 'c', depth: 0, from: 1, to: 2 }, + ]); + }); + + it('an unanswered call ends where its participant was last busy once another caller arrives', () => { + expect(activations([m('1', 'a', 'c'), m('2', 'a', 'b'), m('3', 'b', 'c'), m('4', 'c', 'b', { kind: 'return' })])).toEqual([ + { participant: 'c', depth: 0, from: 0, to: 0 }, + { participant: 'b', depth: 0, from: 1, to: 3 }, + { participant: 'c', depth: 0, from: 2, to: 3 }, + ]); + }); + + it('a callback while the callee waits on its own call nests a bar, and the replies unwind it', () => { + // a→b request; b→c fetch; c→b callback; b→c ack; c→b data; b→a response + const bars = activations([ + m('0', 'a', 'b'), + m('1', 'b', 'c'), + m('2', 'c', 'b'), + m('3', 'b', 'c', { kind: 'return' }), + m('4', 'c', 'b', { kind: 'return' }), + m('5', 'b', 'a', { kind: 'return' }), + ]); + expect(bars).toEqual([ + { participant: 'b', depth: 0, from: 0, to: 5 }, + { participant: 'c', depth: 0, from: 1, to: 4 }, + { participant: 'b', depth: 1, from: 2, to: 3 }, + ]); + }); + + it('receiving a reply keeps a bar busy', () => { + // a→b; b→c; c→b (reply at row 2); d→b: b's first bar runs to row 2, then d's opens. + const bars = activations([m('0', 'a', 'b'), m('1', 'b', 'c'), m('2', 'c', 'b', { kind: 'return' }), m('3', 'd', 'b')]); + expect(bars.filter((b) => b.participant === 'b')).toEqual([ + { participant: 'b', depth: 0, from: 0, to: 2 }, + { participant: 'b', depth: 0, from: 3, to: 3 }, + ]); + }); + + it('a self-call nests a bar on an active participant', () => { + expect(activations([m('1', 'a', 'b'), m('2', 'b', 'b'), m('3', 'b', 'a', { kind: 'return' })])).toEqual([ + { participant: 'b', depth: 0, from: 0, to: 2 }, + { participant: 'b', depth: 1, from: 1, to: 1, self: '2' }, + ]); + }); +}); + +describe('layoutSequence', () => { + it('widens a gap for a long label and draws a self-call as a loop', () => { + const narrow = layoutSequence(seq([m('1', 'a', 'b', { label: 'x' })])); + const wide = layoutSequence(seq([m('1', 'a', 'b', { label: 'a very long message label indeed' })])); + const gap = (o: typeof narrow) => o.sequence!.lifelines.b!.x - o.sequence!.lifelines.a!.x; + expect(gap(wide)).toBeGreaterThan(gap(narrow)); + const self = layoutSequence(seq([m('1', 'a', 'b'), m('2', 'b', 'b', { label: 'cache' }), m('3', 'b', 'a', { kind: 'return' })])); + const loop = self.edges['2']!; + expect(loop).toHaveLength(4); + expect(loop[1]!.x).toBeGreaterThan(loop[0]!.x); + expect(loop.at(-1)!.y).toBeGreaterThan(loop[0]!.y); + }); + + it('lays out a sequence with no messages', () => { + const out = layoutSequence(seq([])); + expect(Object.keys(out.nodes)).toEqual(['a', 'b', 'c']); + expect(out.sequence!.activations).toEqual([]); + }); +}); diff --git a/packages/schema/src/rules/kinds.ts b/packages/schema/src/rules/kinds.ts index 3de16f8..4f91570 100644 --- a/packages/schema/src/rules/kinds.ts +++ b/packages/schema/src/rules/kinds.ts @@ -114,6 +114,63 @@ export function kindDiagnostics(d: DiagramDraft): Diagnostic[] { out.push(...phaseDiagnostics(d)); if (lanes && d.lanes?.length) out.push(...laneGroupDiagnostics(d)); + if (d.kind === 'sequence') out.push(...sequenceDiagnostics(d)); + return out; +} + +/** Replies that answer nothing, and time bands that overlap (each band spans its first to its last message). */ +function sequenceDiagnostics(d: DiagramDraft): Diagnostic[] { + const out: Diagnostic[] = []; + // Calls still waiting for a reply, per (caller → callee) pair. + const waiting = new Map(); + d.edges.forEach((e, i) => { + if (e.from === e.to) return; + if (e.kind !== 'return') { + if (e.kind !== 'async') waiting.set(`${e.from}>${e.to}`, (waiting.get(`${e.from}>${e.to}`) ?? 0) + 1); + return; + } + const key = `${e.to}>${e.from}`; + const open = waiting.get(key) ?? 0; + if (open > 0) waiting.set(key, open - 1); + else + out.push({ + ...error('semantics/unmatched-return', `/edges/${i}`, `Reply "${e.id}" answers no earlier call from "${e.to}" to "${e.from}"`, { id: e.id }, [ + `add the call from "${e.to}" to "${e.from}" before it`, + 'remove "kind": "return" if it is a call of its own', + ]), + severity: 'warning', + }); + }); + + const row = new Map(d.edges.map((e, i) => [e.id, i])); + const spans = (d.phases ?? []).flatMap((p, i) => { + const rows = (p.edges ?? []).flatMap((id) => (row.has(id) ? [row.get(id)!] : [])); + return rows.length ? [{ i, id: p.id, first: Math.min(...rows), last: Math.max(...rows) }] : []; + }); + // A band spans its first to its last message: messages in between it doesn't list are in it anyway. + for (const sp of spans) { + const listed = new Set(d.phases![sp.i]!.edges); + const unlisted = d.edges.slice(sp.first, sp.last + 1).filter((e) => !listed.has(e.id)).map((e) => e.id); + if (unlisted.length) + out.push({ + ...error('semantics/phase-gap', `/phases/${sp.i}/edges`, `Phase "${sp.id}" also covers ${unlisted.map((x) => `"${x}"`).join(', ')}, between its first and last message`, { id: sp.id, unlisted }, [ + `add ${unlisted.map((x) => `"${x}"`).join(', ')} to the phase`, + 'reorder the messages so the band is contiguous', + ]), + severity: 'warning', + }); + } + for (let k = 1; k < spans.length; k++) { + const prev = spans[k - 1]!; + const cur = spans[k]!; + if (cur.first <= prev.last) + out.push( + error('semantics/phase-overlap', `/phases/${cur.i}`, `Phase "${cur.id}" starts before phase "${prev.id}" ends; time bands follow each other`, { id: cur.id, previous: prev.id }, [ + `list only messages after "${d.edges[prev.last]!.id}" in "${cur.id}"`, + 'reorder the phases to follow the messages', + ]), + ); + } return out; } diff --git a/packages/schema/test/kinds.test.ts b/packages/schema/test/kinds.test.ts index d13491c..4943e57 100644 --- a/packages/schema/test/kinds.test.ts +++ b/packages/schema/test/kinds.test.ts @@ -159,6 +159,37 @@ describe('kind rules', () => { ]); }); + it('sequence: replies must answer a call, and time bands follow each other', () => { + const seq = (edges: unknown[], phases?: unknown[]) => ({ + kind: 'sequence', + title: 'T', + nodes: [ + { id: 'a', type: 'client', card: { title: 'A' } }, + { id: 'b', type: 'service', card: { title: 'B' } }, + ], + edges, + ...(phases ? { phases } : {}), + }); + const call = { id: 'c', from: 'a', to: 'b' }; + const reply = { id: 'r', from: 'b', to: 'a', kind: 'return' }; + expect(codes(seq([call, reply]))).toEqual([]); + expect(codes(seq([reply, call]))).toEqual(['warning:semantics/unmatched-return@/edges/0']); + expect(codes(seq([{ ...call, kind: 'async' }, reply]))).toEqual(['warning:semantics/unmatched-return@/edges/1']); + // A self-call is fine, and draws no self-loop warning. + expect(codes(seq([call, { id: 's', from: 'b', to: 'b', label: 'cache' }, reply]))).toEqual([]); + const bands = [ + { id: 'p', label: 'P', edges: ['c', 'r'] }, + { id: 'q', label: 'Q', edges: ['r2'] }, + ]; + expect(codes(seq([call, reply, { ...call, id: 'c2' }, { ...reply, id: 'r2' }], bands))).toEqual([]); + expect(codes(seq([call, { ...call, id: 'c2' }, reply, { ...reply, id: 'r2' }], [{ id: 'p', label: 'P', edges: ['c', 'r'] }, { id: 'q', label: 'Q', edges: ['c2'] }]))).toEqual([ + 'warning:semantics/phase-gap@/phases/0/edges', + 'error:semantics/phase-overlap@/phases/1', + ]); + expect(codes({ ...seq([call]), groups: [{ id: 'g', label: 'G' }] })).toContain('error:semantics/groups-for-kind@/groups'); + expect(codes(seq([call, { ...call, id: 'c2' }, reply, { ...reply, id: 'r2' }], [{ id: 'p', label: 'P', edges: ['c', 'r'] }]))).toContain('warning:semantics/phase-gap@/phases/0/edges'); + }); + it('warns that lane kinds ignore direction', () => { expect(codes(workflow({ direction: 'DOWN' }))).toEqual(['warning:semantics/direction-ignored@/direction']); }); diff --git a/packages/viewer/e2e/lanes.spec.ts b/packages/viewer/e2e/lanes.spec.ts index c6f6921..e1d0948 100644 --- a/packages/viewer/e2e/lanes.spec.ts +++ b/packages/viewer/e2e/lanes.spec.ts @@ -38,3 +38,16 @@ test('a staged dataflow draws its stages as bands', async ({ page }) => { await expect(page.locator('[data-phase-id]')).toHaveCount(5); await expect(page.locator('[data-testid="step-card"]')).toHaveCount(10); }); + +test('a sequence draws lifelines, activation bars and time bands, and selecting lights its messages', async ({ page }) => { + await page.goto('/?page=cache-miss'); + await expect(page.locator('[data-lifeline]')).toHaveCount(7); + await expect(page.locator('[data-activation="api"]')).toHaveCount(1); + await expect(page.locator('[data-phase-id]')).toHaveCount(3); + await expect(page.getByText('Sequence', { exact: true })).toBeVisible(); + await page.locator('.sm-card[data-card-id="redis"]').click(); + await expect(page.locator('path[data-edge-id="read"]')).toHaveAttribute('data-tint', 'service'); + await expect(page.locator('path[data-edge-id="miss"]')).toHaveAttribute('data-tint', 'cache'); + await page.getByRole('tab', { name: 'Fallback' }).click(); + await expect(page.locator('[data-lifeline="user"]')).toHaveAttribute('data-dim', 'true'); +}); diff --git a/packages/viewer/e2e/visual.spec.ts b/packages/viewer/e2e/visual.spec.ts index 5fb87bd..fa736fa 100644 --- a/packages/viewer/e2e/visual.spec.ts +++ b/packages/viewer/e2e/visual.spec.ts @@ -11,6 +11,7 @@ const PAGES = { workflow: '/?page=release-delivery', lifecycle: '/?page=agent-run', stages: '/?page=product-analytics', + sequence: '/?page=cache-miss', } as const; for (const theme of ['light', 'dark'] as const) { diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png new file mode 100644 index 0000000..5dc9fa0 Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png new file mode 100644 index 0000000..b627d8e Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png index 29d9068..99c3944 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png index cdca049..9fdbeb8 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png differ diff --git a/packages/viewer/src/canvas/Minimap.tsx b/packages/viewer/src/canvas/Minimap.tsx index 6b1e690..98f4ea6 100644 --- a/packages/viewer/src/canvas/Minimap.tsx +++ b/packages/viewer/src/canvas/Minimap.tsx @@ -63,6 +63,9 @@ export function Minimap({ scene }: { scene: Scene }) { {[...scene.lanes, ...(scene.phaseStyle === 'band' ? scene.phases : [])].map((l) => ( ))} + {scene.lifelines.map((l) => ( + + ))} {scene.frames.map((f) => ( +
{phase.label}
+ + ); + } if (style === 'band') { return (
+ {scene.lifelines.map((l) => ( + + ))} + {scene.activations.map((a, i) => ( + + ))} {scene.edges.map((e) => { const { dim, tint } = emphasis.edges.get(e.id) ?? { dim: false, tint: null }; const look = edgeLook(e, tint); @@ -290,12 +330,15 @@ export const SceneLayers = memo(function SceneLayers({ key={e.id} data-edge-label={e.id} data-dim={emphasis.edges.get(e.id)?.dim || undefined} - className="sm-edge-label pointer-events-none absolute rounded-full bg-panel px-2 py-0.5 font-sans text-[11px] whitespace-nowrap text-fg-muted" + className={`sm-edge-label pointer-events-none absolute font-sans whitespace-nowrap ${ + scene.labelStyle === 'text' ? 'rounded bg-stage px-1.5 text-[11.5px] leading-4 text-fg' : 'rounded-full bg-panel px-2 py-0.5 text-[11px] text-fg-muted' + }`} style={{ left: e.mid.x, top: e.mid.y, transform: 'translate(-50%, -50%)', - boxShadow: 'inset 0 0 0 1px var(--sm-panel-border)', + // Text labels sit on the stage (lifelines pass behind them); pills get their outline. + boxShadow: scene.labelStyle === 'text' ? undefined : 'inset 0 0 0 1px var(--sm-panel-border)', }} > {e.label} diff --git a/packages/viewer/src/canvas/scene.test.ts b/packages/viewer/src/canvas/scene.test.ts index c3dea6a..14af941 100644 --- a/packages/viewer/src/canvas/scene.test.ts +++ b/packages/viewer/src/canvas/scene.test.ts @@ -110,4 +110,17 @@ describe('toScene', () => { expect(staged.compact).toBe(true); expect(staged.phases.map((p) => p.id)).toEqual(['sources', 'ingest', 'process', 'store', 'consume']); }); + + it('sequence: lifelines under every participant, activation bars, text labels, no dots', () => { + const d = galleryLayouts['cache-miss']!; + const seq = toScene(d); + expect(seq.phaseStyle).toBe('time'); + expect(seq.labelStyle).toBe('text'); + expect(seq.handles).toEqual([]); + expect(seq.lifelines.map((l) => l.node)).toEqual(d.draft.nodes.map((n) => n.id)); + expect(seq.activations.length).toBe(d.sequence!.activations.length); + // Fit frames the lifelines too, not just the cards at the top. + const bottom = Math.max(...seq.lifelines.map((l) => l.bottom)); + expect(seq.content.y + seq.content.height).toBeGreaterThanOrEqual(bottom); + }); }); diff --git a/packages/viewer/src/canvas/scene.ts b/packages/viewer/src/canvas/scene.ts index 4c40b0f..1882a9a 100644 --- a/packages/viewer/src/canvas/scene.ts +++ b/packages/viewer/src/canvas/scene.ts @@ -62,6 +62,21 @@ export interface SceneEdge { mid?: Point; } +export interface SceneLifeline { + node: string; + type: NodeType; + x: number; + top: number; + bottom: number; +} + +export interface SceneActivation { + node: string; + type: NodeType; + rect: Rect; + depth: number; +} + /** A connection dot where an edge meets a card, tinted by that card's type. */ export interface SceneHandle { node: string; @@ -86,6 +101,11 @@ export interface Scene { edges: SceneEdge[]; /** Lane layouts attach edges anywhere on a card, so dots sit at the route ends. ELK layouts: null (fixed ports). */ handles: SceneHandle[] | null; + /** sequence: one lifeline per participant and the activation bars on them */ + lifelines: SceneLifeline[]; + activations: SceneActivation[]; + /** pill: a chip on the line; text: plain text above it (sequence messages) */ + labelStyle: 'pill' | 'text'; } function need(value: T | undefined, what: string): T { @@ -167,7 +187,17 @@ export function toScene(d: LaidOutDiagram): Scene { }); const typeOf = new Map(d.draft.nodes.map((n) => [n.id, n.type])); - let handles: SceneHandle[] | null = null; + const sequence = d.draft.kind === 'sequence'; + const lifelines: SceneLifeline[] = sequence + ? d.draft.nodes.flatMap((n) => { + const l = d.sequence?.lifelines[n.id]; + return l ? [{ node: n.id, type: n.type, ...l }] : []; + }) + : []; + const activations: SceneActivation[] = (d.sequence?.activations ?? []).flatMap((a) => + typeOf.has(a.participant) ? [{ node: a.participant, type: typeOf.get(a.participant)!, rect: a.rect, depth: a.depth }] : [], + ); + let handles: SceneHandle[] | null = sequence ? [] : null; if (lanes) { const seen = new Set(); handles = []; @@ -190,12 +220,24 @@ export function toScene(d: LaidOutDiagram): Scene { compact: usesCompactCards(d.draft), phaseStyle: lanes ? 'header' : d.draft.kind === 'sequence' ? 'time' : 'band', bounds: d.bounds, - content: union([...laneList.map((l) => l.rect), ...phases.map((p) => p.rect), ...frames.map((f) => f.rect), ...cards.map((c) => c.rect)]), + content: union([ + ...laneList.map((l) => l.rect), + ...phases.map((p) => p.rect), + ...frames.map((f) => f.rect), + ...cards.map((c) => c.rect), + ...lifelines.map((l) => ({ x: l.x, y: l.top, width: 0, height: l.bottom - l.top })), + // Labels can reach past everything else (a self-call's label on the last lifeline). The viewer can't ship + // the font metrics, so their boxes are a generous estimate: 7px a character plus padding. + ...edges.flatMap((e) => (e.label && e.mid ? [{ x: e.mid.x - (e.label.length * 7 + 16) / 2, y: e.mid.y - 10, width: e.label.length * 7 + 16, height: 20 }] : [])), + ]), lanes: laneList, phases, frames, cards, edges, handles, + lifelines, + activations, + labelStyle: sequence ? 'text' : 'pill', }; } diff --git a/packages/viewer/src/motion/useSceneReveal.ts b/packages/viewer/src/motion/useSceneReveal.ts index 4a079e5..9a4f4ba 100644 --- a/packages/viewer/src/motion/useSceneReveal.ts +++ b/packages/viewer/src/motion/useSceneReveal.ts @@ -27,8 +27,10 @@ export function useSceneReveal(root: RefObject, scene: Scene const fresh = (key: string) => !prev?.has(key); const q = (sel: string) => el.querySelector(sel); - const pos = (r: Parameters[1]) => flowPosition(scene.direction, r); - const start = (r: Parameters[1]) => flowPosition(scene.direction, { ...r, width: 0, height: 0 }); + // A sequence reads in time: participants arrive together, then messages draw top to bottom. + const sequence = scene.kind === 'sequence'; + const pos = (r: Parameters[1]) => (sequence ? 0 : flowPosition(scene.direction, r)); + const start = (r: Parameters[1]) => (sequence ? 0 : flowPosition(scene.direction, { ...r, width: 0, height: 0 })); const centre = new Map(scene.cards.map((c) => [c.node.id, pos(c.rect)])); const targets: RevealTargets = { items: [], edges: [] }; @@ -49,7 +51,15 @@ export function useSceneReveal(root: RefObject, scene: Scene if (!path) continue; const label = q(`[data-edge-label="${CSS.escape(e.id)}"]`) ?? undefined; // Dashed and dotted connections fade in: drawing them would show a solid line that snaps to dashes. - targets.edges.push({ el: path as unknown as SVGPathElement, from: centre.get(e.from) ?? 0, to: centre.get(e.to) ?? 0, async: e.kind !== 'sync', label }); + const at = sequence ? e.points[0]!.y : undefined; + targets.edges.push({ el: path as unknown as SVGPathElement, from: at ?? centre.get(e.from) ?? 0, to: at ?? centre.get(e.to) ?? 0, async: e.kind !== 'sync', label }); + } + // A sequence's activation bars arrive with the message that opens them. + if (sequence) { + el.querySelectorAll('[data-activation]').forEach((bar, i) => { + const a = scene.activations[i]; + if (a && fresh(`n:${a.node}`)) targets.items.push({ el: bar as unknown as HTMLElement, at: 0, kind: 'frame' }); + }); } const motion = revealScene(targets); return () => motion.cancel(); diff --git a/packages/viewer/src/samples/gallery.layout.ts b/packages/viewer/src/samples/gallery.layout.ts index 098a178..04f9a44 100644 --- a/packages/viewer/src/samples/gallery.layout.ts +++ b/packages/viewer/src/samples/gallery.layout.ts @@ -4820,5 +4820,1194 @@ export const galleryLayouts: Record = { "y": 314 } } + }, + "cache-miss": { + "draft": { + "kind": "sequence", + "title": "Cache miss request", + "subtitle": "Dashboard load when the cache is cold", + "phases": [ + { + "id": "request", + "label": "Request", + "edges": [ + "open", + "get", + "verify", + "claims" + ] + }, + { + "id": "fallback", + "label": "Fallback", + "edges": [ + "read", + "miss", + "query", + "rows" + ] + }, + { + "id": "respond", + "label": "Response and trace", + "edges": [ + "set", + "emit", + "json", + "render" + ] + } + ], + "nodes": [ + { + "id": "user", + "type": "client", + "card": { + "title": "User", + "subtitle": "Browser session" + } + }, + { + "id": "web", + "type": "client", + "card": { + "title": "Web app", + "subtitle": "React UI", + "brand": "react" + } + }, + { + "id": "api", + "type": "service", + "card": { + "title": "API", + "subtitle": "Request handler" + } + }, + { + "id": "auth", + "type": "security", + "card": { + "title": "Auth", + "subtitle": "JWT verify" + } + }, + { + "id": "redis", + "type": "cache", + "card": { + "title": "Redis", + "subtitle": "Cache", + "brand": "redis" + } + }, + { + "id": "db", + "type": "database", + "card": { + "title": "Postgres", + "subtitle": "Source of truth", + "brand": "postgresql" + } + }, + { + "id": "trace", + "type": "queue", + "card": { + "title": "Trace", + "subtitle": "Async event" + } + } + ], + "edges": [ + { + "id": "open", + "from": "user", + "to": "web", + "label": "open page" + }, + { + "id": "get", + "from": "web", + "to": "api", + "label": "GET /dashboard", + "tone": "main" + }, + { + "id": "verify", + "from": "api", + "to": "auth", + "label": "verify JWT", + "tone": "security" + }, + { + "id": "claims", + "from": "auth", + "to": "api", + "label": "claims ok", + "kind": "return" + }, + { + "id": "read", + "from": "api", + "to": "redis", + "label": "read cache" + }, + { + "id": "miss", + "from": "redis", + "to": "api", + "label": "miss", + "kind": "return" + }, + { + "id": "query", + "from": "api", + "to": "db", + "label": "query profile", + "tone": "main" + }, + { + "id": "rows", + "from": "db", + "to": "api", + "label": "rows", + "kind": "return" + }, + { + "id": "set", + "from": "api", + "to": "redis", + "label": "set cache", + "kind": "async" + }, + { + "id": "emit", + "from": "api", + "to": "trace", + "label": "emit trace", + "kind": "async" + }, + { + "id": "json", + "from": "api", + "to": "web", + "label": "200 JSON", + "kind": "return" + }, + { + "id": "render", + "from": "web", + "to": "user", + "label": "render", + "kind": "return" + } + ], + "views": [ + { + "id": "fallback", + "label": "Fallback", + "caption": "The database is read only after the cache misses.", + "nodes": [ + "api", + "redis", + "db" + ] + } + ], + "notes": [ + { + "title": "Happy path", + "items": [ + "Web app → API → data source → response", + "Replies are quieter than forward calls", + "Activation bars show how long each part is busy" + ] + }, + { + "title": "Policy and fallback", + "items": [ + "JWT verification is a security interaction", + "The cache miss is visible without overpowering the main path" + ] + }, + { + "title": "Async trace", + "items": [ + "Trace emission is fire-and-forget", + "It never blocks the response" + ] + } + ] + }, + "nodes": { + "user": { + "x": 40, + "y": 40, + "width": 176, + "height": 60 + }, + "web": { + "x": 256, + "y": 40, + "width": 176, + "height": 60 + }, + "api": { + "x": 472, + "y": 40, + "width": 176, + "height": 60 + }, + "auth": { + "x": 688, + "y": 40, + "width": 176, + "height": 60 + }, + "redis": { + "x": 904, + "y": 40, + "width": 176, + "height": 60 + }, + "db": { + "x": 1120, + "y": 40, + "width": 176, + "height": 60 + }, + "trace": { + "x": 1336, + "y": 40, + "width": 176, + "height": 60 + } + }, + "groups": {}, + "edges": { + "open": [ + { + "x": 128, + "y": 186 + }, + { + "x": 339, + "y": 186 + } + ], + "get": [ + { + "x": 349, + "y": 230 + }, + { + "x": 555, + "y": 230 + } + ], + "verify": [ + { + "x": 565, + "y": 274 + }, + { + "x": 771, + "y": 274 + } + ], + "claims": [ + { + "x": 771, + "y": 318 + }, + { + "x": 565, + "y": 318 + } + ], + "read": [ + { + "x": 565, + "y": 402 + }, + { + "x": 987, + "y": 402 + } + ], + "miss": [ + { + "x": 987, + "y": 446 + }, + { + "x": 565, + "y": 446 + } + ], + "query": [ + { + "x": 565, + "y": 490 + }, + { + "x": 1203, + "y": 490 + } + ], + "rows": [ + { + "x": 1203, + "y": 534 + }, + { + "x": 565, + "y": 534 + } + ], + "set": [ + { + "x": 565, + "y": 618 + }, + { + "x": 987, + "y": 618 + } + ], + "emit": [ + { + "x": 565, + "y": 662 + }, + { + "x": 1419, + "y": 662 + } + ], + "json": [ + { + "x": 555, + "y": 706 + }, + { + "x": 349, + "y": 706 + } + ], + "render": [ + { + "x": 339, + "y": 750 + }, + { + "x": 128, + "y": 750 + } + ] + }, + "labels": { + "open": { + "x": 233.5, + "y": 172 + }, + "get": { + "x": 452, + "y": 216 + }, + "verify": { + "x": 668, + "y": 260 + }, + "claims": { + "x": 668, + "y": 304 + }, + "read": { + "x": 776, + "y": 388 + }, + "miss": { + "x": 776, + "y": 432 + }, + "query": { + "x": 884, + "y": 476 + }, + "rows": { + "x": 884, + "y": 520 + }, + "set": { + "x": 776, + "y": 604 + }, + "emit": { + "x": 992, + "y": 648 + }, + "json": { + "x": 452, + "y": 692 + }, + "render": { + "x": 233.5, + "y": 736 + } + }, + "phases": { + "request": { + "x": 24, + "y": 136, + "width": 1504, + "height": 216 + }, + "fallback": { + "x": 24, + "y": 352, + "width": 1504, + "height": 216 + }, + "respond": { + "x": 24, + "y": 568, + "width": 1504, + "height": 216 + } + }, + "sequence": { + "lifelines": { + "user": { + "x": 128, + "top": 100, + "bottom": 796 + }, + "web": { + "x": 344, + "top": 100, + "bottom": 796 + }, + "api": { + "x": 560, + "top": 100, + "bottom": 796 + }, + "auth": { + "x": 776, + "top": 100, + "bottom": 796 + }, + "redis": { + "x": 992, + "top": 100, + "bottom": 796 + }, + "db": { + "x": 1208, + "top": 100, + "bottom": 796 + }, + "trace": { + "x": 1424, + "top": 100, + "bottom": 796 + } + }, + "activations": [ + { + "participant": "web", + "rect": { + "x": 339, + "y": 180, + "width": 10, + "height": 576 + }, + "depth": 0 + }, + { + "participant": "api", + "rect": { + "x": 555, + "y": 224, + "width": 10, + "height": 488 + }, + "depth": 0 + }, + { + "participant": "auth", + "rect": { + "x": 771, + "y": 268, + "width": 10, + "height": 56 + }, + "depth": 0 + }, + { + "participant": "redis", + "rect": { + "x": 987, + "y": 396, + "width": 10, + "height": 56 + }, + "depth": 0 + }, + { + "participant": "db", + "rect": { + "x": 1203, + "y": 484, + "width": 10, + "height": 56 + }, + "depth": 0 + }, + { + "participant": "redis", + "rect": { + "x": 987, + "y": 612, + "width": 10, + "height": 24 + }, + "depth": 0 + }, + { + "participant": "trace", + "rect": { + "x": 1419, + "y": 656, + "width": 10, + "height": 24 + }, + "depth": 0 + } + ] + }, + "bounds": { + "width": 1568, + "height": 836 + } + }, + "async-job": { + "draft": { + "kind": "sequence", + "title": "Async job roundtrip", + "subtitle": "Accept now, work in the background, notify later", + "phases": [ + { + "id": "accept", + "label": "Accept", + "edges": [ + "post", + "enqueue", + "accepted" + ] + }, + { + "id": "work", + "label": "Background work", + "edges": [ + "deliver", + "perform", + "result", + "retry", + "persist" + ] + }, + { + "id": "notify", + "label": "Notify and reconcile", + "edges": [ + "completed", + "webhook", + "poll", + "status", + "state", + "final" + ] + } + ], + "nodes": [ + { + "id": "client", + "type": "client", + "card": { + "title": "Client", + "subtitle": "Mobile app" + } + }, + { + "id": "api", + "type": "service", + "card": { + "title": "Jobs API", + "subtitle": "Request edge" + } + }, + { + "id": "queue", + "type": "queue", + "card": { + "title": "Queue", + "subtitle": "Durable work", + "brand": "rabbitmq" + } + }, + { + "id": "worker", + "type": "service", + "card": { + "title": "Worker", + "subtitle": "Background" + } + }, + { + "id": "provider", + "type": "external", + "card": { + "title": "Provider", + "subtitle": "External API" + } + }, + { + "id": "store", + "type": "database", + "card": { + "title": "Job store", + "subtitle": "Source of truth", + "brand": "postgresql" + } + }, + { + "id": "notifier", + "type": "queue", + "card": { + "title": "Notifier", + "subtitle": "Webhook" + } + } + ], + "edges": [ + { + "id": "post", + "from": "client", + "to": "api", + "label": "POST /jobs", + "tone": "main" + }, + { + "id": "enqueue", + "from": "api", + "to": "queue", + "label": "enqueue job", + "kind": "async", + "tone": "main" + }, + { + "id": "accepted", + "from": "api", + "to": "client", + "label": "202 + job id", + "kind": "return" + }, + { + "id": "deliver", + "from": "queue", + "to": "worker", + "label": "deliver", + "tone": "main" + }, + { + "id": "perform", + "from": "worker", + "to": "provider", + "label": "perform work" + }, + { + "id": "result", + "from": "provider", + "to": "worker", + "label": "result or timeout", + "kind": "return" + }, + { + "id": "retry", + "from": "worker", + "to": "queue", + "label": "retry if timeout", + "kind": "async", + "tone": "error" + }, + { + "id": "persist", + "from": "worker", + "to": "store", + "label": "persist final state", + "tone": "main" + }, + { + "id": "completed", + "from": "worker", + "to": "notifier", + "label": "job.completed", + "kind": "async" + }, + { + "id": "webhook", + "from": "notifier", + "to": "client", + "label": "signed webhook", + "kind": "async", + "tone": "security" + }, + { + "id": "poll", + "from": "client", + "to": "api", + "label": "GET /jobs/:id" + }, + { + "id": "status", + "from": "api", + "to": "store", + "label": "read status" + }, + { + "id": "state", + "from": "store", + "to": "api", + "label": "completed", + "kind": "return" + }, + { + "id": "final", + "from": "api", + "to": "client", + "label": "200 final result", + "kind": "return" + } + ], + "notes": [ + { + "title": "Latency contract", + "items": [ + "The client gets 202 before any work starts", + "Retries stay inside the background phase" + ] + }, + { + "title": "Reconciliation", + "items": [ + "The webhook is signed", + "Polling reads the job store, the source of truth" + ] + } + ] + }, + "nodes": { + "client": { + "x": 40, + "y": 40, + "width": 176, + "height": 60 + }, + "api": { + "x": 256, + "y": 40, + "width": 176, + "height": 60 + }, + "queue": { + "x": 472, + "y": 40, + "width": 176, + "height": 60 + }, + "worker": { + "x": 688, + "y": 40, + "width": 176, + "height": 60 + }, + "provider": { + "x": 904, + "y": 40, + "width": 176, + "height": 60 + }, + "store": { + "x": 1120, + "y": 40, + "width": 176, + "height": 60 + }, + "notifier": { + "x": 1336, + "y": 40, + "width": 176, + "height": 60 + } + }, + "groups": {}, + "edges": { + "post": [ + { + "x": 128, + "y": 186 + }, + { + "x": 339, + "y": 186 + } + ], + "enqueue": [ + { + "x": 349, + "y": 230 + }, + { + "x": 555, + "y": 230 + } + ], + "accepted": [ + { + "x": 339, + "y": 274 + }, + { + "x": 128, + "y": 274 + } + ], + "deliver": [ + { + "x": 565, + "y": 358 + }, + { + "x": 771, + "y": 358 + } + ], + "perform": [ + { + "x": 781, + "y": 402 + }, + { + "x": 987, + "y": 402 + } + ], + "result": [ + { + "x": 987, + "y": 446 + }, + { + "x": 781, + "y": 446 + } + ], + "retry": [ + { + "x": 771, + "y": 490 + }, + { + "x": 565, + "y": 490 + } + ], + "persist": [ + { + "x": 781, + "y": 534 + }, + { + "x": 1203, + "y": 534 + } + ], + "completed": [ + { + "x": 781, + "y": 618 + }, + { + "x": 1419, + "y": 618 + } + ], + "webhook": [ + { + "x": 1419, + "y": 662 + }, + { + "x": 133, + "y": 662 + } + ], + "poll": [ + { + "x": 133, + "y": 706 + }, + { + "x": 339, + "y": 706 + } + ], + "status": [ + { + "x": 349, + "y": 750 + }, + { + "x": 1203, + "y": 750 + } + ], + "state": [ + { + "x": 1203, + "y": 794 + }, + { + "x": 349, + "y": 794 + } + ], + "final": [ + { + "x": 339, + "y": 838 + }, + { + "x": 133, + "y": 838 + } + ] + }, + "labels": { + "post": { + "x": 233.5, + "y": 172 + }, + "enqueue": { + "x": 452, + "y": 216 + }, + "accepted": { + "x": 233.5, + "y": 260 + }, + "deliver": { + "x": 668, + "y": 344 + }, + "perform": { + "x": 884, + "y": 388 + }, + "result": { + "x": 884, + "y": 432 + }, + "retry": { + "x": 668, + "y": 476 + }, + "persist": { + "x": 992, + "y": 520 + }, + "completed": { + "x": 1100, + "y": 604 + }, + "webhook": { + "x": 776, + "y": 648 + }, + "poll": { + "x": 236, + "y": 692 + }, + "status": { + "x": 776, + "y": 736 + }, + "state": { + "x": 776, + "y": 780 + }, + "final": { + "x": 236, + "y": 824 + } + }, + "phases": { + "accept": { + "x": 24, + "y": 136, + "width": 1504, + "height": 172 + }, + "work": { + "x": 24, + "y": 308, + "width": 1504, + "height": 260 + }, + "notify": { + "x": 24, + "y": 568, + "width": 1504, + "height": 304 + } + }, + "sequence": { + "lifelines": { + "client": { + "x": 128, + "top": 100, + "bottom": 884 + }, + "api": { + "x": 344, + "top": 100, + "bottom": 884 + }, + "queue": { + "x": 560, + "top": 100, + "bottom": 884 + }, + "worker": { + "x": 776, + "top": 100, + "bottom": 884 + }, + "provider": { + "x": 992, + "top": 100, + "bottom": 884 + }, + "store": { + "x": 1208, + "top": 100, + "bottom": 884 + }, + "notifier": { + "x": 1424, + "top": 100, + "bottom": 884 + } + }, + "activations": [ + { + "participant": "api", + "rect": { + "x": 339, + "y": 180, + "width": 10, + "height": 100 + }, + "depth": 0 + }, + { + "participant": "queue", + "rect": { + "x": 555, + "y": 224, + "width": 10, + "height": 272 + }, + "depth": 0 + }, + { + "participant": "worker", + "rect": { + "x": 771, + "y": 352, + "width": 10, + "height": 272 + }, + "depth": 0 + }, + { + "participant": "provider", + "rect": { + "x": 987, + "y": 396, + "width": 10, + "height": 56 + }, + "depth": 0 + }, + { + "participant": "store", + "rect": { + "x": 1203, + "y": 528, + "width": 10, + "height": 24 + }, + "depth": 0 + }, + { + "participant": "notifier", + "rect": { + "x": 1419, + "y": 612, + "width": 10, + "height": 56 + }, + "depth": 0 + }, + { + "participant": "client", + "rect": { + "x": 123, + "y": 656, + "width": 10, + "height": 188 + }, + "depth": 0 + }, + { + "participant": "api", + "rect": { + "x": 339, + "y": 700, + "width": 10, + "height": 144 + }, + "depth": 0 + }, + { + "participant": "store", + "rect": { + "x": 1203, + "y": 744, + "width": 10, + "height": 56 + }, + "depth": 0 + } + ] + }, + "bounds": { + "width": 1568, + "height": 924 + } } }; diff --git a/skill/SKILL.md b/skill/SKILL.md index 6af15fb..18ebfce 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: stackmap -description: Turn a system into an explorable architecture, data-flow, workflow or lifecycle diagram, delivered as one offline HTML file with a topology-dashboard look (dark and light), search, trace, guided views and PNG/SVG export. Use when the user asks to diagram, map, visualize or explain the architecture, services, infrastructure, dependencies, data pipeline, process, runbook, release flow or state machine of a codebase or of a described system, or to update such a diagram. +description: Turn a system into an explorable architecture, data-flow, workflow, lifecycle or sequence diagram, delivered as one offline HTML file with a topology-dashboard look (dark and light), search, trace, guided views and PNG/SVG export. Use when the user asks to diagram, map, visualize or explain the architecture, services, infrastructure, dependencies, data pipeline, process, runbook, release flow, state machine or request sequence of a codebase or of a described system, or to update such a diagram. license: MIT metadata: version: "0.1.0" @@ -37,7 +37,8 @@ Unless the user names a location, each diagram lives in `.stackmap//` unde - `dataflow`: only when the edges are *data moving between stages* (pipelines, ETL/ELT, CDC, event streams, lineage). - `workflow`: *steps* done by different owners, in order (a release process, an incident runbook, an agent's tool call). Owners are `lanes`. - `lifecycle`: the *states of one thing* and the transitions between them (a job, an order, a deployment). States are nodes with state types. - - If unsure between architecture and dataflow, use `architecture`. Sequence diagrams (messages over time) aren't supported yet — say so, and offer a workflow instead. + - `sequence`: *messages over time* between a few participants, for one scenario (a request with a cache miss, an async job roundtrip). Participants are nodes; the edges are the messages, in time order. + - If unsure between architecture and dataflow, use `architecture`. 2. **Gather facts.** For a real codebase, follow [Repository diagrams](references/authoring-contract.md#repository-diagrams): every node and edge should be backed by code or config you read. For a described system, use only what the user said plus unavoidable glue; don't invent components. For Mermaid or other diagram text, use the [Mermaid mapping](references/authoring-contract.md#from-mermaid). 3. **Write the complete `diagram.json` once**, following [the authoring contract](references/authoring-contract.md). Field rules and limits: [schema reference](references/schema.md). Shapes: [examples](examples/) — they teach shape, not facts. 4. **Validate** with `validate --json`. diff --git a/skill/examples/checkout.sequence.json b/skill/examples/checkout.sequence.json new file mode 100644 index 0000000..8731225 --- /dev/null +++ b/skill/examples/checkout.sequence.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json", + "kind": "sequence", + "title": "Checkout request", + "subtitle": "Card payment with a fraud check", + "phases": [ + { "id": "authorize", "label": "Authorize", "edges": ["pay", "score", "ok", "charge", "captured"] }, + { "id": "confirm", "label": "Confirm", "edges": ["receipt", "done"] } + ], + "nodes": [ + { "id": "web", "type": "client", "card": { "title": "Web app", "subtitle": "Checkout page" } }, + { "id": "api", "type": "service", "card": { "title": "Orders API", "subtitle": "Payments route" } }, + { "id": "fraud", "type": "security", "card": { "title": "Fraud check", "subtitle": "Risk score" } }, + { "id": "stripe", "type": "external", "card": { "title": "Stripe", "subtitle": "Payments API", "brand": "stripe" } }, + { "id": "mail", "type": "queue", "card": { "title": "Mail queue", "subtitle": "Receipts" } } + ], + "edges": [ + { "id": "pay", "from": "web", "to": "api", "label": "POST /pay", "tone": "main" }, + { "id": "score", "from": "api", "to": "fraud", "label": "score order", "tone": "security" }, + { "id": "ok", "from": "fraud", "to": "api", "label": "low risk", "kind": "return" }, + { "id": "charge", "from": "api", "to": "stripe", "label": "create charge", "tone": "main" }, + { "id": "captured", "from": "stripe", "to": "api", "label": "captured", "kind": "return" }, + { "id": "receipt", "from": "api", "to": "mail", "label": "send receipt", "kind": "async" }, + { "id": "done", "from": "api", "to": "web", "label": "200 paid", "kind": "return" } + ] +} diff --git a/skill/references/authoring-contract.md b/skill/references/authoring-contract.md index a70a96a..c825d96 100644 --- a/skill/references/authoring-contract.md +++ b/skill/references/authoring-contract.md @@ -14,7 +14,7 @@ How to model a system as a stackmap diagram. The [schema reference](schema.md) s - `kind: "async"` for queues, events, fire-and-forget and callbacks — it renders dashed. Don't use it for "happens at build time" or "optional"; say that in a label or in the node's subtitle. - `kind: "return"` for a reply, a roll back or a retry that loops back to an earlier step — it renders dotted, and layout never lets it push its target to a later column. - **`tone`** marks the few edges the reader must tell apart: `main` for the happy path (drawn in ink), `security` for a trust crossing or a policy check, `error` for a failure path. Leave the rest untoned; a diagram where everything has a tone has none. -- **Edge labels** (≤ 24 chars) only where the relationship isn't obvious from the two ends: a protocol (`gRPC`), a verb (`enqueue`), a topic. Most edges need none. +- **Edge labels** (≤ 24 chars) only where the relationship isn't obvious from the two ends: a protocol (`gRPC`), a verb (`enqueue`), a topic. Most edges need none — except in sequences, where every message is labelled. - **Direction.** `RIGHT` (default) reads as a request path and suits 2–4 stages. Prefer `DOWN` for tiered or grouped systems (edge → app → data) and for anything with more than ~5 stages; it avoids long wrap-around edges. - **Groups** are boundaries a reader should see: tiers, trust zones, VPCs, clusters, teams. Nest with `parent`. A group with one node is usually noise. - **Stages** (`phases` with `nodes`) in `architecture` and `dataflow` are an ordered pipeline (sources → ingest → process → store → consume): each stage is drawn as a band, in flow order. Stage members must be ungrouped; use stages *or* groups for a node, not both. @@ -43,6 +43,19 @@ Read Mermaid for topology and meaning, then write fresh stackmap JSON — don't | `
` detail in a label | `subtitle` or a card row | | `classDef`, `style`, `linkStyle` | ignore | +A Mermaid `sequenceDiagram` becomes `kind: "sequence"`: + +| Mermaid | stackmap | +|---|---| +| `participant A as Label`, `actor A` | node (participant) in the order declared; `actor` is usually a `client` | +| `A->>B: text`, `A->B: text` | edge `from: "a", to: "b", label: "text"` (a call) | +| `B-->>A: text`, `B-->A: text` | the same with `kind: "return"` | +| `A-)B: text` | the same with `kind: "async"` | +| `A->>A: text` | a self-call (`from` and `to` the same) | +| `rect … end`, a `Note over` spanning a stretch | a phase with those messages in `edges` | +| `activate` / `deactivate`, `+` / `-` | leave out: bars are derived from calls and replies | +| `alt`, `opt`, `loop`, `par` | not supported: draw the main case and say the others in `notes` | + ## Workflows and lifecycles Both are drawn as **swimlanes**: `lanes` are full-width rows in the order you list them, and stackmap picks the columns from the edges (a step's successor in the same lane moves right; a hand-off to another lane may drop straight down). Every node needs a `lane`. @@ -65,6 +78,17 @@ Both are drawn as **swimlanes**: `lanes` are full-width rows in the order you li A `success` or `failure` state with no outgoing transition is drawn as an end state. A retry is a `return` edge back to the state it retries. +## Sequences + +One scenario, told in time. `nodes` are the participants, left to right in the order you list them; `edges` are the messages, top to bottom in array order. stackmap spaces the lifelines for the labels and draws the activation bars itself — never give positions. + +- **Participants:** three to eight. Order them the way the request travels (caller first, stores and third parties last) so most arrows point right. +- **Messages:** label every one, briefly (`GET /dashboard`, `read cache`, `202 + job id`). A call is plain; `"kind": "return"` is its reply, from the callee back to the caller, after it; `"kind": "async"` is fire-and-forget. A message from a participant to itself is a self-call (drawn as a loop). +- **Activation bars** follow from the messages: a call opens a bar on the callee, and its reply closes it. A call that arrives while the callee is waiting on its own call (a callback) nests a bar inside. A bar nobody closes ends where its participant was last busy (sending or receiving) once someone else calls it, or at its last message. A reply that answers nothing is a warning (`semantics/unmatched-return`). +- **Phases** (`phases` with `edges`) band stretches of time: *Request, Fallback, Response*. List each band's messages: a band runs from its first to its last message (anything in between it doesn't list is a warning), and bands must follow each other. +- `tone` works as elsewhere: `main` for the happy path, `security` for auth checks, `error` for retries and failures. +- Cards are compact (title, subtitle, brand, tag). No groups or lanes. + ## Compact cards `"density": "compact"` gives an `architecture` or `dataflow` diagram the compact cards workflows use (title, subtitle, brand, tag). Use it for long chains (more than ~5 stages), overviews and summaries, where full cards would make the diagram too small to read at fit; keep full cards when rows, stats and footers carry the answer.