Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions apps/playground/src/server/generation/service.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import "@tanstack/react-start/server-only";

import {
SKETCHI_DIAGRAM_STYLE,
type FlowchartDiagram,
type MindmapDiagram,
} from "@sketchi/diagram-core";
import type { FlowchartDiagram, MindmapDiagram, SequenceDiagram } from "@sketchi/diagram-core";
import {
CloudflareAiGatewayBinding,
CloudflareGoogleAiStudioClientLive,
Expand All @@ -16,7 +12,6 @@ import {
type DiagramGenerationCandidate,
type DiagramGenerationError,
type DiagramGenerationType,
type GeneratedSequenceDiagram,
DiagramGenerationPolicyLive,
} from "@sketchi/diagram-generation";
import { Context, Effect, Layer } from "effect";
Expand Down Expand Up @@ -106,16 +101,27 @@ export function mindmapDocumentInput(diagram: MindmapDiagram): unknown {
};
}

/** Convert a validated sequence candidate into the native Code Mode contract. */
export function sequenceDocumentInput(diagram: GeneratedSequenceDiagram): unknown {
/** Convert a validated sequence candidate into a canonical document input. */
export function sequenceDocumentInput(diagram: SequenceDiagram): unknown {
return {
type: "sequence",
spec: {
id: diagram.id,
title: diagram.title,
participants: diagram.participants,
messages: diagram.messages,
style: diagram.style ?? SKETCHI_DIAGRAM_STYLE,
participants: diagram.participants.map((participant) => ({
id: participant.id,
label: participant.label,
...(participant.kind ? { kind: participant.kind } : {}),
})),
messages: diagram.messages.map((message) => ({
id: message.id,
source: message.source,
target: message.target,
label: message.label,
...(message.type ? { type: message.type } : {}),
...(message.style ? { style: message.style } : {}),
})),
style: diagram.style,
},
};
}
Expand Down
11 changes: 9 additions & 2 deletions packages/diagram/agent/src/lib/code-mode/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
CANVAS_LIMITS,
CANVAS_NODE_ICON,
CANVAS_SPEC_VERSION,
SEQUENCE_MESSAGE_STYLES,
SEQUENCE_MESSAGE_TYPES,
SKETCHI_DIAGRAM_STYLE,
} from "@sketchi/diagram-core";

Expand Down Expand Up @@ -598,6 +600,11 @@ export const FlowchartSpecEdgeSchema = FlowchartSpecEdge;
export const FlowchartSpecLayoutSchema = FlowchartSpecLayout;
export const FlowchartSpecStyleSchema = FlowchartSpecStyle;

