feat: introduce archify skill for generating architecture diagrams
- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
# Workflow Renderer
|
||||
|
||||
Render `diagram_type: "workflow"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/workflow/render-workflow.mjs input.workflow.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/workflow.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
|
||||
or falls back to `workflow.html` in the current working directory.
|
||||
|
||||
After rendering, run the artifact checker:
|
||||
|
||||
```bash
|
||||
node archify/scripts/check-render-output.mjs output.html
|
||||
```
|
||||
|
||||
It catches final-SVG issues that are easiest to see in a browser: non-finite
|
||||
SVG values, accidental two-point diagonal arrows, and arrows crossing the
|
||||
legend.
|
||||
|
||||
## Input
|
||||
|
||||
Workflow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Agent Tool Call Workflow"
|
||||
},
|
||||
"lanes": [],
|
||||
"phases": [],
|
||||
"groups": [],
|
||||
"mainPath": [],
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
Use `schema_version: 2` for new workflows. Its readable layout compiler treats
|
||||
every `col` as a logical rank in `0..5` and derives geometry from the measured
|
||||
document. `schema_version: 1` remains the fixed legacy contract for existing
|
||||
sources; valid v1 output is preserved byte-for-byte and never silently
|
||||
reinterpreted as v2.
|
||||
|
||||
Omit `meta.viewBox` for the common v2 case so the compiler can use intrinsic
|
||||
measured bounds. In v1, the omitted width remains fixed at 720 and height is
|
||||
derived from lane count. A complete worked example lives at
|
||||
`archify/examples/agent-tool-call.workflow.json`; its `schema_version` selects
|
||||
the applicable contract.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/workflow.schema.json
|
||||
```
|
||||
|
||||
## Migration and layout receipt
|
||||
|
||||
Migrate an existing v1 source into a separate v2 file:
|
||||
|
||||
```bash
|
||||
node archify/bin/archify.mjs migrate workflow old.json new.json --to-schema 2 --json
|
||||
```
|
||||
|
||||
Running the command again with its schema-v2 output as the new source is an
|
||||
idempotent verification pass: the destination bytes and geometry stay unchanged.
|
||||
|
||||
The command never overwrites the source by default. It maps absolute
|
||||
`via[*][0]`, `labelAt[0]`, and `channelX` values from legacy to solved rank
|
||||
space, preserves y coordinates unless a reported vertical constraint needs
|
||||
author input, expands an explicit viewBox only for an unambiguous containment
|
||||
repair, and writes the destination only after v2 compilation and artifact
|
||||
checks pass. Ambiguous explicit pins fail without producing the destination.
|
||||
|
||||
Inspect the stable author-facing v2 plan with:
|
||||
|
||||
```bash
|
||||
node archify/bin/archify.mjs validate workflow input.workflow.json --layout-json
|
||||
```
|
||||
|
||||
The receipt reports the selected contract, measured `viewBox` and
|
||||
`requiredViewBox`, solved columns, nodes, edges, labels, and causal diagnostics.
|
||||
It deliberately omits solver iterations and candidate scores.
|
||||
|
||||
## Legend
|
||||
|
||||
The default legend derives component kinds from `nodes[].type`. Supported
|
||||
`meta.legend.entries` keys, in stable order, are `frontend`, `backend`,
|
||||
`security`, `messagebus`, `database`, `cloud`, and `external`. Labels and
|
||||
visibility may be overridden through the shared legend contract; only kinds
|
||||
backed by rendered nodes receive Semantic Legend controls.
|
||||
|
||||
## Layout contracts
|
||||
|
||||
### Fixed v1
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[720, auto]` — auto height = 52 + lanes×104 + (lanes−1)×20 + 124 |
|
||||
| Lane frame | x 40, width 640, height 104, gap 20; first lane top at y 52 |
|
||||
| Lane title strip | top 30px of each lane; node boxes must stay below it |
|
||||
| Column centers (`col` 0–5) | x = 88, 220, 300, 430, 500, 625 |
|
||||
| Phase headers | Optional `phases[]` render above the first lane, spanning `fromCol..toCol` |
|
||||
| Lane groups | Optional `groups[]` frame parallel work or branch work inside one lane |
|
||||
| Exception lanes | Set `lane.variant: "exception"` for retry, denial, fallback, or failure paths |
|
||||
| Main path lint | Optional `mainPath[]` checks that happy-path steps have matching edges and do not move backward |
|
||||
| Default node | 92×52 (height 68 when `tag` is set) |
|
||||
| Node spacing | ≥8px between nodes in the same lane |
|
||||
| Edge length | straight segments must span ≥28px |
|
||||
| Legend row | y = lane bottom + 44; viewBox height must be ≥ legend y + 18 |
|
||||
|
||||
Column-center gaps are 132 / 80 / 130 / 70 / 125 px: columns 1↔2 (80px) and
|
||||
3↔4 (70px) cannot both hold default-width 92px nodes in the same lane. Such an
|
||||
invalid v1 source receives one causal `workflow/column-capacity` diagnostic and
|
||||
a verified migration-to-v2 repair; v1 never falls through to adaptive layout.
|
||||
|
||||
### Readable v2
|
||||
|
||||
| Invariant | Contract |
|
||||
|----------|----------|
|
||||
| Logical columns | `col` is an integer in `0..5`; pixel centers are measured output |
|
||||
| Adjacent-rank baseline | 120px center distance before document-specific constraints |
|
||||
| Same-lane node clearance | ≥8px when vertical node intervals overlap |
|
||||
| Facing direct edge | clear gap ≥`max(28px, measured label mask width + 8px)` |
|
||||
| Automatic route rhythm | direct segment ≥28px; endpoint stub ≥8px; interior turn segment ≥16px |
|
||||
| Implicit viewBox | intrinsic content bounds plus contract padding |
|
||||
| Explicit viewBox | containment capacity; too-small input reports exact `requiredViewBox` and contributors |
|
||||
|
||||
The compiler applies constraints only to actual related or overlapping
|
||||
same-lane nodes, so a wide node in an unrelated lane does not expand every
|
||||
rank. Legacy centers are a soft preference after correctness constraints, not
|
||||
a geometry promise. Phase and group frames derive from the solved rank bands.
|
||||
Automatic routes are normalized once and the same final scene drives
|
||||
validation and SVG serialization. Long automatic labels compare direct-gutter
|
||||
growth with a legal channel instead of widening every downstream rank. Measured
|
||||
multi-row legends participate in intrinsic height and explicit viewBox
|
||||
capacity.
|
||||
|
||||
Authored `via`, `labelAt`, `channelX`, and `channelY` are absolute hard pins in
|
||||
v2; an infeasible pin returns `workflow/explicit-pin-conflict` rather than being
|
||||
silently moved. `fromSide` and `toSide` remain direction constraints. A route
|
||||
preset restricts the automatic candidate family but is not itself an absolute
|
||||
coordinate pin. When either endpoint side is omitted, the v2 compiler chooses
|
||||
a feasible side; an authored side restricts that endpoint to the named port.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use lanes for ownership or runtime boundaries.
|
||||
- Use phase headers for high-level story beats such as Intake, Plan, Execute, and Report.
|
||||
- Use groups for parallel checks, branch handling, or bounded work within a lane; every group must contain at least one node.
|
||||
- Use `lane.variant: "exception"` for human wait, denial, retry, fallback, and failure lanes instead of mixing those paths into the happy path.
|
||||
- Set `mainPath` when the diagram has a clear happy path; the renderer validates that consecutive ids have matching edges and move left-to-right.
|
||||
- Place nodes with lane IDs and `col` indexes in `0..5`, not raw SVG coordinates.
|
||||
- Preserve semantic edge labels. Readable v2 allocates measured label clearance;
|
||||
when a label does not fit, repair the reported capacity or route constraint
|
||||
instead of deleting meaning.
|
||||
- Use labels for decisions, approvals, protocols, async traces, return paths,
|
||||
and any other relationship meaning not fully implied by its endpoints.
|
||||
- Prefer route presets — `drop` (bend between lanes; `bias` 0–1 picks where),
|
||||
`outside-right`, `return-left`, `bottom-channel`, and `up-channel` — before
|
||||
using raw `via` points. `straight` and the default `auto` cover the rest.
|
||||
- Keep workflow examples compact enough to render well in narrow chat/browser
|
||||
previews.
|
||||
|
||||
### Optional semantic checks
|
||||
|
||||
Layout validation cannot infer domain truth from labels or cards. When source
|
||||
evidence establishes roots, terminals, mandatory direct relationships, or
|
||||
mandatory directed reachability, encode those facts in `semanticChecks`:
|
||||
|
||||
```json
|
||||
"semanticChecks": {
|
||||
"allowedRoots": ["request", "resource_catalog"],
|
||||
"allowedTerminals": ["reply", "audit_log"],
|
||||
"requiredEdges": [
|
||||
{ "from": "dispatch", "to": "dispatch_ledger" }
|
||||
],
|
||||
"requiredPaths": [
|
||||
{ "from": "event_ledger", "to": "runtime_host" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When `allowedRoots` or `allowedTerminals` is present, it is the complete allow
|
||||
list for zero-incoming or zero-outgoing nodes respectively. `requiredEdges`
|
||||
requires one exact authored direction; `requiredPaths` permits intermediate
|
||||
nodes but follows authored edge direction. These checks run before layout, do
|
||||
not alter SVG or receipt bytes, and must not be weakened merely to resolve a
|
||||
route or composition diagnostic. Omit fields whose domain facts are unknown.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including node overlap, nodes outside their lanes, invalid
|
||||
phase/group column ranges, empty groups, broken `mainPath` steps, unknown edge
|
||||
targets, labels colliding with nodes or other labels, labels wider than their
|
||||
node, legends outside the viewBox, or straight arrows that are too short to
|
||||
read cleanly. The shared Clean Flow Gate also rejects edges crossing unrelated
|
||||
nodes with 2px clearance; lanes, phases, and groups remain intentional
|
||||
pass-through containers. Text width is estimated CJK-aware: fullwidth glyphs
|
||||
count as two units.
|
||||
|
||||
Diagnostics are causal: a rank-capacity failure suppresses derivative short
|
||||
edge, endpoint-direction, and label-overlap findings. Every
|
||||
`supportedFixes[]` entry is verified by replanning the proposed edit, and a
|
||||
diagnostic never proposes removing a semantic label when label presence does
|
||||
not cause the failed invariant.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear lane corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated edges overlap for at least 8px. Shared semantic
|
||||
endpoints, point touches, and shorter overlaps remain valid. Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid for fixed lane gaps.
|
||||
@@ -0,0 +1,35 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadDiagramWithBrandMarks, writeDiagram } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticError } from '../shared/diagnostics.mjs';
|
||||
import { compileWorkflow } from './workflow-compiler.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: workflow, template, outPath } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'workflow',
|
||||
defaultExample: 'agent-tool-call.workflow.json'
|
||||
});
|
||||
|
||||
const compiled = compileWorkflow({
|
||||
workflow,
|
||||
qualityProfile: process.env.ARCHIFY_QUALITY_PROFILE || workflow.meta?.quality_profile,
|
||||
});
|
||||
|
||||
const layoutJson = process.argv.includes('--layout-json');
|
||||
|
||||
if (layoutJson) {
|
||||
process.stdout.write(`${JSON.stringify(compiled.receipt, null, 2)}\n`);
|
||||
if (!compiled.ok) process.exitCode = 1;
|
||||
} else if (!compiled.ok) {
|
||||
throwDiagnosticError(compiled.error || 'Workflow compilation failed.', compiled.diagnostics);
|
||||
} else {
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'workflow',
|
||||
meta: workflow.meta,
|
||||
svg: compiled.svg,
|
||||
cards: workflow.cards,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
const TARGET_SCHEMA_VERSION = 2;
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authored workflow as a schema-v2 document without its capacity
|
||||
* override. The compiler can use this projection to discover the intrinsic v2
|
||||
* rank plan before deciding whether an explicit viewBox needs to grow.
|
||||
*/
|
||||
export function intrinsicWorkflow(workflow) {
|
||||
const intrinsic = clone(workflow);
|
||||
intrinsic.schema_version = TARGET_SCHEMA_VERSION;
|
||||
intrinsic.meta = { ...intrinsic.meta };
|
||||
delete intrinsic.meta.viewBox;
|
||||
return intrinsic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a schema-v2 planning projection that removes authored route geometry
|
||||
* which may only become valid after its legacy X coordinates are remapped.
|
||||
* Rank-affecting automatic and straight relationships remain in the projection.
|
||||
*/
|
||||
export function planningWorkflow(workflow) {
|
||||
const planned = intrinsicWorkflow(workflow);
|
||||
planned.edges = planned.edges.flatMap((edge) => {
|
||||
const hasRoutedGeometry = Array.isArray(edge.via)
|
||||
|| (edge.route && !['auto', 'straight'].includes(edge.route))
|
||||
|| edge.channelX !== undefined
|
||||
|| edge.channelY !== undefined;
|
||||
if (hasRoutedGeometry) return [];
|
||||
|
||||
const automatic = {};
|
||||
for (const property of ['id', 'from', 'to', 'variant', 'role', 'width']) {
|
||||
if (edge[property] !== undefined) automatic[property] = edge[property];
|
||||
}
|
||||
if (edge.route === 'straight') automatic.route = 'straight';
|
||||
if (edge.labelAt === undefined && edge.label !== undefined) automatic.label = edge.label;
|
||||
return [automatic];
|
||||
});
|
||||
|
||||
if (Array.isArray(planned.mainPath)) {
|
||||
const projectedPairs = new Set(planned.edges.map((edge) => `${edge.from}\u0000${edge.to}`));
|
||||
const projectionBreaksMainPath = planned.mainPath.some((from, index) => (
|
||||
index < planned.mainPath.length - 1
|
||||
&& !projectedPairs.has(`${from}\u0000${planned.mainPath[index + 1]}`)
|
||||
));
|
||||
if (projectionBreaksMainPath) delete planned.mainPath;
|
||||
}
|
||||
|
||||
return planned;
|
||||
}
|
||||
|
||||
function mappedNumber(value) {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic piecewise-linear mapping between corresponding legacy
|
||||
* and readable rank centers. Coordinates outside the rank span are extrapolated
|
||||
* using the nearest segment so explicitly authored outside corridors retain
|
||||
* their relative offset.
|
||||
*/
|
||||
export function createHorizontalRankMapper(oldColumns, newColumns) {
|
||||
if (
|
||||
!Array.isArray(oldColumns)
|
||||
|| !Array.isArray(newColumns)
|
||||
|| oldColumns.length !== newColumns.length
|
||||
|| oldColumns.length < 2
|
||||
|| !oldColumns.every(Number.isFinite)
|
||||
|| !newColumns.every(Number.isFinite)
|
||||
) {
|
||||
throw new TypeError('Horizontal rank mapping requires matching finite column arrays.');
|
||||
}
|
||||
for (let index = 1; index < oldColumns.length; index += 1) {
|
||||
if (oldColumns[index] <= oldColumns[index - 1] || newColumns[index] <= newColumns[index - 1]) {
|
||||
throw new TypeError('Horizontal rank mapping requires strictly increasing columns.');
|
||||
}
|
||||
}
|
||||
|
||||
return (x) => {
|
||||
if (!Number.isFinite(x)) throw new TypeError('Horizontal rank mapping requires a finite x coordinate.');
|
||||
let segment = oldColumns.length - 2;
|
||||
if (x <= oldColumns[0]) {
|
||||
segment = 0;
|
||||
} else {
|
||||
for (let index = 0; index < oldColumns.length - 1; index += 1) {
|
||||
if (x <= oldColumns[index + 1]) {
|
||||
segment = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const oldSpan = oldColumns[segment + 1] - oldColumns[segment];
|
||||
const newSpan = newColumns[segment + 1] - newColumns[segment];
|
||||
const ratio = (x - oldColumns[segment]) / oldSpan;
|
||||
return mappedNumber(newColumns[segment] + ratio * newSpan);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one horizontal coordinate mapping to every schema-v1 absolute X pin.
|
||||
* The caller owns the supplied workflow; this function reports an audit trail
|
||||
* for each changed coordinate in stable document order.
|
||||
*/
|
||||
export function mapExplicitCoordinates(workflow, mapX) {
|
||||
const changedCoordinates = [];
|
||||
const record = (path, owner, property) => {
|
||||
const from = owner[property];
|
||||
const to = mapX(from);
|
||||
owner[property] = to;
|
||||
if (to !== from) changedCoordinates.push({ path, from, to });
|
||||
};
|
||||
|
||||
for (const [edgeIndex, edge] of workflow.edges.entries()) {
|
||||
if (Array.isArray(edge.via)) {
|
||||
for (const [pointIndex, point] of edge.via.entries()) {
|
||||
if (Array.isArray(point) && Number.isFinite(point[0])) {
|
||||
record(`/edges/${edgeIndex}/via/${pointIndex}/0`, point, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(edge.labelAt) && Number.isFinite(edge.labelAt[0])) {
|
||||
record(`/edges/${edgeIndex}/labelAt/0`, edge.labelAt, 0);
|
||||
}
|
||||
if (Number.isFinite(edge.channelX)) {
|
||||
record(`/edges/${edgeIndex}/channelX`, edge, 'channelX');
|
||||
}
|
||||
}
|
||||
return changedCoordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an independently owned schema-v2 candidate with all authored
|
||||
* absolute X pins mapped to the readable rank plan.
|
||||
*/
|
||||
export function createMappedWorkflowCandidate(workflow, oldColumns, newColumns) {
|
||||
const document = clone(workflow);
|
||||
document.schema_version = TARGET_SCHEMA_VERSION;
|
||||
const mapX = createHorizontalRankMapper(oldColumns, newColumns);
|
||||
const changedCoordinates = mapExplicitCoordinates(document, mapX);
|
||||
return { document, changedCoordinates };
|
||||
}
|
||||
Reference in New Issue
Block a user