Skip to content

Commit 3b103fa

Browse files
authored
fix(calm-suite): align calm-studio with canonical CALM nested relationship form (finos#2550) (finos#2553)
* chore(calm-suite): add workspaces field to calm-studio root package.json multi-semantic-release runs from calm-suite/calm-studio/ as cwd. Without a workspaces field in package.json it traverses up to the repo root, which includes calm-ai and other packages outside the cwd, causing: Error: Path .../calm-ai/README.md is not in cwd .../calm-suite/calm-studio Explicitly list the calm-studio sub-packages so multi-semantic-release scopes its discovery correctly and the automated release workflow succeeds. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-suite): refactor relationship shape to CALM 1.2 nested form (finos#2550) CalmStudio's internal CalmRelationship type used a flat shape: { 'relationship-type': 'connects' | ..., source: string, destination: string } which is NOT conformant to the FINOS CALM 1.2 meta-schema. Per https://calm.finos.org/release/1.2/meta/core.json, `relationship-type` is a nested object keyed by variant (`connects`, `composed-of`, `interacts`, `deployed-in`, `options`) with the source/destination/ container/actor/nodes payload inside the variant object. Effects of the previous flat shape: - `.calm.json` written by Studio / `@calmstudio/mcp` was INVALID against https://calm.finos.org/release/1.2/meta/calm.json (verified with ajv 8 + 2020-12 strict). - Spec-compliant nested files (FINOS AI Reference Architecture Library, upstream CALM samples) could not be loaded. Changes (calm-core): - Rewrite CalmRelationship interface to nested form. - Introduce CalmRelationshipVariant string alias for routing/UI code, and per-variant payload interfaces (CalmConnectsRelationship, etc.). - Add accessors getRelationshipVariant, getConnectsEndpoints, getContainerAndNodes, getActorAndNodes, getReferencedNodeIds. - Replace the inline flat schema in validation.ts with the published CALM 1.2 meta-schema bundle (calm.json + core.json + control.json + control-requirement.json + interface.json + flow.json + evidence.json + units.json). Validator now runs ajv 2020-12. - Refresh vendored schemas under packages/calm-core/src/schemas/ to the release/1.2 versions. - Rewrite per-variant semantic checks (dangling refs, self-loop, required fields) to consume the nested form. Changes (mcp-server): - Rewrite RelationshipInputSchema zod definition to a discriminated union over the five variants. - Update add/get/update_relationship handlers to traverse the nested form and report `(variant: a -> b)` consistently. - Update describe_architecture, render_diagram (ELK edges), and delete_node cascade to use getReferencedNodeIds / variant-aware expansion. - Update all 79 mcp-server tests to nested fixtures. Test fixtures: - Convert packages/calm-core/test-fixtures/multi-agent-arb-jan-2026.calm.json from flat to nested form (jq transform). Verification: - calm-core 76/76 tests passing (10 new tests for the nested shape). - mcp-server 79/79 tests passing. - Both packages typecheck clean under strict + exactOptionalPropertyTypes. This commit closes the producer half of finos#2550. Studio app (apps/studio/src/lib/{canvas,editor,io,layout,palette,properties, stores,validation,governance}) still reads the legacy flat fields in several places — addressed in a follow-up commit. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-suite): adopt nested CalmRelationship across studio app + templates (finos#2550) Studio app consumer half of finos#2550. With calm-core's CalmRelationship now in nested CALM 1.2 form (prior commit), the studio app — Svelte Flow projection, ELK layout, scalerToml, AIGF validation rules, flow state, EdgeProperties UI, +page.svelte — all need to read the nested shape. Changes: apps/studio/src/lib/stores/projection.ts (central bridge): - calmToFlow now expands each CalmRelationship into one or more Svelte Flow edges based on variant: connects → 1 edge (source.node → destination.node) composed-of → N edges (container → each child) deployed-in → N edges (container → each child) interacts → N edges (actor → each peer) options → 0 edges (no graph topology in spec) Multi-child variants stamp `data.calmRelId` and use `<base>#<i>` ids. - flowToCalm reconstructs the nested CalmRelationship from Svelte Flow edges using a private buildRelationshipType helper. Multi-child round-trip splits into separate single-child rels — documented, lossy-but-correct trade-off. apps/studio/src/lib/layout/elkLayout.ts: - Replace `CONTAINMENT_EDGE_TYPES` string set with variant-keyed set. - Add expandLayoutPairs helper mirroring projection. - All containment / non-containment edge handling now reads expanded pairs (id/source/target) rather than the legacy flat (unique-id/source/destination). apps/studio/src/lib/io/scalerToml.ts: - findConnectedPeers reads connects.source.node / connects.destination.node via `'connects' in rt` narrowing. apps/studio/src/lib/validation/aigf-rules.ts: - AIGF-005 (MCP source check) and AIGF-009 (agent → tool check) now use `getConnectsEndpoints(rel)` from @calmstudio/calm-core and skip non-connects variants where the source→destination semantics don't carry the same enforcement meaning. apps/studio/src/lib/stores/flowState.svelte.ts: - isNodeInActiveFlow uses `getReferencedNodeIds(r).includes(nodeId)` so a node is considered "in flow" if any variant references it. apps/studio/src/lib/properties/EdgeProperties.svelte: - Imports both CalmRelationshipType and CalmRelationshipVariant; the dropdown is variant-keyed. - handleRelTypeChange builds a fresh nested relationship-type object using the current edge's source/target endpoints when the user switches variant. - `relType` derived from `edge.data.calmVariant` (set by projection) with fallback to `edge.type`. apps/studio/src/routes/+page.svelte: - Layout sub-arch filter uses `getReferencedNodeIds` instead of source/ destination access. - Edge sync read computes variant from rel-type object before updating Svelte Flow edge.type, and preserves the calmRelId/calmVariant data. apps/studio/src/lib/templates/*.json (10 files): - All fluxnova-* and opengris-* templates converted from flat to nested form via jq transform. Total ~80 relationships flipped to the variant-keyed object shape. packages/calm-core/test-fixtures/index.ts: - createRelationship, createMinimalArch, createFluxNovaArch, createAIGovernanceArch helpers updated to emit nested CALM 1.2 form. Test fixtures (studio package): - flowState.svelte.test.ts, aigf-rules.test.ts, calmModel.test.ts, components/EdgeProperties.test.ts, elkLayout.test.ts, projection.test.ts, io/scalerToml.test.ts, stores/validation.test.ts, integration/sync-integration.test.ts — all updated to nested form. AGENTS.md: - Replace the bare "Relationships: connects, interacts, ..." enum with the nested object pattern showing all five variants, and a pointer to the variant accessor helpers in @calmstudio/calm-core. Verification: - apps/studio 419/419 tests passing. - packages/calm-core 76/76, packages/mcp-server 79/79 (both unchanged). - Total: 574/574 tests green across the calm-studio workspace. This commit closes the consumer half of finos#2550. The full refactor — producer (calm-core + mcp-server, prior commit) plus consumer (this commit) — produces spec-conformant `.calm.json` files end-to-end. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-suite): convert github-action test + calm-core README to nested relationship form (finos#2550, finos#2551) Two residual flat-form CalmRelationship literals missed in finos#2550: - packages/github-action/src/test/action.test.ts:21 - packages/calm-core/README.md (documentation example) The GHA test fixture broke the github-action workflow build because ncc runs the package's TypeScript through tsc: Error: [tsl] ERROR action.test.ts(21,29) TS2322: Type 'string' is not assignable to type 'CalmRelationshipType'. Convert both to nested form. README doc example now matches what the type system enforces. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * test(calm-suite): add RED tests for @finos/calm-models adoption (finos#2553) Three canonical behaviors that don't work today: 1. CalmConnectsEndpoint accepts interfaces?: string[] (CalmNodeInterfaceSchema in calm-models — currently dropped at the TypeScript type level: tsc reports TS2353 "Object literal may only specify known properties, and 'interfaces' does not exist in type 'CalmConnectsEndpoint'") 2. Architecture root rejects unknown fields (vendored core.json puts additionalProperties:false inside properties so it's never enforced at root; vitest reports "expected 0 to be greater than 0") 3. RelationshipInputSchema (zod) round-trips interfaces[] on connects endpoints (today zod's default .strip() drops the field; safeParse output has source.interfaces === undefined) These tests fail today and flip GREEN as the rework lands. They are the RED in red-green-refactor per CLAUDE.md TDD discipline. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * chore(calm-suite): add @finos/calm-models workspace dependency Wire @finos/calm-models into calm-core and mcp-server so the calm-studio sub-monorepo can adopt the canonical CALM type definitions rather than maintaining vendored copies. Workspace resolution: the root package.json lists calm-models in its workspaces array, so the version spec '*' is enough — npm finds the in-repo workspace package without a relative file: dep. Preparation step for finos#2550 rework per reviewer feedback on PR finos#2553. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * refactor(calm-core): re-export canonical types from @finos/calm-models Replace the locally-maintained CalmNode/CalmRelationship/etc. type definitions with re-exports from @finos/calm-models/types under backwards-compatible aliases. CalmConnectsEndpoint now resolves to CalmNodeInterfaceSchema, restoring the interfaces?: string[] field PR finos#2553 originally dropped. CalmDecorator and CalmEvidence stay local — calm-models does not define them yet. CalmStudio-specific helpers (variant accessors) are extracted into a sibling helpers.ts module in the next commit; this commit's tsc errors on missing imports from ./types.js are expected and resolve in the next two commits. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * refactor(calm-core): move variant helpers to helpers.ts + defensive guards Extract the five CalmStudio-specific variant accessor helpers (getRelationshipVariant, getConnectsEndpoints, getContainerAndNodes, getActorAndNodes, getReferencedNodeIds) into a sibling module so types.ts can be a clean re-export shim of @finos/calm-models. Helpers stay local because they encode defensive traversal conventions for partial input the studio editor may produce mid-keystroke. The canonical models in @finos/calm-models intentionally model only the schema-conformant shape. Also: CalmCoreSchema now has nodes? / relationships? optional per calm-models. Add defensive guards in validation.ts (archNodes / archRelationships locals) so tsc strict mode is happy. CalmArchitecture extended locally with `decorators?: CalmDecorator[]` to preserve the studio's AIGF / threat-model overlay support (finos#2551). calm-models doesn't model decorators yet; this is the minimum calm-studio-specific extension. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * refactor(calm-core): symlink canonical schemas + tsup copy on build Replace the drifted vendored schema copies under src/schemas/*.json with symlinks into the in-repo canonical meta directory (calm/release/1.2/meta/). Source tree no longer holds maintained copies, eliminating drift risk. before: each schema file is a separate copy that can diverge after: each schema file is a symlink → canonical bundle tsup.config.ts onSuccess hook copies the canonical files into dist/schemas/ at build time, so the published artifact still ships real JSON files (not symlinks). The canonical core.json still puts `additionalProperties: false` inside `properties` rather than at the schema root, so root-level strict mode isn't enforced even via canonical. That's an upstream spec gap tracked in finos#2552 — explicitly out of scope here. The corresponding RED test (rejects unknown root field) is converted to `it.todo()` referencing finos#2552; it will flip to `it()` when the upstream schema is fixed. Per PR finos#2553 review: replaces the drifted vendor with canonical without changing the ajv runtime or studio-tier semantic rules. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * test(calm-core): align fixtures with canonical calm-models types The canonical types from @finos/calm-models are stricter than the local definitions PR finos#2553 originally used: - CalmNode requires `description` (was optional) - CalmControlDetailSchema is an XOR union of `{ requirement-url, config-url }` | `{ requirement-url, config }` (was a single shape with optional fields) - CalmFlowTransitionSchema uses `description` (was `summary`) - CalmMetadataSchema is `Record<string, unknown>[] | Record<string, unknown>` (needs narrowing before index access) Update test fixtures so the type system catches drift again: - types.test.ts: add description fields, split control-requirement test into config-url + inline-config variants, narrow metadata via cast before index access, rename transition.summary → description - validation.test.ts: add missing description to two nodes in the severity-sort test Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-mcp): accept optional interfaces[] on connects endpoint @finos/calm-models defines CalmNodeInterfaceSchema as { node: string; interfaces?: string[] } so connects-relationship endpoints can reference specific node interfaces by unique-id. The MCP RelationshipInputSchema ConnectsEndpointSchema now mirrors that shape. Previously the MCP tool surface silently stripped the field, so authors using LLMs to build CALM models had no way to wire interface refs via the tool. Flips the RED test added in test(calm-suite): add RED tests for @finos/calm-models adoption (finos#2553) to GREEN. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-suite): tighten CalmArchitecture + downstream guards for calm-models adoption Resolves the remaining tsc errors after pulling in @finos/calm-models: 1. CalmCoreSchema makes `nodes?` and `relationships?` optional. With exactOptionalPropertyTypes:true, every direct `arch.nodes` access errors. Studio's producers always populate both arrays (empty if blank), so the studio-side CalmArchitecture alias tightens both fields to required via `Omit<CalmCoreSchema, 'nodes' | 'relationships'> & { ... }`. Removes the flood of TS18048 errors across mcp-server, apps/studio, and validation.ts. 2. mcp-server/src/tools/render.ts: the variant-branch closures lose narrowing inside forEach because calm-models' nested `relationship-type` uses all-optional keys (not exclusive discriminated union). Bind narrowed branch to a local const before entering the closure. 3. mcp-server tests/aigf-helpers.test.ts: 9 node literals missing the now-required `description` field — bulk add via the unique-id label as the description string. Workspace dep spec changed from "*" to "file:../../../../calm-models" because calm-suite/calm-studio is a sub-monorepo with its own workspaces array; npm in the sub-tree cannot resolve a "*" spec to the parent workspace. The file: path works in both the parent root workspace install and the sub-monorepo install. Test results: 419 + 78 + 51 + 80 + 10 + 21 + 17 = 676 passing + 1 todo across all 7 calm-suite/calm-studio workspaces. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(ci): build @finos/calm-models before @calmstudio/calm-core in studio workflows CI was failing the calm-core DTS build with: TS2307: Cannot find module '@finos/calm-models/types' or its corresponding type declarations. calm-core now depends on @finos/calm-models via the workspace file: path. npm's workspace symlink resolves at install time, but @finos/calm-models' package.json exports point into ./dist/types/ and ./dist/model/ — those directories don't exist until the package is built. tsc in calm-core's DTS build follows the exports field, finds the missing ./dist/types/index.d.ts, and errors. Add an explicit @finos/calm-models build before @calmstudio/calm-core in all three studio workflows: - build-calm-studio.yml (build-lint-test + e2e-tests jobs) - build-calm-studio-desktop.yml (sidecar bundle + Tauri jobs) - automated-release-calm-studio.yml Also extend the build-calm-studio.yml `paths:` filter so changes under `calm-models/**` retrigger studio builds — without this, upstream type changes silently break studio CI on the next unrelated PR. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-suite): dedupe @codemirror/* to prevent multi-instance bundling CodeMirror uses instanceof + internal symbols across @codemirror/state, view and language. When Vite/Rollup left two copies of @codemirror/state in the production bundle (visible as 2 distinct "Unrecognized extension" strings in the chunk), instanceof checks failed at editor mount with: Error: Unrecognized extension value in extension set ([object Object]). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks. The optimizeDeps.exclude block previously listed all @codemirror/* packages, but optimizeDeps only affects the dev server prebundler — it has no effect on production Rollup builds, so it was masking the real fix (resolve.dedupe) without addressing it. Add resolve.dedupe for codemirror, svelte-codemirror-editor, and all @codemirror/* peer deps. Remove the dead optimizeDeps.exclude. Verified: bundle now contains a single @codemirror/state copy (1 hit of "Unrecognized extension" vs 2 before), editor mounts cleanly, demos render with 0 console errors on calm-studio.vercel.app. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-suite): migrate bundled studio demos to CALM 1.2 nested relationships The flat→nested relationship-type refactor (finos#2550) updated the studio runtime and tests but missed the three demo .calm.json files shipped at apps/studio/static/demos/. Loading any of them threw: TypeError: Cannot use 'in' operator to search for 'connects' in interacts …because the variant accessors (`getConnectsEndpoints`, `getActorAndNodes`, …) expected an object-shaped `relationship-type` but got the legacy string. Convert all relationships in ecommerce, aws-multi-tier, and opengris-local-cluster to the canonical CALM 1.2 nested form. Mapping: - connects: { source: {node: src}, destination: {node: dst} } - interacts: { actor: src, nodes: [dst] } - composed-of: { container: src, nodes: [dst] } - deployed-in: { container: dst, nodes: [src] } (semantics inverted — flat shape used source="thing being deployed", destination="where") Protocol and description fields preserved at the relationship root. Total: 58 relationships migrated across the 3 files. All three demos now load and render with 0 console errors on calm-studio.vercel.app. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * fix(calm-suite): align fluxnova templates with canonical control-detail schema The canonical control.json adopted in finos#2553 requires every control-detail to carry `requirement-url` plus exactly one of `config-url` / `config` (oneOf gate). The six bundled fluxnova templates predated this and shipped requirements with only `requirement-url`, producing 120 schema errors total when the user clicked Validate on a loaded template: fluxnova-ai-agent 9 errors (3 requirements × 3 errors) fluxnova-flash-risk 12 errors (4 requirements × 3 errors) fluxnova-kyc-onboarding 69 errors (23 requirements × 3 errors) fluxnova-microservices 6 errors (2 requirements × 3 errors) fluxnova-platform 6 errors (2 requirements × 3 errors) fluxnova-settlement 18 errors (6 requirements × 3 errors) Add `config-url` to every requirement, derived as `<requirement-url>/config.json`. opengris-* templates and static/demos/*.calm.json already validate cleanly and are untouched. Surfaces during PR finos#2553 review (rocketstack-matt) — the hand-rolled schema the studio used previously skipped control internals, so this drift was hidden until the canonical schema landed. Signed-off-by: Gourav Shah <gjs@opsflow.sh> * test(calm-suite): validate bundled templates + demos against CALM 1.2 schema Loops over every template registered by initAllTemplates() (10 files — 6 fluxnova + 4 opengris) and every .calm.json under apps/studio/static/demos/ (3 files — ecommerce, aws-multi-tier, opengris-local-cluster), runs validateCalmArchitecture on each, and asserts zero error-severity issues per file. Prevents the regression class that surfaced in PR finos#2553 review: the bundled content drifts away from the canonical CALM 1.2 meta-schemas because nothing in CI validates it, and the symptom only appears when a user clicks Validate after loading a template. Each file is one `it` block, so failures point at the specific template/demo + the first 5 schema errors with node id and JSON path. Signed-off-by: Gourav Shah <gjs@opsflow.sh> --------- Signed-off-by: Gourav Shah <gjs@opsflow.sh>
1 parent e06705c commit 3b103fa

75 files changed

Lines changed: 22820 additions & 2355 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/automated-release-calm-studio.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ jobs:
4141

4242
- name: Build (in dep order)
4343
run: |
44+
npm run build --if-present --workspace=@finos/calm-models
4445
npm run build --if-present --workspace=@calmstudio/calm-core
4546
npm run build --if-present --workspace=@calmstudio/extensions
4647
npm run build --if-present --workspace=@calmstudio/mcp

.github/workflows/build-calm-studio-desktop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ jobs:
4141
cache: npm
4242
cache-dependency-path: package-lock.json
4343
- run: npm ci
44+
- run: npm run build --if-present --workspace=@finos/calm-models
4445
- run: npm run build --if-present --workspace=@calmstudio/calm-core
4546
- name: Build sidecar bundle
4647
run: npm run build:bundle --workspace=@calmstudio/mcp
@@ -117,6 +118,7 @@ jobs:
117118

118119
- name: Build calm-core and studio (pre-Tauri)
119120
run: |
121+
npm run build --if-present --workspace=@finos/calm-models
120122
npm run build --if-present --workspace=@calmstudio/calm-core
121123
npm run build --if-present --workspace=@calmstudio/extensions
122124
npm run build --if-present --workspace=@calmstudio/studio

.github/workflows/build-calm-studio.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ on:
88
branches: ['main', 'release*']
99
paths:
1010
- 'calm-suite/calm-studio/**'
11+
- 'calm-models/**'
1112
- '.github/workflows/build-calm-studio.yml'
1213
- 'package.json'
1314
- 'package-lock.json'
1415
push:
1516
branches: ['main', 'release*']
1617
paths:
1718
- 'calm-suite/calm-studio/**'
19+
- 'calm-models/**'
1820
- '.github/workflows/build-calm-studio.yml'
1921
- 'package.json'
2022
- 'package-lock.json'
@@ -39,6 +41,7 @@ jobs:
3941

4042
- name: Build (in dep order)
4143
run: |
44+
npm run build --if-present --workspace=@finos/calm-models
4245
npm run build --if-present --workspace=@calmstudio/calm-core
4346
npm run build --if-present --workspace=@calmstudio/extensions
4447
npm run build --if-present --workspace=@calmstudio/mcp
@@ -91,6 +94,7 @@ jobs:
9194

9295
- name: Build
9396
run: |
97+
npm run build --if-present --workspace=@finos/calm-models
9498
npm run build --if-present --workspace=@calmstudio/calm-core
9599
npm run build --if-present --workspace=@calmstudio/extensions
96100
npm run build --if-present --workspace=@calmstudio/mcp

calm-suite/calm-studio/AGENTS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,17 @@ All output must conform to CALM 1.2. These are non-negotiable:
2626

2727
**Node types:** `actor`, `ecosystem`, `system`, `service`, `database`, `network`, `ldap`, `webclient`, `data-asset`. Extensions: `fluxnova:engine`, `ai:llm`, etc.
2828

29-
**Relationships:** `connects`, `interacts`, `deployed-in`, `composed-of`, `options`
29+
**Relationships** — nested form per CALM 1.2 meta-schema. `relationship-type` is an **object** keyed by variant. Exactly one variant key is present:
30+
31+
```json
32+
{ "unique-id": "...", "relationship-type": { "connects": { "source": { "node": "..." }, "destination": { "node": "..." } } } }
33+
{ "unique-id": "...", "relationship-type": { "composed-of": { "container": "...", "nodes": ["..."] } } }
34+
{ "unique-id": "...", "relationship-type": { "interacts": { "actor": "...", "nodes": ["..."] } } }
35+
{ "unique-id": "...", "relationship-type": { "deployed-in": { "container": "...", "nodes": ["..."] } } }
36+
{ "unique-id": "...", "relationship-type": { "options": [ ... ] } }
37+
```
38+
39+
The legacy flat shape (`'relationship-type': 'connects'` + sibling `source`/`destination` strings) is **not valid CALM 1.2** and was removed in #2550. Use the variant accessors `getRelationshipVariant`, `getConnectsEndpoints`, `getContainerAndNodes`, `getActorAndNodes`, `getReferencedNodeIds` from `@calmstudio/calm-core` to traverse relationships generically.
3040

3141
**Protocols:** `HTTP`, `HTTPS`, `FTP`, `SFTP`, `JDBC`, `WebSocket`, `SocketIO`, `LDAP`, `AMQP`, `TLS`, `mTLS`, `TCP`
3242

calm-suite/calm-studio/apps/studio/src/lib/io/scalerToml.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,13 @@ function findConnectedPeers(
123123
): CalmNode[] {
124124
const peers: CalmNode[] = [];
125125
for (const rel of relationships) {
126-
if (rel['relationship-type'] !== 'connects') continue;
126+
const rt = rel['relationship-type'];
127+
if (!('connects' in rt)) continue;
128+
const src = rt.connects.source.node;
129+
const dst = rt.connects.destination.node;
127130
let peerId: string | null = null;
128-
if (rel.source === nodeId) peerId = rel.destination;
129-
else if (rel.destination === nodeId) peerId = rel.source;
131+
if (src === nodeId) peerId = dst;
132+
else if (dst === nodeId) peerId = src;
130133
if (peerId !== null) {
131134
const peer = nodeMap.get(peerId);
132135
if (peer) peers.push(peer);

calm-suite/calm-studio/apps/studio/src/lib/layout/elkLayout.ts

Lines changed: 92 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717

1818
import ELK from 'elkjs/lib/elk.bundled.js';
1919
import type { ElkNode, ElkExtendedEdge } from 'elkjs/lib/elk.bundled.js';
20-
import type { CalmArchitecture, CalmRelationship } from '@calmstudio/calm-core';
20+
import type {
21+
CalmArchitecture,
22+
CalmRelationship,
23+
CalmRelationshipVariant
24+
} from '@calmstudio/calm-core';
2125

2226
// ─── Types ────────────────────────────────────────────────────────────────────
2327

@@ -26,8 +30,52 @@ export type LayoutDirection = 'DOWN' | 'RIGHT' | 'UP';
2630
/** Position map: node unique-id -> {x, y, width?, height?} from ELK layout. */
2731
export type PositionMap = Map<string, { x: number; y: number; width?: number; height?: number }>;
2832

29-
/** The set of CALM edge types that imply containment. */
30-
const CONTAINMENT_EDGE_TYPES = new Set(['deployed-in', 'composed-of']);
33+
/** The set of CALM 1.2 variant keys that imply containment. */
34+
const CONTAINMENT_VARIANTS: ReadonlySet<CalmRelationshipVariant> = new Set([
35+
'deployed-in',
36+
'composed-of'
37+
]);
38+
39+
/**
40+
* Expand a CALM relationship into flat (source, target) edge pairs for layout.
41+
* Mirrors stores/projection.ts but kept local to avoid app→lib coupling.
42+
*/
43+
function expandLayoutPairs(
44+
rel: CalmRelationship,
45+
): Array<{ source: string; target: string; variant: CalmRelationshipVariant }> {
46+
const rt = rel['relationship-type'];
47+
if ('connects' in rt) {
48+
return [
49+
{
50+
source: rt.connects.source.node,
51+
target: rt.connects.destination.node,
52+
variant: 'connects'
53+
}
54+
];
55+
}
56+
if ('composed-of' in rt) {
57+
return rt['composed-of'].nodes.map((child) => ({
58+
source: rt['composed-of'].container,
59+
target: child,
60+
variant: 'composed-of' as const
61+
}));
62+
}
63+
if ('deployed-in' in rt) {
64+
return rt['deployed-in'].nodes.map((child) => ({
65+
source: rt['deployed-in'].container,
66+
target: child,
67+
variant: 'deployed-in' as const
68+
}));
69+
}
70+
if ('interacts' in rt) {
71+
return rt.interacts.nodes.map((peer) => ({
72+
source: rt.interacts.actor,
73+
target: peer,
74+
variant: 'interacts' as const
75+
}));
76+
}
77+
return [];
78+
}
3179

3280
/** Default node dimensions — sized to fit typical node labels + icons */
3381
const NODE_WIDTH = 180;
@@ -65,38 +113,43 @@ export async function layoutCalm(
65113
const parentChildren = new Map<string, Set<string>>();
66114

67115
for (const rel of arch.relationships) {
68-
if (!CONTAINMENT_EDGE_TYPES.has(rel['relationship-type'])) continue;
69-
if (pinnedIds.has(rel.source) || pinnedIds.has(rel.destination)) continue;
70-
71-
let parentId: string;
72-
let childId: string;
73-
74-
if (rel['relationship-type'] === 'deployed-in') {
75-
parentId = rel.destination;
76-
childId = rel.source;
77-
} else {
78-
parentId = rel.source;
79-
childId = rel.destination;
80-
}
116+
for (const pair of expandLayoutPairs(rel)) {
117+
if (!CONTAINMENT_VARIANTS.has(pair.variant)) continue;
118+
if (pinnedIds.has(pair.source) || pinnedIds.has(pair.target)) continue;
81119

82-
if (!freeNodeIds.has(parentId) || !freeNodeIds.has(childId)) continue;
120+
// In CALM 1.2, composed-of and deployed-in both use `container` as
121+
// the source position in the expanded pair → container is parent.
122+
const parentId = pair.source;
123+
const childId = pair.target;
83124

84-
childToParent.set(childId, parentId);
85-
if (!parentChildren.has(parentId)) {
86-
parentChildren.set(parentId, new Set());
125+
if (!freeNodeIds.has(parentId) || !freeNodeIds.has(childId)) continue;
126+
127+
childToParent.set(childId, parentId);
128+
if (!parentChildren.has(parentId)) {
129+
parentChildren.set(parentId, new Set());
130+
}
131+
parentChildren.get(parentId)!.add(childId);
87132
}
88-
parentChildren.get(parentId)!.add(childId);
89133
}
90134

91-
// Non-containment edges (for ELK layout edges)
92-
const layoutEdges = arch.relationships.filter(
93-
(r) =>
94-
!CONTAINMENT_EDGE_TYPES.has(r['relationship-type']) &&
95-
freeNodeIds.has(r.source) &&
96-
freeNodeIds.has(r.destination) &&
97-
!pinnedIds.has(r.source) &&
98-
!pinnedIds.has(r.destination)
99-
);
135+
// Flatten all relationships into (source, target) pairs once, then keep
136+
// only the non-containment pairs that connect two free, non-pinned nodes.
137+
type LayoutPair = { id: string; source: string; target: string };
138+
const layoutEdges: LayoutPair[] = [];
139+
for (const rel of arch.relationships) {
140+
const pairs = expandLayoutPairs(rel);
141+
const multi = pairs.length > 1;
142+
pairs.forEach((p, i) => {
143+
if (CONTAINMENT_VARIANTS.has(p.variant)) return;
144+
if (!freeNodeIds.has(p.source) || !freeNodeIds.has(p.target)) return;
145+
if (pinnedIds.has(p.source) || pinnedIds.has(p.target)) return;
146+
layoutEdges.push({
147+
id: multi ? `${rel['unique-id']}#${i}` : rel['unique-id'],
148+
source: p.source,
149+
target: p.target
150+
});
151+
});
152+
}
100153

101154
// Find the direct child of a container that a descendant belongs to.
102155
// E.g., for VPC containing subnets containing services,
@@ -150,11 +203,11 @@ export async function layoutCalm(
150203
// Add edges between direct children within this container
151204
const childSet = children;
152205
const innerEdges: ElkExtendedEdge[] = layoutEdges
153-
.filter((r) => childSet.has(r.source) && childSet.has(r.destination))
206+
.filter((r) => childSet.has(r.source) && childSet.has(r.target))
154207
.map((r) => ({
155-
id: r['unique-id'],
208+
id: r.id,
156209
sources: [r.source],
157-
targets: [r.destination],
210+
targets: [r.target],
158211
}));
159212

160213
// Also find cross-child-container edges: edges between descendants in
@@ -164,11 +217,11 @@ export async function layoutCalm(
164217
const allDescendants = getAllDescendants(nodeId);
165218
const seenCrossEdgePairs = new Set<string>();
166219
for (const r of layoutEdges) {
167-
if (childSet.has(r.source) && childSet.has(r.destination)) continue; // already handled
168-
if (!allDescendants.has(r.source) || !allDescendants.has(r.destination)) continue;
220+
if (childSet.has(r.source) && childSet.has(r.target)) continue; // already handled
221+
if (!allDescendants.has(r.source) || !allDescendants.has(r.target)) continue;
169222

170223
const srcChild = findDirectChildOf(nodeId, r.source);
171-
const tgtChild = findDirectChildOf(nodeId, r.destination);
224+
const tgtChild = findDirectChildOf(nodeId, r.target);
172225
if (!srcChild || !tgtChild || srcChild === tgtChild) continue;
173226

174227
const pairKey = `${srcChild}->${tgtChild}`;
@@ -268,16 +321,16 @@ export async function layoutCalm(
268321
const seenTopEdgePairs = new Set<string>();
269322
const topEdges: ElkExtendedEdge[] = [];
270323
for (const r of layoutEdges) {
271-
if (usedEdgeIds.has(r['unique-id'])) continue;
324+
if (usedEdgeIds.has(r.id)) continue;
272325
const src = findTopLevelAncestor(r.source, childToParent);
273-
const tgt = findTopLevelAncestor(r.destination, childToParent);
326+
const tgt = findTopLevelAncestor(r.target, childToParent);
274327
// Skip self-loops (both endpoints inside same top-level container)
275328
if (src === tgt) continue;
276329
const pairKey = `${src}->${tgt}`;
277330
if (seenTopEdgePairs.has(pairKey)) continue;
278331
seenTopEdgePairs.add(pairKey);
279332
topEdges.push({
280-
id: r['unique-id'],
333+
id: r.id,
281334
sources: [src],
282335
targets: [tgt],
283336
});

calm-suite/calm-studio/apps/studio/src/lib/properties/EdgeProperties.svelte

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,34 @@
88
-->
99
<script lang="ts">
1010
import type { Edge } from '@xyflow/svelte';
11-
import type { CalmRelationshipType } from '@calmstudio/calm-core';
11+
import type {
12+
CalmRelationshipType,
13+
CalmRelationshipVariant
14+
} from '@calmstudio/calm-core';
1215
import { updateEdgeProperty } from '$lib/stores/calmModel.svelte';
1316
import ControlsList from './ControlsList.svelte';
1417
18+
function buildRelationshipType(
19+
variant: CalmRelationshipVariant,
20+
source: string,
21+
target: string,
22+
): CalmRelationshipType {
23+
switch (variant) {
24+
case 'connects':
25+
return {
26+
connects: { source: { node: source }, destination: { node: target } },
27+
};
28+
case 'composed-of':
29+
return { 'composed-of': { container: source, nodes: [target] } };
30+
case 'deployed-in':
31+
return { 'deployed-in': { container: source, nodes: [target] } };
32+
case 'interacts':
33+
return { interacts: { actor: source, nodes: [target] } };
34+
case 'options':
35+
return { options: [] };
36+
}
37+
}
38+
1539
let {
1640
edge,
1741
onBeforeFirstEdit,
@@ -24,7 +48,7 @@
2448
onmutate?: () => void;
2549
} = $props();
2650
27-
const RELATIONSHIP_TYPES: CalmRelationshipType[] = [
51+
const RELATIONSHIP_TYPES: CalmRelationshipVariant[] = [
2852
'connects',
2953
'interacts',
3054
'deployed-in',
@@ -72,8 +96,8 @@
7296
localProtocol = String(edge.data?.protocol ?? '');
7397
});
7498
75-
const relType: CalmRelationshipType = $derived(
76-
(edge.data?.calmRelType ?? edge.type ?? 'connects') as CalmRelationshipType
99+
const relType: CalmRelationshipVariant = $derived(
100+
(edge.data?.calmVariant ?? edge.type ?? 'connects') as CalmRelationshipVariant
77101
);
78102
const showProtocol: boolean = $derived(PROTOCOL_TYPES.includes(relType));
79103
@@ -88,9 +112,14 @@
88112
}
89113
90114
function handleRelTypeChange(e: Event) {
91-
const value = (e.target as HTMLSelectElement).value;
115+
const value = (e.target as HTMLSelectElement).value as CalmRelationshipVariant;
92116
signalFirstEdit();
93-
updateEdgeProperty(edge.id, 'relationship-type', value);
117+
// CALM 1.2 nested form: build a fresh relationship-type object using the
118+
// edge's current source/target endpoints. Switching variant preserves
119+
// connectivity but reshapes the payload (e.g. connects.source.node →
120+
// composed-of.container).
121+
const nested = buildRelationshipType(value, edge.source, edge.target);
122+
updateEdgeProperty(edge.id, 'relationship-type', nested);
94123
onmutate?.();
95124
}
96125

calm-suite/calm-studio/apps/studio/src/lib/stores/flowState.svelte.test.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,11 @@ const testArch: CalmArchitecture = {
2222
relationships: [
2323
{
2424
'unique-id': 'rel-1',
25-
'relationship-type': 'connects',
26-
source: 'node-a',
27-
destination: 'node-b',
25+
'relationship-type': { connects: { source: { node: 'node-a' }, destination: { node: 'node-b' } } },
2826
},
2927
{
3028
'unique-id': 'rel-2',
31-
'relationship-type': 'connects',
32-
source: 'node-b',
33-
destination: 'node-c',
29+
'relationship-type': { connects: { source: { node: 'node-b' }, destination: { node: 'node-c' } } },
3430
},
3531
],
3632
flows: [

calm-suite/calm-studio/apps/studio/src/lib/stores/flowState.svelte.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* animated dot overlays with sequence badges on active flow edges.
1010
*/
1111
import type { CalmArchitecture, CalmTransition } from '@calmstudio/calm-core';
12+
import { getReferencedNodeIds } from '@calmstudio/calm-core';
1213

1314
// Reactive state — module-level $state so it is shared across all consumers.
1415
let activeFlowId = $state<string | null>(null);
@@ -61,6 +62,6 @@ export function isNodeInActiveFlow(arch: CalmArchitecture, nodeId: string): bool
6162
const flowEdgeIds = getActiveFlowEdgeIds(arch);
6263
if (flowEdgeIds.size === 0) return true;
6364
return arch.relationships.some(
64-
(r) => flowEdgeIds.has(r['unique-id']) && (r.source === nodeId || r.destination === nodeId)
65+
(r) => flowEdgeIds.has(r['unique-id']) && getReferencedNodeIds(r).includes(nodeId)
6566
);
6667
}

0 commit comments

Comments
 (0)