@stigmer/react 3.12.0 → 3.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/execution/useCreateAgentExecution.d.ts +3 -2
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/internal/isUnimplemented.d.ts +17 -0
- package/internal/isUnimplemented.d.ts.map +1 -0
- package/internal/isUnimplemented.js +26 -0
- package/internal/isUnimplemented.js.map +1 -0
- package/mcp-server/McpServerConnectDialog.js +7 -3
- package/mcp-server/McpServerConnectDialog.js.map +1 -1
- package/mcp-server/useMcpServerCredentials.d.ts +38 -10
- package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
- package/mcp-server/useMcpServerCredentials.js +50 -7
- package/mcp-server/useMcpServerCredentials.js.map +1 -1
- package/mcp-server/useOrgOAuthApp.d.ts +22 -0
- package/mcp-server/useOrgOAuthApp.d.ts.map +1 -1
- package/mcp-server/useOrgOAuthApp.js +39 -7
- package/mcp-server/useOrgOAuthApp.js.map +1 -1
- package/package.json +4 -4
- package/session/useSessionConversation.d.ts +4 -2
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js.map +1 -1
- package/skill/useSkillVersions.d.ts.map +1 -1
- package/skill/useSkillVersions.js +1 -9
- package/skill/useSkillVersions.js.map +1 -1
- package/src/execution/useCreateAgentExecution.ts +3 -2
- package/src/internal/isUnimplemented.ts +26 -0
- package/src/mcp-server/McpServerConnectDialog.tsx +29 -11
- package/src/mcp-server/__tests__/VendorApprovalBlocked.surfaces.test.tsx +103 -5
- package/src/mcp-server/__tests__/byoaEditionDegradation.test.tsx +323 -0
- package/src/mcp-server/__tests__/useMcpServerCredentials.test.tsx +306 -0
- package/src/mcp-server/useMcpServerCredentials.ts +87 -19
- package/src/mcp-server/useOrgOAuthApp.ts +54 -8
- package/src/session/useSessionConversation.ts +4 -2
- package/src/skill/useSkillVersions.ts +1 -11
- package/src/workflow/WorkflowCanvasEditor.tsx +37 -7
- package/src/workflow/WorkflowExecutionGraph.tsx +26 -1
- package/src/workflow/WorkflowExecutionViewer.tsx +27 -13
- package/src/workflow/WorkflowNode.tsx +5 -2
- package/src/workflow/WorkflowRunDialog.tsx +28 -1
- package/src/workflow/__tests__/WorkflowExecutionViewer.reconciliation.test.tsx +48 -1
- package/src/workflow/__tests__/WorkflowNode.test.tsx +93 -0
- package/src/workflow/__tests__/WorkflowRunDialog.test.tsx +127 -0
- package/src/workflow/__tests__/graph-history.test.ts +103 -0
- package/src/workflow/__tests__/overview-graph.test.ts +17 -0
- package/src/workflow/__tests__/useCanvasKeyboardShortcuts.undo.test.ts +115 -0
- package/src/workflow/__tests__/useWorkflowCanvas.undo.test.tsx +133 -0
- package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +16 -7
- package/src/workflow/instance/useDeleteWorkflowInstance.ts +9 -4
- package/src/workflow/layout/registry-dimensions.ts +2 -5
- package/src/workflow/node-shell/NodeShell.tsx +4 -0
- package/src/workflow/task-type-visual-registry.ts +5 -3
- package/src/workflow/useCanvasKeyboardShortcuts.ts +33 -3
- package/src/workflow/useGraphHistory.ts +22 -41
- package/src/workflow/useWorkflowCanvas.ts +12 -10
- package/src/workflow/useWorkflowSave.ts +2 -15
- package/src/workflow/workflow-graph-conversions.ts +25 -3
- package/workflow/WorkflowCanvasEditor.d.ts.map +1 -1
- package/workflow/WorkflowCanvasEditor.js +31 -6
- package/workflow/WorkflowCanvasEditor.js.map +1 -1
- package/workflow/WorkflowExecutionGraph.d.ts.map +1 -1
- package/workflow/WorkflowExecutionGraph.js +27 -1
- package/workflow/WorkflowExecutionGraph.js.map +1 -1
- package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
- package/workflow/WorkflowExecutionViewer.js +26 -12
- package/workflow/WorkflowExecutionViewer.js.map +1 -1
- package/workflow/WorkflowNode.d.ts.map +1 -1
- package/workflow/WorkflowNode.js +2 -2
- package/workflow/WorkflowNode.js.map +1 -1
- package/workflow/WorkflowRunDialog.d.ts +11 -1
- package/workflow/WorkflowRunDialog.d.ts.map +1 -1
- package/workflow/WorkflowRunDialog.js +16 -2
- package/workflow/WorkflowRunDialog.js.map +1 -1
- package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
- package/workflow/instance/CreateWorkflowInstanceDialog.js +10 -2
- package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
- package/workflow/instance/useDeleteWorkflowInstance.d.ts +9 -4
- package/workflow/instance/useDeleteWorkflowInstance.d.ts.map +1 -1
- package/workflow/instance/useDeleteWorkflowInstance.js +4 -2
- package/workflow/instance/useDeleteWorkflowInstance.js.map +1 -1
- package/workflow/layout/registry-dimensions.d.ts.map +1 -1
- package/workflow/layout/registry-dimensions.js +2 -5
- package/workflow/layout/registry-dimensions.js.map +1 -1
- package/workflow/node-shell/NodeShell.js +1 -1
- package/workflow/node-shell/NodeShell.js.map +1 -1
- package/workflow/task-type-visual-registry.d.ts.map +1 -1
- package/workflow/task-type-visual-registry.js +5 -3
- package/workflow/task-type-visual-registry.js.map +1 -1
- package/workflow/useCanvasKeyboardShortcuts.d.ts +15 -4
- package/workflow/useCanvasKeyboardShortcuts.d.ts.map +1 -1
- package/workflow/useCanvasKeyboardShortcuts.js +25 -4
- package/workflow/useCanvasKeyboardShortcuts.js.map +1 -1
- package/workflow/useGraphHistory.d.ts +16 -9
- package/workflow/useGraphHistory.d.ts.map +1 -1
- package/workflow/useGraphHistory.js +19 -33
- package/workflow/useGraphHistory.js.map +1 -1
- package/workflow/useWorkflowCanvas.d.ts +1 -3
- package/workflow/useWorkflowCanvas.d.ts.map +1 -1
- package/workflow/useWorkflowCanvas.js +15 -9
- package/workflow/useWorkflowCanvas.js.map +1 -1
- package/workflow/useWorkflowSave.d.ts.map +1 -1
- package/workflow/useWorkflowSave.js +2 -15
- package/workflow/useWorkflowSave.js.map +1 -1
- package/workflow/workflow-graph-conversions.d.ts +19 -1
- package/workflow/workflow-graph-conversions.d.ts.map +1 -1
- package/workflow/workflow-graph-conversions.js +17 -3
- package/workflow/workflow-graph-conversions.js.map +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { render, cleanup } from "@testing-library/react";
|
|
3
|
+
import type { NodeProps } from "@xyflow/react";
|
|
4
|
+
import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
|
|
5
|
+
import { WorkflowNode } from "../WorkflowNode";
|
|
6
|
+
import { START_NODE_ID, END_NODE_ID } from "../workflow-graph-model";
|
|
7
|
+
import type { CanvasTaskNodeData } from "../workflow-graph-conversions";
|
|
8
|
+
|
|
9
|
+
// NodeHandles and NodeActions render @xyflow/react primitives (Handle,
|
|
10
|
+
// NodeToolbar) that throw outside a live React Flow canvas. Stub them so
|
|
11
|
+
// WorkflowNode's own DOM contract can be asserted in isolation — the
|
|
12
|
+
// established pattern for React Flow-adjacent component tests in this suite.
|
|
13
|
+
vi.mock("../node-shell/NodeHandles.js", () => ({
|
|
14
|
+
NodeHandles: () => null,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("../node-shell/NodeActions.js", () => ({
|
|
17
|
+
NodeActions: () => null,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
function makeData(overrides: Partial<CanvasTaskNodeData>): CanvasTaskNodeData {
|
|
21
|
+
return {
|
|
22
|
+
taskName: "analyze",
|
|
23
|
+
kind: WorkflowTaskKind.agent_call,
|
|
24
|
+
kindString: "agent_call",
|
|
25
|
+
category: "ai",
|
|
26
|
+
visualClass: "task-card",
|
|
27
|
+
displayName: "Agent Call",
|
|
28
|
+
ariaShapeLabel: "card",
|
|
29
|
+
config: {},
|
|
30
|
+
isSentinel: false,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderNode(id: string, data: CanvasTaskNodeData) {
|
|
36
|
+
const props = { id, data, selected: false } as unknown as NodeProps & {
|
|
37
|
+
data: CanvasTaskNodeData;
|
|
38
|
+
};
|
|
39
|
+
return render(<WorkflowNode {...props} />);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(cleanup);
|
|
43
|
+
|
|
44
|
+
describe("WorkflowNode data-task-kind contract (oss#581)", () => {
|
|
45
|
+
test("task nodes expose their kind string as data-task-kind", () => {
|
|
46
|
+
const { container } = renderNode("analyze", makeData({}));
|
|
47
|
+
const node = container.querySelector('[role="button"]');
|
|
48
|
+
expect(node).not.toBeNull();
|
|
49
|
+
expect(node!.getAttribute("data-task-kind")).toBe("agent_call");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("sentinel nodes carry no data-task-kind attribute", () => {
|
|
53
|
+
for (const [id, taskName] of [
|
|
54
|
+
[START_NODE_ID, "Start"],
|
|
55
|
+
[END_NODE_ID, "End"],
|
|
56
|
+
] as const) {
|
|
57
|
+
const { container, unmount } = renderNode(
|
|
58
|
+
id,
|
|
59
|
+
makeData({
|
|
60
|
+
taskName,
|
|
61
|
+
kind: WorkflowTaskKind.workflow_task_kind_unspecified,
|
|
62
|
+
kindString: id,
|
|
63
|
+
category: id === START_NODE_ID ? "start" : "end",
|
|
64
|
+
visualClass: "terminal-pill",
|
|
65
|
+
displayName: taskName,
|
|
66
|
+
ariaShapeLabel: "pill",
|
|
67
|
+
isSentinel: true,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
const node = container.querySelector('[role="button"]');
|
|
71
|
+
expect(node).not.toBeNull();
|
|
72
|
+
expect(node!.hasAttribute("data-task-kind")).toBe(false);
|
|
73
|
+
unmount();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the internal unknown_* enum fallback never reaches the DOM", () => {
|
|
78
|
+
const { container } = renderNode(
|
|
79
|
+
START_NODE_ID,
|
|
80
|
+
makeData({
|
|
81
|
+
taskName: "Start",
|
|
82
|
+
kind: WorkflowTaskKind.workflow_task_kind_unspecified,
|
|
83
|
+
kindString: START_NODE_ID,
|
|
84
|
+
category: "start",
|
|
85
|
+
visualClass: "terminal-pill",
|
|
86
|
+
displayName: "Start",
|
|
87
|
+
ariaShapeLabel: "pill",
|
|
88
|
+
isSentinel: true,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
expect(container.querySelector('[data-task-kind^="unknown_"]')).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Tests for WorkflowRunDialog's `initialInstanceId` preselection (issue
|
|
2
|
+
// #582): a row-level "Run" must open the dialog with that instance already
|
|
3
|
+
// selected, while reopening without one resets to the server-resolved
|
|
4
|
+
// default. Selection is asserted through the rendered instance <select> —
|
|
5
|
+
// the observable surface — with the real useRunWorkflowFlow underneath.
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
8
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import type { Stigmer } from "@stigmer/sdk";
|
|
11
|
+
import { StigmerContext } from "../../context";
|
|
12
|
+
import { WorkflowRunDialog } from "../WorkflowRunDialog";
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
// happy-dom does not implement the native <dialog> modal API.
|
|
16
|
+
HTMLDialogElement.prototype.showModal = function showModal() {
|
|
17
|
+
this.open = true;
|
|
18
|
+
};
|
|
19
|
+
HTMLDialogElement.prototype.close = function close() {
|
|
20
|
+
this.open = false;
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
cleanup();
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function makeWorkflow() {
|
|
30
|
+
return {
|
|
31
|
+
metadata: { id: "wf_1", name: "my-workflow", slug: "my-workflow" },
|
|
32
|
+
spec: { env: {} },
|
|
33
|
+
} as never;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeInstance(id: string, name: string) {
|
|
37
|
+
return {
|
|
38
|
+
metadata: { id, name, slug: name },
|
|
39
|
+
spec: { environmentRefs: [] },
|
|
40
|
+
} as never;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Two user instances so the picker always renders (it needs >= 1 user
|
|
44
|
+
// instance when defaultInstanceId is provided).
|
|
45
|
+
const DEFAULT_INSTANCE_ID = "wfi_default";
|
|
46
|
+
const INSTANCES = [
|
|
47
|
+
makeInstance("wfi_a", "instance-a"),
|
|
48
|
+
makeInstance("wfi_b", "instance-b"),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const mockClient = {
|
|
52
|
+
workflowExecution: { create: vi.fn() },
|
|
53
|
+
environment: { getByReference: vi.fn() },
|
|
54
|
+
} as unknown as Stigmer;
|
|
55
|
+
|
|
56
|
+
function renderDialog(props?: {
|
|
57
|
+
open?: boolean;
|
|
58
|
+
initialInstanceId?: string | null;
|
|
59
|
+
}) {
|
|
60
|
+
const ui = (dialogProps?: {
|
|
61
|
+
open?: boolean;
|
|
62
|
+
initialInstanceId?: string | null;
|
|
63
|
+
}) => (
|
|
64
|
+
<StigmerContext.Provider value={mockClient}>
|
|
65
|
+
<WorkflowRunDialog
|
|
66
|
+
open={dialogProps?.open ?? true}
|
|
67
|
+
onOpenChange={vi.fn()}
|
|
68
|
+
org="acme"
|
|
69
|
+
workflow={makeWorkflow()}
|
|
70
|
+
instances={INSTANCES}
|
|
71
|
+
defaultInstanceId={DEFAULT_INSTANCE_ID}
|
|
72
|
+
initialInstanceId={dialogProps?.initialInstanceId}
|
|
73
|
+
onSuccess={vi.fn()}
|
|
74
|
+
onError={vi.fn()}
|
|
75
|
+
/>
|
|
76
|
+
</StigmerContext.Provider>
|
|
77
|
+
);
|
|
78
|
+
const result = render(ui(props));
|
|
79
|
+
return {
|
|
80
|
+
...result,
|
|
81
|
+
rerenderDialog: (nextProps?: {
|
|
82
|
+
open?: boolean;
|
|
83
|
+
initialInstanceId?: string | null;
|
|
84
|
+
}) => result.rerender(ui(nextProps)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function instanceSelect(): HTMLSelectElement {
|
|
89
|
+
return screen.getByLabelText("Instance") as HTMLSelectElement;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("WorkflowRunDialog initialInstanceId", () => {
|
|
93
|
+
it("preselects the requested instance when the dialog opens", () => {
|
|
94
|
+
renderDialog({ initialInstanceId: "wfi_b" });
|
|
95
|
+
|
|
96
|
+
expect(instanceSelect().value).toBe("wfi_b");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("defaults to the server-resolved option when omitted", () => {
|
|
100
|
+
renderDialog();
|
|
101
|
+
|
|
102
|
+
// "" is the "Default (no specific configuration)" option.
|
|
103
|
+
expect(instanceSelect().value).toBe("");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("falls back to the default option when the id is stale", () => {
|
|
107
|
+
renderDialog({ initialInstanceId: "wfi_deleted" });
|
|
108
|
+
|
|
109
|
+
expect(instanceSelect().value).toBe("");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("applies the fresh preselection on each open transition", () => {
|
|
113
|
+
const { rerenderDialog } = renderDialog({ initialInstanceId: "wfi_a" });
|
|
114
|
+
expect(instanceSelect().value).toBe("wfi_a");
|
|
115
|
+
|
|
116
|
+
// Close, then reopen targeting a different instance (a second row's
|
|
117
|
+
// Run click) — the reset-then-preselect path must apply the new id.
|
|
118
|
+
rerenderDialog({ open: false, initialInstanceId: "wfi_a" });
|
|
119
|
+
rerenderDialog({ open: true, initialInstanceId: "wfi_b" });
|
|
120
|
+
expect(instanceSelect().value).toBe("wfi_b");
|
|
121
|
+
|
|
122
|
+
// Reopen with none (the header Run button) — back to the default.
|
|
123
|
+
rerenderDialog({ open: false, initialInstanceId: "wfi_b" });
|
|
124
|
+
rerenderDialog({ open: true, initialInstanceId: null });
|
|
125
|
+
expect(instanceSelect().value).toBe("");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { GraphHistory, DeleteNodeCommand } from "../graph-commands";
|
|
3
|
+
import { yamlToGraph } from "../workflow-graph-conversions";
|
|
4
|
+
import { START_NODE_ID, END_NODE_ID } from "../workflow-graph-model";
|
|
5
|
+
|
|
6
|
+
// Direct coverage for the GraphHistory command stack and the
|
|
7
|
+
// DeleteNodeCommand round-trip. Before oss#588 nothing exercised
|
|
8
|
+
// GraphHistory itself — the canvas-sync bug lived one layer up
|
|
9
|
+
// (useGraphHistory/useWorkflowCanvas), but the stack's synchronous
|
|
10
|
+
// return-the-model contract is what that fix builds on, so it is
|
|
11
|
+
// pinned here.
|
|
12
|
+
|
|
13
|
+
const FIXTURE_YAML = `apiVersion: agentic.stigmer.ai/v1
|
|
14
|
+
kind: Workflow
|
|
15
|
+
metadata:
|
|
16
|
+
name: undo-fixture
|
|
17
|
+
spec:
|
|
18
|
+
document:
|
|
19
|
+
dsl: "1.0.0"
|
|
20
|
+
namespace: test
|
|
21
|
+
name: undo-fixture
|
|
22
|
+
version: "0.0.1"
|
|
23
|
+
tasks:
|
|
24
|
+
- name: step_1
|
|
25
|
+
kind: agent_call
|
|
26
|
+
task_config:
|
|
27
|
+
agent: "org/agent-slug"
|
|
28
|
+
message: "first"
|
|
29
|
+
flow:
|
|
30
|
+
then: step_2
|
|
31
|
+
- name: step_2
|
|
32
|
+
kind: agent_call
|
|
33
|
+
task_config:
|
|
34
|
+
agent: "org/agent-slug"
|
|
35
|
+
message: "second"
|
|
36
|
+
flow:
|
|
37
|
+
then: end
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
function nodeIds(model: { nodes: readonly { id: string }[] }): string[] {
|
|
41
|
+
return model.nodes.map((n) => n.id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("GraphHistory — delete/undo/redo round-trip", () => {
|
|
45
|
+
it("dispatch(DeleteNodeCommand) removes the node and its edges", () => {
|
|
46
|
+
const initial = yamlToGraph(FIXTURE_YAML);
|
|
47
|
+
const history = new GraphHistory(initial);
|
|
48
|
+
|
|
49
|
+
const next = history.dispatch(new DeleteNodeCommand("step_2", "step_2"));
|
|
50
|
+
|
|
51
|
+
expect(nodeIds(next)).not.toContain("step_2");
|
|
52
|
+
expect(next.edges.some((e) => e.source === "step_2" || e.target === "step_2")).toBe(false);
|
|
53
|
+
expect(history.canUndo).toBe(true);
|
|
54
|
+
expect(history.canRedo).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("undo() synchronously returns the model with the node and edges restored", () => {
|
|
58
|
+
const initial = yamlToGraph(FIXTURE_YAML);
|
|
59
|
+
const history = new GraphHistory(initial);
|
|
60
|
+
const deleted = history.dispatch(new DeleteNodeCommand("step_2", "step_2"));
|
|
61
|
+
const removedEdgeCount = initial.edges.length - deleted.edges.length;
|
|
62
|
+
expect(removedEdgeCount).toBeGreaterThan(0);
|
|
63
|
+
|
|
64
|
+
const restored = history.undo();
|
|
65
|
+
|
|
66
|
+
expect(nodeIds(restored)).toContain("step_2");
|
|
67
|
+
expect(restored.edges).toHaveLength(initial.edges.length);
|
|
68
|
+
expect(history.current).toBe(restored);
|
|
69
|
+
expect(history.canUndo).toBe(false);
|
|
70
|
+
expect(history.canRedo).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("redo() synchronously returns the model with the node removed again", () => {
|
|
74
|
+
const initial = yamlToGraph(FIXTURE_YAML);
|
|
75
|
+
const history = new GraphHistory(initial);
|
|
76
|
+
history.dispatch(new DeleteNodeCommand("step_2", "step_2"));
|
|
77
|
+
history.undo();
|
|
78
|
+
|
|
79
|
+
const redone = history.redo();
|
|
80
|
+
|
|
81
|
+
expect(nodeIds(redone)).not.toContain("step_2");
|
|
82
|
+
expect(history.canUndo).toBe(true);
|
|
83
|
+
expect(history.canRedo).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("undo()/redo() on an empty stack return the current model unchanged", () => {
|
|
87
|
+
const initial = yamlToGraph(FIXTURE_YAML);
|
|
88
|
+
const history = new GraphHistory(initial);
|
|
89
|
+
|
|
90
|
+
expect(history.undo()).toBe(initial);
|
|
91
|
+
expect(history.redo()).toBe(initial);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("sentinels survive the round-trip", () => {
|
|
95
|
+
const initial = yamlToGraph(FIXTURE_YAML);
|
|
96
|
+
const history = new GraphHistory(initial);
|
|
97
|
+
history.dispatch(new DeleteNodeCommand("step_1", "step_1"));
|
|
98
|
+
const restored = history.undo();
|
|
99
|
+
|
|
100
|
+
expect(nodeIds(restored)).toContain(START_NODE_ID);
|
|
101
|
+
expect(nodeIds(restored)).toContain(END_NODE_ID);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -51,6 +51,23 @@ describe("Overview graph pipeline", () => {
|
|
|
51
51
|
expect((taskNode!.data as CanvasTaskNodeData).isSentinel).toBeFalsy();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test("sentinel nodes carry the pseudo-kind as kindString, never the enum fallback (oss#581)", () => {
|
|
55
|
+
const laid = applyDagreLayout(threeNodeGraph);
|
|
56
|
+
const { nodes } = toReactFlowElements(laid);
|
|
57
|
+
const startData = nodes.find((n) => n.id === START_NODE_ID)!.data as CanvasTaskNodeData;
|
|
58
|
+
const endData = nodes.find((n) => n.id === END_NODE_ID)!.data as CanvasTaskNodeData;
|
|
59
|
+
expect(startData.kindString).toBe(START_NODE_ID);
|
|
60
|
+
expect(endData.kindString).toBe(END_NODE_ID);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("no node's kindString is ever the internal unknown_* fallback", () => {
|
|
64
|
+
const laid = applyDagreLayout(threeNodeGraph);
|
|
65
|
+
const { nodes } = toReactFlowElements(laid);
|
|
66
|
+
for (const node of nodes) {
|
|
67
|
+
expect((node.data as CanvasTaskNodeData).kindString).not.toMatch(/^unknown_/);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
54
71
|
test("task nodes carry correct kindString and category", () => {
|
|
55
72
|
const laid = applyDagreLayout(threeNodeGraph);
|
|
56
73
|
const { nodes } = toReactFlowElements(laid);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { renderHook } from "@testing-library/react";
|
|
3
|
+
import { useCanvasKeyboardShortcuts } from "../useCanvasKeyboardShortcuts";
|
|
4
|
+
import type { UseCanvasKeyboardShortcutsOptions } from "../useCanvasKeyboardShortcuts";
|
|
5
|
+
|
|
6
|
+
// Keyboard undo/redo binding (moved here from useGraphHistory with the
|
|
7
|
+
// oss#588 fix so the shortcut routes through the orchestrator's
|
|
8
|
+
// canvas-syncing undo/redo instead of a raw model mutation).
|
|
9
|
+
|
|
10
|
+
let container: HTMLDivElement;
|
|
11
|
+
|
|
12
|
+
function makeOptions(
|
|
13
|
+
overrides: Partial<UseCanvasKeyboardShortcutsOptions> = {},
|
|
14
|
+
): UseCanvasKeyboardShortcutsOptions {
|
|
15
|
+
return {
|
|
16
|
+
containerRef: { current: container },
|
|
17
|
+
selection: null,
|
|
18
|
+
duplicateNode: vi.fn(),
|
|
19
|
+
selectAll: vi.fn(),
|
|
20
|
+
clearSelection: vi.fn(),
|
|
21
|
+
onRequestTaskPicker: vi.fn(),
|
|
22
|
+
onDismiss: vi.fn(),
|
|
23
|
+
undo: vi.fn(),
|
|
24
|
+
redo: vi.fn(),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pressModZ(target: EventTarget, shiftKey = false) {
|
|
30
|
+
target.dispatchEvent(
|
|
31
|
+
new KeyboardEvent("keydown", {
|
|
32
|
+
key: "z",
|
|
33
|
+
metaKey: true,
|
|
34
|
+
shiftKey,
|
|
35
|
+
bubbles: true,
|
|
36
|
+
cancelable: true,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
container = document.createElement("div");
|
|
43
|
+
container.tabIndex = -1;
|
|
44
|
+
document.body.appendChild(container);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
container.remove();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("useCanvasKeyboardShortcuts — undo/redo", () => {
|
|
52
|
+
it("Mod+Z triggers undo when focus is inside the container", () => {
|
|
53
|
+
const options = makeOptions();
|
|
54
|
+
renderHook(() => useCanvasKeyboardShortcuts(options));
|
|
55
|
+
|
|
56
|
+
container.focus();
|
|
57
|
+
pressModZ(container);
|
|
58
|
+
|
|
59
|
+
expect(options.undo).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(options.redo).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("Mod+Shift+Z triggers redo when focus is inside the container", () => {
|
|
64
|
+
const options = makeOptions();
|
|
65
|
+
renderHook(() => useCanvasKeyboardShortcuts(options));
|
|
66
|
+
|
|
67
|
+
container.focus();
|
|
68
|
+
pressModZ(container, true);
|
|
69
|
+
|
|
70
|
+
expect(options.redo).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(options.undo).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("Mod+Z does nothing when focus is outside the container", () => {
|
|
75
|
+
const options = makeOptions();
|
|
76
|
+
renderHook(() => useCanvasKeyboardShortcuts(options));
|
|
77
|
+
|
|
78
|
+
const outside = document.createElement("button");
|
|
79
|
+
document.body.appendChild(outside);
|
|
80
|
+
outside.focus();
|
|
81
|
+
pressModZ(outside);
|
|
82
|
+
|
|
83
|
+
expect(options.undo).not.toHaveBeenCalled();
|
|
84
|
+
outside.remove();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("Mod+Z in a text input inside the container keeps native text undo", () => {
|
|
88
|
+
const options = makeOptions();
|
|
89
|
+
renderHook(() => useCanvasKeyboardShortcuts(options));
|
|
90
|
+
|
|
91
|
+
const input = document.createElement("input");
|
|
92
|
+
container.appendChild(input);
|
|
93
|
+
input.focus();
|
|
94
|
+
pressModZ(input);
|
|
95
|
+
|
|
96
|
+
expect(options.undo).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("Ctrl+Z (non-Mac modifier) also triggers undo", () => {
|
|
100
|
+
const options = makeOptions();
|
|
101
|
+
renderHook(() => useCanvasKeyboardShortcuts(options));
|
|
102
|
+
|
|
103
|
+
container.focus();
|
|
104
|
+
container.dispatchEvent(
|
|
105
|
+
new KeyboardEvent("keydown", {
|
|
106
|
+
key: "z",
|
|
107
|
+
ctrlKey: true,
|
|
108
|
+
bubbles: true,
|
|
109
|
+
cancelable: true,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(options.undo).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ReactFlowProvider } from "@xyflow/react";
|
|
5
|
+
import { useWorkflowCanvas } from "../useWorkflowCanvas";
|
|
6
|
+
|
|
7
|
+
// Regression tests for oss#588: undoing a node deletion restored the graph
|
|
8
|
+
// MODEL (inspector rebinds, history entry consumed) but the React Flow
|
|
9
|
+
// nodes array was synced from a stale pre-undo snapshot, so the canvas
|
|
10
|
+
// node never reappeared. These tests exercise the hook composition — the
|
|
11
|
+
// exact layer the bug lived in; the pure command stack is covered in
|
|
12
|
+
// graph-history.test.ts.
|
|
13
|
+
|
|
14
|
+
const FIXTURE_YAML = `apiVersion: agentic.stigmer.ai/v1
|
|
15
|
+
kind: Workflow
|
|
16
|
+
metadata:
|
|
17
|
+
name: undo-fixture
|
|
18
|
+
spec:
|
|
19
|
+
document:
|
|
20
|
+
dsl: "1.0.0"
|
|
21
|
+
namespace: test
|
|
22
|
+
name: undo-fixture
|
|
23
|
+
version: "0.0.1"
|
|
24
|
+
tasks:
|
|
25
|
+
- name: step_1
|
|
26
|
+
kind: agent_call
|
|
27
|
+
task_config:
|
|
28
|
+
agent: "org/agent-slug"
|
|
29
|
+
message: "first"
|
|
30
|
+
flow:
|
|
31
|
+
then: step_2
|
|
32
|
+
- name: step_2
|
|
33
|
+
kind: agent_call
|
|
34
|
+
task_config:
|
|
35
|
+
agent: "org/agent-slug"
|
|
36
|
+
message: "second"
|
|
37
|
+
flow:
|
|
38
|
+
then: end
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function wrapper({ children }: { children: ReactNode }) {
|
|
42
|
+
return <ReactFlowProvider>{children}</ReactFlowProvider>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderCanvas() {
|
|
46
|
+
return renderHook(() => useWorkflowCanvas(FIXTURE_YAML), { wrapper });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function canvasNodeIds(result: { current: { nodes: readonly { id: string }[] } }): string[] {
|
|
50
|
+
return result.current.nodes.map((n) => n.id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("useWorkflowCanvas — undo/redo canvas sync (oss#588)", () => {
|
|
54
|
+
it("parses the fixture into canvas nodes", () => {
|
|
55
|
+
const { result } = renderCanvas();
|
|
56
|
+
expect(canvasNodeIds(result)).toEqual(
|
|
57
|
+
expect.arrayContaining(["step_1", "step_2"]),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("undo after a node delete restores the node on the CANVAS, not just the model", () => {
|
|
62
|
+
const { result } = renderCanvas();
|
|
63
|
+
|
|
64
|
+
const target = result.current.nodes.find((n) => n.id === "step_2");
|
|
65
|
+
expect(target).toBeDefined();
|
|
66
|
+
|
|
67
|
+
act(() => {
|
|
68
|
+
result.current.onNodesDelete([target!]);
|
|
69
|
+
});
|
|
70
|
+
expect(canvasNodeIds(result)).not.toContain("step_2");
|
|
71
|
+
expect(result.current.canUndo).toBe(true);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current.undo();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The model restores (this always worked — the inspector saw it) …
|
|
78
|
+
expect(result.current.graph?.nodes.map((n) => n.id)).toContain("step_2");
|
|
79
|
+
// … and the canvas must restore with it (the oss#588 gap).
|
|
80
|
+
expect(canvasNodeIds(result)).toContain("step_2");
|
|
81
|
+
expect(result.current.canUndo).toBe(false);
|
|
82
|
+
expect(result.current.canRedo).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("undo restores the deleted node's edges on the canvas", () => {
|
|
86
|
+
const { result } = renderCanvas();
|
|
87
|
+
const edgeCountBefore = result.current.edges.length;
|
|
88
|
+
|
|
89
|
+
const target = result.current.nodes.find((n) => n.id === "step_2");
|
|
90
|
+
act(() => {
|
|
91
|
+
result.current.onNodesDelete([target!]);
|
|
92
|
+
});
|
|
93
|
+
expect(result.current.edges.length).toBeLessThan(edgeCountBefore);
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
result.current.undo();
|
|
97
|
+
});
|
|
98
|
+
expect(result.current.edges.length).toBe(edgeCountBefore);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("redo after an undo removes the node from the canvas again", () => {
|
|
102
|
+
const { result } = renderCanvas();
|
|
103
|
+
|
|
104
|
+
const target = result.current.nodes.find((n) => n.id === "step_2");
|
|
105
|
+
act(() => {
|
|
106
|
+
result.current.onNodesDelete([target!]);
|
|
107
|
+
});
|
|
108
|
+
act(() => {
|
|
109
|
+
result.current.undo();
|
|
110
|
+
});
|
|
111
|
+
expect(canvasNodeIds(result)).toContain("step_2");
|
|
112
|
+
|
|
113
|
+
act(() => {
|
|
114
|
+
result.current.redo();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.current.graph?.nodes.map((n) => n.id)).not.toContain("step_2");
|
|
118
|
+
expect(canvasNodeIds(result)).not.toContain("step_2");
|
|
119
|
+
expect(result.current.canUndo).toBe(true);
|
|
120
|
+
expect(result.current.canRedo).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("undo with an empty history leaves the canvas untouched", () => {
|
|
124
|
+
const { result } = renderCanvas();
|
|
125
|
+
const before = canvasNodeIds(result);
|
|
126
|
+
|
|
127
|
+
act(() => {
|
|
128
|
+
result.current.undo();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(canvasNodeIds(result)).toEqual(before);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useId, useRef, useState } from "react";
|
|
4
4
|
import { cn } from "@stigmer/theme";
|
|
5
5
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
6
6
|
import type { WorkflowInstance } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/api_pb";
|
|
@@ -42,6 +42,15 @@ export function CreateWorkflowInstanceDialog({
|
|
|
42
42
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
43
43
|
const { create, isCreating, error, clearError } = useCreateWorkflowInstance();
|
|
44
44
|
|
|
45
|
+
// Instance-scoped element ids (oss#571): a reusable component must not
|
|
46
|
+
// hardcode DOM ids — hosts legitimately mount this dialog more than once
|
|
47
|
+
// per page (e.g. zone-cached detail pages), and duplicate ids silently
|
|
48
|
+
// break the label→input association for every copy after the first.
|
|
49
|
+
const baseId = useId();
|
|
50
|
+
const titleId = `${baseId}-title`;
|
|
51
|
+
const nameId = `${baseId}-name`;
|
|
52
|
+
const descriptionId = `${baseId}-description`;
|
|
53
|
+
|
|
45
54
|
const [name, setName] = useState("");
|
|
46
55
|
const [description, setDescription] = useState("");
|
|
47
56
|
const [environmentRefs, setEnvironmentRefs] = useState<ResourceRef[]>([]);
|
|
@@ -108,12 +117,12 @@ export function CreateWorkflowInstanceDialog({
|
|
|
108
117
|
"stg:fixed stg:inset-0 stg:m-auto stg:w-full stg:max-w-lg stg:rounded-xl stg:border stg:border-border stg:bg-popover stg:p-0 stg:shadow-xl",
|
|
109
118
|
"stg:backdrop:bg-black/50",
|
|
110
119
|
)}
|
|
111
|
-
aria-labelledby=
|
|
120
|
+
aria-labelledby={titleId}
|
|
112
121
|
>
|
|
113
122
|
<form onSubmit={handleSubmit} className="stg:flex stg:flex-col">
|
|
114
123
|
{/* Header */}
|
|
115
124
|
<div className="stg:flex stg:items-center stg:justify-between stg:border-b stg:border-border stg:px-6 stg:py-4">
|
|
116
|
-
<h2 id=
|
|
125
|
+
<h2 id={titleId} className="stg:text-base stg:font-semibold stg:text-foreground">
|
|
117
126
|
Create Workflow Instance
|
|
118
127
|
</h2>
|
|
119
128
|
<button
|
|
@@ -134,11 +143,11 @@ export function CreateWorkflowInstanceDialog({
|
|
|
134
143
|
<div className="stg:space-y-5 stg:px-6 stg:py-5">
|
|
135
144
|
{/* Name */}
|
|
136
145
|
<div>
|
|
137
|
-
<label htmlFor=
|
|
146
|
+
<label htmlFor={nameId} className="stg:block stg:text-sm stg:font-medium stg:text-foreground stg:mb-1.5">
|
|
138
147
|
Name <span className="stg:text-destructive">*</span>
|
|
139
148
|
</label>
|
|
140
149
|
<input
|
|
141
|
-
id=
|
|
150
|
+
id={nameId}
|
|
142
151
|
type="text"
|
|
143
152
|
required
|
|
144
153
|
value={name}
|
|
@@ -160,11 +169,11 @@ export function CreateWorkflowInstanceDialog({
|
|
|
160
169
|
|
|
161
170
|
{/* Description */}
|
|
162
171
|
<div>
|
|
163
|
-
<label htmlFor=
|
|
172
|
+
<label htmlFor={descriptionId} className="stg:block stg:text-sm stg:font-medium stg:text-foreground stg:mb-1.5">
|
|
164
173
|
Description
|
|
165
174
|
</label>
|
|
166
175
|
<textarea
|
|
167
|
-
id=
|
|
176
|
+
id={descriptionId}
|
|
168
177
|
value={description}
|
|
169
178
|
onChange={(e) => setDescription(e.target.value)}
|
|
170
179
|
placeholder="What is this instance used for?"
|
|
@@ -10,8 +10,11 @@ export interface UseDeleteWorkflowInstanceReturn {
|
|
|
10
10
|
/**
|
|
11
11
|
* Delete a workflow instance by ID. Returns the deleted resource.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Deletion does NOT cascade to executions: runs already created
|
|
14
|
+
* against the instance are preserved and stay visible in the parent
|
|
15
|
+
* workflow's execution history (every execution carries a
|
|
16
|
+
* denormalized workflow reference). Only the instance itself — its
|
|
17
|
+
* environment bindings and configuration — is removed.
|
|
15
18
|
*/
|
|
16
19
|
readonly deleteInstance: (id: string) => Promise<WorkflowInstance>;
|
|
17
20
|
/** `true` while the delete request is in flight. */
|
|
@@ -29,8 +32,10 @@ export interface UseDeleteWorkflowInstanceReturn {
|
|
|
29
32
|
* The caller is responsible for handling post-delete UI updates
|
|
30
33
|
* (e.g., refreshing the instance list, closing a detail panel).
|
|
31
34
|
*
|
|
32
|
-
* Deletion
|
|
33
|
-
*
|
|
35
|
+
* Deletion is instance-only on both editions — executions are NOT
|
|
36
|
+
* cascaded (deliberate platform posture: they keep a denormalized
|
|
37
|
+
* workflow reference and remain in the workflow's execution history).
|
|
38
|
+
* Confirmation UI must not claim execution history is deleted.
|
|
34
39
|
*
|
|
35
40
|
* @example
|
|
36
41
|
* ```tsx
|