/**
* The authoring form of diagram-core's canonical SequenceDiagram: message ids
* are optional and style is defaulted. buildSequenceDiagram normalizes it into
* the core contract, which owns every sequence invariant.
*/
export class SequenceParticipantSpec extends Schema.Class<SequenceParticipantSpec>(
"SequenceParticipantSpec",
)(
Expand All @@ -615,8 +622,8 @@ export class SequenceMessageSpec extends Schema.Class<SequenceMessageSpec>("Sequ
source: RequiredNonEmptyString,
target: RequiredNonEmptyString,
label: RequiredNonEmptyString,
type: optionalContract(literals(["message", "return"])),
style: optionalContract(literals(["solid", "dashed"])),
type: optionalContract(literals(SEQUENCE_MESSAGE_TYPES)),
style: optionalContract(literals(SEQUENCE_MESSAGE_STYLES)),
},
{ identifier: undefined },
) {}
Expand Down
5 changes: 4 additions & 1 deletion packages/diagram/agent/src/lib/code-mode/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1991,7 +1991,10 @@ describe("Code Mode runtime", () => {
issues: expect.arrayContaining([
expect.objectContaining({
code: "missing_edge_source",
hint: expect.stringContaining("participant"),
ref: { kind: "request", path: "spec.messages.[0].source" },
message: 'Message source "missing" is not a participant.',
// Published Code Mode wording names the request field.
hint: "Use the id of a participant declared in spec.participants.",
}),
expect.objectContaining({
code: "self_loop",
Expand Down
133 changes: 42 additions & 91 deletions packages/diagram/agent/src/lib/code-mode/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import {
compileCanvasSpec,
getCanvasValidationIssues,
getFlowchartValidationIssues,
getSequenceValidationIssues,
parseMindmapDiagram,
parseSequenceDiagram,
sequenceLifelineId,
validateFlowchartDiagram,
type FlowchartDiagram,
type FlowchartValidationIssueRef,
type MindmapDiagram,
type CanvasValidationIssue,
type SequenceDiagram,
type SequenceValidationIssueCode,
} from "@sketchi/diagram-core";
import {
convertSceneToExcalidraw,
Expand Down Expand Up @@ -442,96 +443,46 @@ function normalizeSequenceDiagramSpec(
};
}

function validateNormalizedSequenceDiagram(spec: NormalizedSequenceDiagramSpec): CodeModeIssue[] {
const issues: CodeModeIssue[] = [];
const participantIds = new Set<string>();
spec.participants.forEach((participant, index) => {
if (participantIds.has(participant.id)) {
issues.push(
issue({
code: "duplicate_node_id",
stage: "input",
ref: { kind: "request", path: `spec.participants.[${index}].id` },
message: `Participant id "${participant.id}" is duplicated.`,
hint: "Give every participant a unique stable id and update message references.",
}),
);
}
participantIds.add(participant.id);
});
function toSequenceDiagram(spec: NormalizedSequenceDiagramSpec): SequenceDiagram {
return {
id: spec.id,
title: spec.title,
type: "sequence",
participants: spec.participants,
messages: spec.messages,
style: spec.style,
};
}

const participantIndexById = new Map(
spec.participants.map((participant, index) => [participant.id, index]),
);
spec.participants.forEach((participant) => {
const generatedLifelineId = sequenceLifelineId(participant.id);
const collisionIndex = participantIndexById.get(generatedLifelineId);
if (collisionIndex === undefined) {
return;
}
issues.push(
issue({
code: "duplicate_node_id",
stage: "input",
ref: {
kind: "request",
path: `spec.participants.[${collisionIndex}].id`,
},
message: `Participant id "${generatedLifelineId}" collides with the generated lifeline for "${participant.id}".`,
hint: "Rename the participant so its id does not equal another participant id followed by :lifeline.",
}),
);
});
/** Code Mode's published issue codes for diagram-core's sequence invariants. */
const SEQUENCE_ISSUE_CODES = {
duplicate_participant_id: "duplicate_node_id",
lifeline_id_collision: "duplicate_node_id",
duplicate_message_id: "duplicate_edge_id",
missing_message_source: "missing_edge_source",
missing_message_target: "missing_edge_target",
self_message: "self_loop",
} as const satisfies Record<SequenceValidationIssueCode, CodeModeIssueCode>;

/**
* Code Mode requests nest the diagram under `spec`, so hints that name a field
* keep the published request path rather than diagram-core's.
*/
const SEQUENCE_ISSUE_HINTS: Partial<Record<SequenceValidationIssueCode, string>> = {
missing_message_source: "Use the id of a participant declared in spec.participants.",
missing_message_target: "Use the id of a participant declared in spec.participants.",
};

const messageIds = new Set<string>();
spec.messages.forEach((message, index) => {
if (messageIds.has(message.id)) {
issues.push(
issue({
code: "duplicate_edge_id",
stage: "input",
ref: { kind: "request", path: `spec.messages.[${index}].id` },
message: `Message id "${message.id}" is duplicated.`,
hint: "Give every message a unique id or omit message ids to generate them deterministically.",
}),
);
}
messageIds.add(message.id);
if (!participantIds.has(message.source)) {
issues.push(
issue({
code: "missing_edge_source",
stage: "input",
ref: { kind: "request", path: `spec.messages.[${index}].source` },
message: `Message source "${message.source}" is not a participant.`,
hint: "Use the id of a participant declared in spec.participants.",
}),
);
}
if (!participantIds.has(message.target)) {
issues.push(
issue({
code: "missing_edge_target",
stage: "input",
ref: { kind: "request", path: `spec.messages.[${index}].target` },
message: `Message target "${message.target}" is not a participant.`,
hint: "Use the id of a participant declared in spec.participants.",
}),
);
}
if (message.source === message.target) {
issues.push(
issue({
code: "self_loop",
stage: "input",
ref: { kind: "request", path: `spec.messages.[${index}]` },
message: `Message "${message.id}" is self-referential.`,
hint: "Choose a different target participant; self messages are not supported.",
}),
);
}
});
return issues;
function validateNormalizedSequenceDiagram(spec: NormalizedSequenceDiagramSpec): CodeModeIssue[] {
return getSequenceValidationIssues(toSequenceDiagram(spec)).map((entry) =>
issue({
code: SEQUENCE_ISSUE_CODES[entry.code],
stage: "input",
ref: { kind: "request", path: `spec.${entry.path}` },
message: entry.message,
hint: SEQUENCE_ISSUE_HINTS[entry.code] ?? entry.hint,
}),
);
}

function sequenceQuality(spec: NormalizedSequenceDiagramSpec, threshold: number): QualityReport {
Expand Down Expand Up @@ -2273,7 +2224,7 @@ const buildSequenceDiagramWorkflow = Effect.fn("codeMode.buildSequenceDiagram.wo
}

const scene = yield* Effect.try({
try: () => renderSequenceDiagram(parseSequenceDiagram({ ...normalizedSpec, type: "sequence" })),
try: () => renderSequenceDiagram(toSequenceDiagram(normalizedSpec)),
catch: (cause) =>
new BuildSequenceDiagramFailure({
status: "render_failed",
Expand Down
Loading
Loading